1import { Inject, Injectable, Logger } from "@nestjs/common";
2import { PrismaService } from "../../common/prisma.service.js";
3import { AuditService } from "../../common/audit.service.js";
4import { EventBus } from "../../common/event-bus.js";
5import { EmailService } from "../../common/email.service.js";
6import { ApiError } from "../../common/errors.js";
7import { sha256hex, randomToken } from "../../lib/crypto.js";
8import { actorMembership, assertCan, assertRole } from "../../lib/authorize.js";
9import { parsePagination } from "../../lib/pagination.js";
10import { pageActivity } from "../../lib/activity.js";
11import { EVENTS, MAX_FILE_SIZE_DEFAULT } from "../../constants.js";
12import { seedBuiltinTemplatesForWorkspace } from "@factory/shared";
13import { effectiveStorageQuota } from "../files/quota.js";
14
15const WS_SELECT = {
16 id: true, name: true, slug: true, description: true, iconUrl: true,
17 isPersonal: true, ownerId: true, billingPlan: true, usageLimits: true,
18 organizationId: true, createdAt: true, updatedAt: true, deletedAt: true,
19
20 type: true, archivedAt: true
21} as const;
22
23@Injectable()
24export class WorkspacesService {
25 private readonly log = new Logger(WorkspacesService.name);
26
27 constructor(
28 @Inject(PrismaService) private readonly prisma: PrismaService,
29 @Inject(AuditService) private readonly audit: AuditService,
30 @Inject(EventBus) private readonly events: EventBus,
31 @Inject(EmailService) private readonly email: EmailService
32 ) {}
33
34
35
36 private async getWorkspace(idOrSlug: string) {
37
38
39
40
41
42
43 let ws = await this.prisma.workspace.findUnique({ where: { id: idOrSlug }, select: WS_SELECT });
44 if (!ws) {
45 ws = await this.prisma.workspace.findUnique({ where: { slug: idOrSlug }, select: WS_SELECT });
46 }
47 if (!ws || ws.deletedAt) throw ApiError.notFound();
48 return ws;
49 }
50
51 private async requireMembership(workspaceId: string, userId: string) {
52
53
54
55 const m = await actorMembership(this.prisma, userId, workspaceId);
56 if (!m) throw ApiError.forbidden("Not a member of this workspace.", [{ reason: "not_workspace_member" }]);
57 return m;
58 }
59
60
61
62
69 async listMine(userId: string, query?: { search?: string; type?: string; archived?: string; sort?: string }) {
70 const showArchived = query?.archived === "true";
71 const memberships = await this.prisma.workspaceMember.findMany({
72 where: { userId },
73 include: { workspace: { select: WS_SELECT } },
74 orderBy: { createdAt: "asc" }
75 });
76 let rows = memberships
77 .filter((m) => !m.workspace.deletedAt)
78 .filter((m) => (showArchived ? true : !m.workspace.archivedAt))
79 .map((m) => ({ ...m.workspace, role: m.role }));
80 if (query?.type) rows = rows.filter((w) => w.type === query.type);
81 if (query?.search) {
82 const q = query.search.toLowerCase();
83 rows = rows.filter((w) =>
84 w.name.toLowerCase().includes(q) || (w.description ?? "").toLowerCase().includes(q)
85 );
86 }
87 switch (query?.sort) {
88 case "name": rows.sort((a, b) => a.name.localeCompare(b.name)); break;
89 case "recent": rows.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); break;
90 default: break;
91 }
92 return { data: rows };
93 }
94
95 async create(userId: string, input: { name: string; slug?: string }) {
96 const slug = (input.slug ?? slugify(input.name)) + "-" + randomToken(4).toLowerCase().replace(/[^a-z0-9]/g, "x").slice(0, 4);
97 let workspace;
98 try {
99 workspace = await this.prisma.workspace.create({
100 data: { name: input.name, slug, isPersonal: false, ownerId: userId },
101 select: WS_SELECT
102 });
103 } catch {
104 throw new ApiError("CONFLICT", 409, "Workspace slug already exists.", [{ field: "slug", issue: "already_exists" }]);
105 }
106 await this.prisma.workspaceMember.create({
107 data: { workspaceId: workspace.id, userId, role: "OWNER", joinedAt: new Date() }
108 });
109
110
111 await seedBuiltinTemplatesForWorkspace(this.prisma, workspace.id, userId).catch((e) => {
112 this.log.warn(`builtin template seeding failed for workspace ${workspace.id}: ${e instanceof Error ? e.message : e}`);
113 });
114 await this.events.publish(EVENTS.WORKSPACE_CREATED, { workspaceId: workspace.id, name: workspace.name, personal: false });
115 await this.audit.write({
116 actorId: userId,
117 action: "workspace.created",
118 resourceType: "workspace",
119 resourceId: workspace.id,
120 workspaceId: workspace.id
121 });
122 return { ...workspace, role: "OWNER" };
123 }
124
125 async get(workspaceId: string, userId: string) {
126
127
128
129
130
131
132
133 const ws = await this.getWorkspace(workspaceId);
134 const membership = await this.requireMembership(ws.id, userId);
135 return { ...ws, role: membership.role };
136 }
137
138 async update(workspaceId: string, userId: string, input: { name?: string; slug?: string; description?: string | null; iconUrl?: string | null; type?: string }, req: any) {
139 await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]);
140 if (input.slug) {
141 const taken = await this.prisma.workspace.findUnique({ where: { slug: input.slug } });
142 if (taken && taken.id !== workspaceId) {
143 throw new ApiError("CONFLICT", 409, "Workspace slug already exists.", [{ field: "slug", issue: "already_exists" }]);
144 }
145 }
146 const ws = await this.prisma.workspace.update({
147 where: { id: workspaceId },
148 data: {
149 ...(input.name !== undefined ? { name: input.name } : {}),
150 ...(input.slug !== undefined ? { slug: input.slug } : {}),
151 ...(input.description !== undefined ? { description: input.description } : {}),
152 ...(input.iconUrl !== undefined ? { iconUrl: input.iconUrl } : {}),
153 ...(input.type !== undefined ? { type: input.type } : {})
154 },
155 select: WS_SELECT
156 });
157 await this.audit.audit(req, req?.auth, {
158 action: "workspace.updated",
159 resourceType: "workspace",
160 resourceId: workspaceId,
161 metadata: input.type !== undefined ? { field: "type", to: input.type } : undefined
162 });
163 return ws;
164 }
165
166
167
168
169 async archive(workspaceId: string, userId: string, req: any) {
170 const ws = await this.getWorkspace(workspaceId);
171 await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]);
172 if (ws.archivedAt) return { archived: true, archivedAt: ws.archivedAt };
173 const updated = await this.prisma.workspace.update({
174 where: { id: workspaceId },
175 data: { archivedAt: new Date() },
176 select: WS_SELECT
177 });
178 await this.audit.audit(req, req?.auth, {
179 action: "workspace.archived",
180 resourceType: "workspace",
181 resourceId: workspaceId,
182 workspaceId
183 });
184 return { archived: true, archivedAt: updated.archivedAt };
185 }
186
187 async restoreWorkspace(workspaceId: string, userId: string, req: any) {
188 const ws = await this.getWorkspace(workspaceId);
189 await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]);
190 if (!ws.archivedAt) return { archived: false };
191 await this.prisma.workspace.update({
192 where: { id: workspaceId },
193 data: { archivedAt: null }
194 });
195 await this.audit.audit(req, req?.auth, {
196 action: "workspace.restored",
197 resourceType: "workspace",
198 resourceId: workspaceId,
199 workspaceId
200 });
201 return { archived: false };
202 }
203
204
208 async stats(workspaceId: string, userId: string) {
209 const ws = await this.getWorkspace(workspaceId);
210 await this.requireMembership(workspaceId, userId);
211 const [pages, whiteboards, tasks, databases, records, comments, members, fileAgg] = await Promise.all([
212 this.prisma.page.count({ where: { workspaceId, trashedAt: null, deletedAt: null } }),
213 this.prisma.page.count({ where: { workspaceId, kind: "whiteboard", trashedAt: null, deletedAt: null } }),
214 this.prisma.page.count({ where: { workspaceId, kind: "task", trashedAt: null, deletedAt: null } }),
215 this.prisma.database.count({ where: { workspaceId, isArchived: false } }),
216 this.prisma.databaseRecord.count({ where: { workspaceId, archivedAt: null } }),
217 this.prisma.comment.count({ where: { workspaceId } }),
218 this.prisma.workspaceMember.count({ where: { workspaceId } }),
219 this.prisma.file.aggregate({ where: { workspaceId, deletedAt: null }, _sum: { size: true } })
220 ]);
221 const wsRow = await this.prisma.workspace.findUnique({
222 where: { id: workspaceId },
223 select: { usageStorageBytes: true }
224 });
225 return {
226 workspaceId,
227 type: ws.type,
228 archivedAt: ws.archivedAt,
229 content: {
230 pages,
231 whiteboards,
232 tasks,
233 databases,
234 records,
235 comments,
236 members,
237 storageBytes: wsRow?.usageStorageBytes ?? 0n
238 }
239 };
240 }
241
242
243 async remove(workspaceId: string, userId: string, req: any) {
244 await assertRole(this.prisma, { workspaceId, userId }, ["OWNER"]);
245 const ws = await this.getWorkspace(workspaceId);
246 await this.prisma.$transaction(async (tx) => {
247
248
249 await tx.workspace.delete({ where: { id: workspaceId } });
250 });
251 await this.audit.audit(req, req?.auth, {
252 action: "workspace.deleted",
253 resourceType: "workspace",
254 resourceId: workspaceId,
255 metadata: { name: ws.name }
256 });
257 }
258
259
260
261 async listMembers(workspaceId: string, userId: string, query: { search?: string; cursor?: string; limit?: string }) {
262 await this.requireMembership(workspaceId, userId);
263 const { cursor, limit } = parsePagination(query);
264 const members = await this.prisma.workspaceMember.findMany({
265 where: {
266 workspaceId,
267 ...(query.search
268 ? { user: { OR: [{ email: { contains: query.search, mode: "insensitive" } }, { name: { contains: query.search, mode: "insensitive" } }] } }
269 : {})
270 },
271 include: { user: { select: { id: true, email: true, name: true, displayName: true, avatarUrl: true } } },
272 orderBy: { createdAt: "asc" },
273 take: limit + 1
274 });
275 const page = members.slice(0, limit);
276 const next = members.length > limit ? page[page.length - 1] : undefined;
277 return {
278 data: page.map((m) => ({
279 userId: m.userId,
280 email: m.user.email,
281 name: m.user.name,
282 displayName: m.user.displayName ?? m.user.name,
283 avatarUrl: m.user.avatarUrl,
284 role: m.role,
285 joinedAt: m.joinedAt
286 })),
287 nextCursor: next ? Buffer.from(JSON.stringify({ id: next.id, position: next.id })).toString("base64url") : null
288 };
289 }
290
291 async invite(workspaceId: string, userId: string, input: { email: string; role: string }, req: any) {
292 await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]);
293 const ws = await this.getWorkspace(workspaceId);
294 const email = input.email.toLowerCase();
295 const existingUser = await this.prisma.user.findUnique({ where: { email } });
296 if (existingUser) {
297 const existing = await this.prisma.workspaceMember.findUnique({
298 where: { workspaceId_userId: { workspaceId, userId: existingUser.id } }
299 });
300 if (existing) {
301 throw new ApiError("CONFLICT", 409, "User is already a member.", [{ field: "email", issue: "already_member" }]);
302 }
303 }
304 const token = `inv_${randomToken(32)}`;
305 let invitation;
306 try {
307 invitation = await this.prisma.invitation.create({
308 data: {
309 workspaceId,
310 email,
311 role: input.role as never,
312 invitedById: userId,
313 tokenHash: sha256hex(token),
314 expiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000)
315 }
316 });
317 } catch {
318 throw new ApiError("CONFLICT", 409, "An invitation with this email and role already exists.", [
319 { field: "email", issue: "already_invited" }
320 ]);
321 }
322 this.email.sendInvitation(email, ws.name, token);
323 await this.audit.audit(req, req?.auth, {
324 action: "workspace.invite_sent",
325 resourceType: "invitation",
326 resourceId: invitation.id,
327 workspaceId,
328 metadata: { email, role: input.role }
329 });
330 return { id: invitation.id, email: invitation.email, role: invitation.role, expiresAt: invitation.expiresAt };
331 }
332
333
334 async acceptInvite(workspaceId: string, userId: string, token: string, req: any) {
335 const user = await this.prisma.user.findUnique({ where: { id: userId } });
336 if (!user) throw ApiError.notFound();
337 const invitation = await this.prisma.invitation.findUnique({
338 where: { tokenHash: sha256hex(token) }
339 });
340 if (!invitation || invitation.workspaceId !== workspaceId) throw ApiError.notFound("Invitation not found.");
341 if (invitation.status !== "PENDING") {
342 throw new ApiError("CONFLICT", 409, "Invitation already processed.", [{ issue: "already_accepted" }]);
343 }
344 if (invitation.expiresAt.getTime() < Date.now()) {
345 await this.prisma.invitation.update({ where: { id: invitation.id }, data: { status: "EXPIRED" } });
346 throw new ApiError("GONE", 410, "Invitation expired.", [{ reason: "token_expired" }]);
347 }
348 if (invitation.email.toLowerCase() !== user.email.toLowerCase()) {
349 throw ApiError.forbidden("This invitation was issued to a different email.", [{ reason: "email_mismatch" }]);
350 }
351 await this.prisma.$transaction([
352 this.prisma.invitation.update({ where: { id: invitation.id }, data: { status: "ACCEPTED", acceptedAt: new Date() } }),
353 this.prisma.workspaceMember.upsert({
354 where: { workspaceId_userId: { workspaceId, userId } },
355 update: {},
356 create: { workspaceId, userId, role: invitation.role as never, invitedBy: invitation.invitedById, joinedAt: new Date() }
357 })
358 ]);
359 await this.audit.audit(req, req?.auth, {
360 action: "workspace.invite_accepted",
361 resourceType: "invitation",
362 resourceId: invitation.id,
363 workspaceId
364 });
365 return { workspaceId, role: invitation.role };
366 }
367
368 async updateMemberRole(workspaceId: string, actorId: string, targetUserId: string, role: string, req: any) {
369 await assertRole(this.prisma, { workspaceId, userId: actorId }, ["OWNER", "ADMIN"]);
370 const member = await this.prisma.workspaceMember.findUnique({
371 where: { workspaceId_userId: { workspaceId, userId: targetUserId } }
372 });
373 if (!member) throw ApiError.notFound("Member not found.");
374 if (role !== "OWNER") {
375
376 if (member.role === "OWNER") {
377 throw new ApiError("CONFLICT", 409, "Owner role can only change via workspace transfer.", [
378 { field: "role", issue: "owner_transfer_required" }
379 ]);
380 }
381 }
382 await this.prisma.workspaceMember.update({
383 where: { id: member.id },
384 data: { role: role as never }
385 });
386 await this.audit.audit(req, req?.auth, {
387 action: "workspace.member_role_changed",
388 resourceType: "workspace_member",
389 resourceId: member.id,
390 workspaceId,
391 metadata: { targetUserId, from: member.role, to: role }
392 });
393
394
395
396 await this.audit.audit(req, req?.auth, {
397 action: EVENTS.PERMISSION_CHANGED,
398 resourceType: "workspace_member",
399 resourceId: member.id,
400 workspaceId,
401 metadata: { change: "role", targetId: targetUserId, from: member.role, to: role }
402 });
403 await this.events.publish(EVENTS.PERMISSION_CHANGED, { workspaceId, userId: targetUserId, role });
404 return { userId: targetUserId, role };
405 }
406
407 async removeMember(workspaceId: string, actorId: string, targetUserId: string, req: any) {
408 const actorRole = await assertRole(this.prisma, { workspaceId, userId: actorId }, ["OWNER", "ADMIN"]);
409 const member = await this.prisma.workspaceMember.findUnique({
410 where: { workspaceId_userId: { workspaceId, userId: targetUserId } }
411 });
412 if (!member) throw ApiError.notFound("Member not found.");
413 if (member.role === "OWNER") {
414 throw new ApiError("CONFLICT", 409, "The workspace owner cannot be removed (transfer ownership first).", [
415 { issue: "last_owner" }
416 ]);
417 }
418 if (member.role === "ADMIN" && actorRole !== "OWNER") {
419 throw ApiError.forbidden("Only the owner can remove an admin.", [{ reason: "requires_owner" }]);
420 }
421 await this.prisma.workspaceMember.delete({ where: { id: member.id } });
422 await this.audit.audit(req, req?.auth, {
423 action: "workspace.member_removed",
424 resourceType: "workspace_member",
425 resourceId: member.id,
426 workspaceId,
427 metadata: { targetUserId, role: member.role }
428 });
429 await this.events.publish(EVENTS.PERMISSION_CHANGED, { workspaceId, userId: targetUserId, removed: true });
430 }
431
432
433
434 async usage(workspaceId: string, userId: string) {
435 await this.requireMembership(workspaceId, userId);
436 const ws = await this.getWorkspace(workspaceId);
437 const limits = (ws.usageLimits as Record<string, number>) ?? {};
438
439
440
441 const wsRow = await this.prisma.workspace.findUnique({
442 where: { id: workspaceId },
443 select: { usageStorageBytes: true },
444 });
445 const effectiveQuota = effectiveStorageQuota(ws.billingPlan, ws.usageLimits as { storageQuota?: number } | null);
446 const [pages, databases, records, fileAgg, apiKeys, webhooks] = await Promise.all([
447 this.prisma.page.count({ where: { workspaceId, trashedAt: null, deletedAt: null } }),
448 this.prisma.database.count({ where: { workspaceId, isArchived: false } }),
449 this.prisma.databaseRecord.count({ where: { workspaceId, archivedAt: null } }),
450 this.prisma.file.aggregate({ where: { workspaceId, deletedAt: null }, _sum: { size: true } }),
451 this.prisma.apiKey.count({ where: { workspaceId, revokedAt: null } }),
452 this.prisma.webhook.count({ where: { workspaceId } })
453 ]);
454 return {
455 workspaceId,
456 usage: {
457 pages,
458 databases,
459 records,
460
461
462 storageBytes: wsRow?.usageStorageBytes ?? 0n,
463
464
465
466 storageBytesComputed: fileAgg._sum.size ?? 0,
467 apiKeys,
468 webhooks
469 },
470 limits: {
471 maxFileSize: limits.maxFileSize ?? MAX_FILE_SIZE_DEFAULT,
472 maxPages: limits.maxPages ?? null,
473 maxDatabases: limits.maxDatabases ?? null,
474 maxRecords: limits.maxRecords ?? null,
475
476
477
478 maxStorageBytes: effectiveQuota,
479 maxApiKeys: limits.maxApiKeys ?? null,
480 maxWebhooks: limits.maxWebhooks ?? null
481 }
482 };
483 }
484
485 async transfer(workspaceId: string, actorId: string, newOwnerId: string, req: any) {
486 await assertRole(this.prisma, { workspaceId, userId: actorId }, ["OWNER"]);
487 const target = await this.prisma.workspaceMember.findUnique({
488 where: { workspaceId_userId: { workspaceId, userId: newOwnerId } }
489 });
490 if (!target) throw ApiError.notFound("New owner must be a workspace member.");
491 const previous = await this.prisma.workspaceMember.findUnique({
492 where: { workspaceId_userId: { workspaceId, userId: actorId } }
493 });
494 await this.prisma.$transaction([
495 this.prisma.workspaceMember.update({ where: { id: target.id }, data: { role: "OWNER" } }),
496 ...(previous ? [this.prisma.workspaceMember.update({ where: { id: previous.id }, data: { role: "ADMIN" } })] : []),
497 this.prisma.workspace.update({ where: { id: workspaceId }, data: { ownerId: newOwnerId } })
498 ]);
499 await this.audit.audit(req, req?.auth, {
500 action: "workspace.transferred",
501 resourceType: "workspace",
502 resourceId: workspaceId,
503 workspaceId,
504 metadata: { from: actorId, to: newOwnerId }
505 });
506 await this.events.publish(EVENTS.PERMISSION_CHANGED, { workspaceId, userId: newOwnerId, role: "OWNER" });
507 return { ownerId: newOwnerId };
508 }
509
510
511
512
517 async activity(workspaceId: string, userId: string, query: { cursor?: string; limit?: string }) {
518 const ws = await this.getWorkspace(workspaceId);
519 await this.requireMembership(ws.id, userId);
520 return pageActivity(this.prisma, { workspaceId: ws.id }, query);
521 }
522}
523
524function slugify(name: string): string {
525 return name
526 .toLowerCase()
527 .replace(/[^a-z0-9]+/g, "-")
528 .replace(/^-+|-+$/g, "")
529 .slice(0, 40) || "workspace";
530}
531