import { Inject, Injectable, Logger } from "@nestjs/common"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { EventBus } from "../../common/event-bus.js"; import { EmailService } from "../../common/email.service.js"; import { ApiError } from "../../common/errors.js"; import { sha256hex, randomToken } from "../../lib/crypto.js"; import { actorMembership, assertCan, assertRole } from "../../lib/authorize.js"; import { parsePagination } from "../../lib/pagination.js"; import { pageActivity } from "../../lib/activity.js"; import { EVENTS, MAX_FILE_SIZE_DEFAULT } from "../../constants.js"; import { seedBuiltinTemplatesForWorkspace } from "@factory/shared"; import { effectiveStorageQuota } from "../files/quota.js"; const WS_SELECT = { id: true, name: true, slug: true, description: true, iconUrl: true, isPersonal: true, ownerId: true, billingPlan: true, usageLimits: true, organizationId: true, createdAt: true, updatedAt: true, deletedAt: true, // T-136: space type + archive state (directory filter/sort/stats surface). type: true, archivedAt: true } as const; @Injectable() export class WorkspacesService { private readonly log = new Logger(WorkspacesService.name); constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService, @Inject(EventBus) private readonly events: EventBus, @Inject(EmailService) private readonly email: EmailService ) {} // ---------- lookups ---------- private async getWorkspace(idOrSlug: string) { // TASK-0124 (repair): the web URL contract is id-OR-slug (useWorkspace // passes the raw [workspace] route segment, and in-app navigation such as // the AI "Open" button pushes the workspace SLUG). This lookup was // id-only, so every slug fetch of an owned workspace failed and the // workspace layout's fallback bounced the user to /w/default. Slugs are // unique in the workspace table, so the fallback lookup is unambiguous. let ws = await this.prisma.workspace.findUnique({ where: { id: idOrSlug }, select: WS_SELECT }); if (!ws) { ws = await this.prisma.workspace.findUnique({ where: { slug: idOrSlug }, select: WS_SELECT }); } if (!ws || ws.deletedAt) throw ApiError.notFound(); return ws; } private async requireMembership(workspaceId: string, userId: string) { // TASK-0127: ACTOR-aware — an API key of this workspace is a // member-equivalent (synthetic role from its scope); the creator's real // membership is never consulted for the key. const m = await actorMembership(this.prisma, userId, workspaceId); if (!m) throw ApiError.forbidden("Not a member of this workspace.", [{ reason: "not_workspace_member" }]); return m; } // ---------- CRUD ---------- /** * T-136: the space directory. The existing `GET /workspaces` contract is * preserved (no query params = all my spaces, non-archived, as before); * the new optional params add filtering/sorting for the directory UI. * Membership-scoped only — non-member workspaces are never returned * (fail-closed; same no-leak contract as the old listMine). */ async listMine(userId: string, query?: { search?: string; type?: string; archived?: string; sort?: string }) { const showArchived = query?.archived === "true"; const memberships = await this.prisma.workspaceMember.findMany({ where: { userId }, include: { workspace: { select: WS_SELECT } }, orderBy: { createdAt: "asc" } }); let rows = memberships .filter((m) => !m.workspace.deletedAt) .filter((m) => (showArchived ? true : !m.workspace.archivedAt)) .map((m) => ({ ...m.workspace, role: m.role })); if (query?.type) rows = rows.filter((w) => w.type === query.type); if (query?.search) { const q = query.search.toLowerCase(); rows = rows.filter((w) => w.name.toLowerCase().includes(q) || (w.description ?? "").toLowerCase().includes(q) ); } switch (query?.sort) { case "name": rows.sort((a, b) => a.name.localeCompare(b.name)); break; case "recent": rows.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); break; default: break; // "created" (default) keeps the membership join order } return { data: rows }; } async create(userId: string, input: { name: string; slug?: string }) { const slug = (input.slug ?? slugify(input.name)) + "-" + randomToken(4).toLowerCase().replace(/[^a-z0-9]/g, "x").slice(0, 4); let workspace; try { workspace = await this.prisma.workspace.create({ data: { name: input.name, slug, isPersonal: false, ownerId: userId }, select: WS_SELECT }); } catch { throw new ApiError("CONFLICT", 409, "Workspace slug already exists.", [{ field: "slug", issue: "already_exists" }]); } await this.prisma.workspaceMember.create({ data: { workspaceId: workspace.id, userId, role: "OWNER", joinedAt: new Date() } }); // T-143 (A-005): every new workspace gets the built-in template catalog // (idempotent; 15 lightweight rows — never a failure path for signup). await seedBuiltinTemplatesForWorkspace(this.prisma, workspace.id, userId).catch((e) => { this.log.warn(`builtin template seeding failed for workspace ${workspace.id}: ${e instanceof Error ? e.message : e}`); }); await this.events.publish(EVENTS.WORKSPACE_CREATED, { workspaceId: workspace.id, name: workspace.name, personal: false }); await this.audit.write({ actorId: userId, action: "workspace.created", resourceType: "workspace", resourceId: workspace.id, workspaceId: workspace.id }); return { ...workspace, role: "OWNER" }; } async get(workspaceId: string, userId: string) { // TASK-0124: surface the caller's OWN role in the single-workspace fetch // (matching `listMine`, which already returns `role`). The web's // `useWorkspace` → `GET /workspaces/:id` is the route's role source of // truth; without it the DB page's `readOnly` (guest) check is dead and the // role-gated write actions (AI generate/extract/formula, etc.) are never // hidden from VIEW members. Returning the caller's own role to themselves // is standard (no authorization bypass — the caller is already a member). const ws = await this.getWorkspace(workspaceId); const membership = await this.requireMembership(ws.id, userId); return { ...ws, role: membership.role }; } async update(workspaceId: string, userId: string, input: { name?: string; slug?: string; description?: string | null; iconUrl?: string | null; type?: string }, req: any) { await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]); if (input.slug) { const taken = await this.prisma.workspace.findUnique({ where: { slug: input.slug } }); if (taken && taken.id !== workspaceId) { throw new ApiError("CONFLICT", 409, "Workspace slug already exists.", [{ field: "slug", issue: "already_exists" }]); } } const ws = await this.prisma.workspace.update({ where: { id: workspaceId }, data: { ...(input.name !== undefined ? { name: input.name } : {}), ...(input.slug !== undefined ? { slug: input.slug } : {}), ...(input.description !== undefined ? { description: input.description } : {}), ...(input.iconUrl !== undefined ? { iconUrl: input.iconUrl } : {}), ...(input.type !== undefined ? { type: input.type } : {}) }, select: WS_SELECT }); await this.audit.audit(req, req?.auth, { action: "workspace.updated", resourceType: "workspace", resourceId: workspaceId, metadata: input.type !== undefined ? { field: "type", to: input.type } : undefined }); return ws; } // ---------- T-136: archive / restore / stats ---------- /** Owner/admin soft-archive: hidden from the directory; content preserved. */ async archive(workspaceId: string, userId: string, req: any) { const ws = await this.getWorkspace(workspaceId); await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]); if (ws.archivedAt) return { archived: true, archivedAt: ws.archivedAt }; const updated = await this.prisma.workspace.update({ where: { id: workspaceId }, data: { archivedAt: new Date() }, select: WS_SELECT }); await this.audit.audit(req, req?.auth, { action: "workspace.archived", resourceType: "workspace", resourceId: workspaceId, workspaceId }); return { archived: true, archivedAt: updated.archivedAt }; } async restoreWorkspace(workspaceId: string, userId: string, req: any) { const ws = await this.getWorkspace(workspaceId); await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]); if (!ws.archivedAt) return { archived: false }; await this.prisma.workspace.update({ where: { id: workspaceId }, data: { archivedAt: null } }); await this.audit.audit(req, req?.auth, { action: "workspace.restored", resourceType: "workspace", resourceId: workspaceId, workspaceId }); return { archived: false }; } /** * T-137: space-level content statistics. Membership required (the counts * describe this workspace's content; non-members get 403, not zeros). */ async stats(workspaceId: string, userId: string) { const ws = await this.getWorkspace(workspaceId); await this.requireMembership(workspaceId, userId); const [pages, whiteboards, tasks, databases, records, comments, members, fileAgg] = await Promise.all([ this.prisma.page.count({ where: { workspaceId, trashedAt: null, deletedAt: null } }), this.prisma.page.count({ where: { workspaceId, kind: "whiteboard", trashedAt: null, deletedAt: null } }), this.prisma.page.count({ where: { workspaceId, kind: "task", trashedAt: null, deletedAt: null } }), this.prisma.database.count({ where: { workspaceId, isArchived: false } }), this.prisma.databaseRecord.count({ where: { workspaceId, archivedAt: null } }), this.prisma.comment.count({ where: { workspaceId } }), this.prisma.workspaceMember.count({ where: { workspaceId } }), this.prisma.file.aggregate({ where: { workspaceId, deletedAt: null }, _sum: { size: true } }) ]); const wsRow = await this.prisma.workspace.findUnique({ where: { id: workspaceId }, select: { usageStorageBytes: true } }); return { workspaceId, type: ws.type, archivedAt: ws.archivedAt, content: { pages, whiteboards, tasks, databases, records, comments, members, storageBytes: wsRow?.usageStorageBytes ?? 0n } }; } /** Owner-only hard delete. */ async remove(workspaceId: string, userId: string, req: any) { await assertRole(this.prisma, { workspaceId, userId }, ["OWNER"]); const ws = await this.getWorkspace(workspaceId); await this.prisma.$transaction(async (tx) => { // Cascade handles the bulk; guard against deleting a personal workspace's // dependent data twice by doing it in one statement. await tx.workspace.delete({ where: { id: workspaceId } }); }); await this.audit.audit(req, req?.auth, { action: "workspace.deleted", resourceType: "workspace", resourceId: workspaceId, metadata: { name: ws.name } }); } // ---------- members & invites ---------- async listMembers(workspaceId: string, userId: string, query: { search?: string; cursor?: string; limit?: string }) { await this.requireMembership(workspaceId, userId); const { cursor, limit } = parsePagination(query); const members = await this.prisma.workspaceMember.findMany({ where: { workspaceId, ...(query.search ? { user: { OR: [{ email: { contains: query.search, mode: "insensitive" } }, { name: { contains: query.search, mode: "insensitive" } }] } } : {}) }, include: { user: { select: { id: true, email: true, name: true, displayName: true, avatarUrl: true } } }, orderBy: { createdAt: "asc" }, take: limit + 1 }); const page = members.slice(0, limit); const next = members.length > limit ? page[page.length - 1] : undefined; return { data: page.map((m) => ({ userId: m.userId, email: m.user.email, name: m.user.name, displayName: m.user.displayName ?? m.user.name, avatarUrl: m.user.avatarUrl, role: m.role, joinedAt: m.joinedAt })), nextCursor: next ? Buffer.from(JSON.stringify({ id: next.id, position: next.id })).toString("base64url") : null }; } async invite(workspaceId: string, userId: string, input: { email: string; role: string }, req: any) { await assertRole(this.prisma, { workspaceId, userId }, ["OWNER", "ADMIN"]); const ws = await this.getWorkspace(workspaceId); const email = input.email.toLowerCase(); const existingUser = await this.prisma.user.findUnique({ where: { email } }); if (existingUser) { const existing = await this.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId: existingUser.id } } }); if (existing) { throw new ApiError("CONFLICT", 409, "User is already a member.", [{ field: "email", issue: "already_member" }]); } } const token = `inv_${randomToken(32)}`; let invitation; try { invitation = await this.prisma.invitation.create({ data: { workspaceId, email, role: input.role as never, invitedById: userId, tokenHash: sha256hex(token), expiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000) } }); } catch { throw new ApiError("CONFLICT", 409, "An invitation with this email and role already exists.", [ { field: "email", issue: "already_invited" } ]); } this.email.sendInvitation(email, ws.name, token); await this.audit.audit(req, req?.auth, { action: "workspace.invite_sent", resourceType: "invitation", resourceId: invitation.id, workspaceId, metadata: { email, role: input.role } }); return { id: invitation.id, email: invitation.email, role: invitation.role, expiresAt: invitation.expiresAt }; } /** Invited user (authenticated) accepts with the token from the invite link. */ async acceptInvite(workspaceId: string, userId: string, token: string, req: any) { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) throw ApiError.notFound(); const invitation = await this.prisma.invitation.findUnique({ where: { tokenHash: sha256hex(token) } }); if (!invitation || invitation.workspaceId !== workspaceId) throw ApiError.notFound("Invitation not found."); if (invitation.status !== "PENDING") { throw new ApiError("CONFLICT", 409, "Invitation already processed.", [{ issue: "already_accepted" }]); } if (invitation.expiresAt.getTime() < Date.now()) { await this.prisma.invitation.update({ where: { id: invitation.id }, data: { status: "EXPIRED" } }); throw new ApiError("GONE", 410, "Invitation expired.", [{ reason: "token_expired" }]); } if (invitation.email.toLowerCase() !== user.email.toLowerCase()) { throw ApiError.forbidden("This invitation was issued to a different email.", [{ reason: "email_mismatch" }]); } await this.prisma.$transaction([ this.prisma.invitation.update({ where: { id: invitation.id }, data: { status: "ACCEPTED", acceptedAt: new Date() } }), this.prisma.workspaceMember.upsert({ where: { workspaceId_userId: { workspaceId, userId } }, update: {}, create: { workspaceId, userId, role: invitation.role as never, invitedBy: invitation.invitedById, joinedAt: new Date() } }) ]); await this.audit.audit(req, req?.auth, { action: "workspace.invite_accepted", resourceType: "invitation", resourceId: invitation.id, workspaceId }); return { workspaceId, role: invitation.role }; } async updateMemberRole(workspaceId: string, actorId: string, targetUserId: string, role: string, req: any) { await assertRole(this.prisma, { workspaceId, userId: actorId }, ["OWNER", "ADMIN"]); const member = await this.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId: targetUserId } } }); if (!member) throw ApiError.notFound("Member not found."); if (role !== "OWNER") { // cannot change an owner's role except via transfer if (member.role === "OWNER") { throw new ApiError("CONFLICT", 409, "Owner role can only change via workspace transfer.", [ { field: "role", issue: "owner_transfer_required" } ]); } } await this.prisma.workspaceMember.update({ where: { id: member.id }, data: { role: role as never } }); await this.audit.audit(req, req?.auth, { action: "workspace.member_role_changed", resourceType: "workspace_member", resourceId: member.id, workspaceId, metadata: { targetUserId, from: member.role, to: role } }); // TASK-0133 audit catalog: a role change IS a permission change — fire // the catalog's `permission.changed` (required metadata: `change`, // `targetId`) in addition to the legacy row. await this.audit.audit(req, req?.auth, { action: EVENTS.PERMISSION_CHANGED, resourceType: "workspace_member", resourceId: member.id, workspaceId, metadata: { change: "role", targetId: targetUserId, from: member.role, to: role } }); await this.events.publish(EVENTS.PERMISSION_CHANGED, { workspaceId, userId: targetUserId, role }); return { userId: targetUserId, role }; } async removeMember(workspaceId: string, actorId: string, targetUserId: string, req: any) { const actorRole = await assertRole(this.prisma, { workspaceId, userId: actorId }, ["OWNER", "ADMIN"]); const member = await this.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId: targetUserId } } }); if (!member) throw ApiError.notFound("Member not found."); if (member.role === "OWNER") { throw new ApiError("CONFLICT", 409, "The workspace owner cannot be removed (transfer ownership first).", [ { issue: "last_owner" } ]); } if (member.role === "ADMIN" && actorRole !== "OWNER") { throw ApiError.forbidden("Only the owner can remove an admin.", [{ reason: "requires_owner" }]); } await this.prisma.workspaceMember.delete({ where: { id: member.id } }); await this.audit.audit(req, req?.auth, { action: "workspace.member_removed", resourceType: "workspace_member", resourceId: member.id, workspaceId, metadata: { targetUserId, role: member.role } }); await this.events.publish(EVENTS.PERMISSION_CHANGED, { workspaceId, userId: targetUserId, removed: true }); } // ---------- usage & transfer ---------- async usage(workspaceId: string, userId: string) { await this.requireMembership(workspaceId, userId); const ws = await this.getWorkspace(workspaceId); const limits = (ws.usageLimits as Record) ?? {}; // TASK-0104 §4: the storage bar needs the AUTHORITATIVE counter // (Workspace.usageStorageBytes — the event counter the upload path // maintains, NOT a recompute) + the plan quota (min(plan, override)). const wsRow = await this.prisma.workspace.findUnique({ where: { id: workspaceId }, select: { usageStorageBytes: true }, }); const effectiveQuota = effectiveStorageQuota(ws.billingPlan, ws.usageLimits as { storageQuota?: number } | null); const [pages, databases, records, fileAgg, apiKeys, webhooks] = await Promise.all([ this.prisma.page.count({ where: { workspaceId, trashedAt: null, deletedAt: null } }), this.prisma.database.count({ where: { workspaceId, isArchived: false } }), this.prisma.databaseRecord.count({ where: { workspaceId, archivedAt: null } }), this.prisma.file.aggregate({ where: { workspaceId, deletedAt: null }, _sum: { size: true } }), this.prisma.apiKey.count({ where: { workspaceId, revokedAt: null } }), this.prisma.webhook.count({ where: { workspaceId } }) ]); return { workspaceId, usage: { pages, databases, records, // The authoritative event counter (the value the upload path counts; // the storage bar shows THIS, not the Σ-File-size recompute below). storageBytes: wsRow?.usageStorageBytes ?? 0n, // The Σ-File.size recompute is kept as `storageBytesComputed` for // drift diagnostics (a big divergence from storageBytes is a stale // counter — the reconciler repairs it). storageBytesComputed: fileAgg._sum.size ?? 0, apiKeys, webhooks }, limits: { maxFileSize: limits.maxFileSize ?? MAX_FILE_SIZE_DEFAULT, maxPages: limits.maxPages ?? null, maxDatabases: limits.maxDatabases ?? null, maxRecords: limits.maxRecords ?? null, // The effective plan quota (min(plan, override)) — the storage bar's // denominator. (Previously `limits.maxStorageBytes ?? null`, which // never applied the plan default, so the bar had no ceiling.) maxStorageBytes: effectiveQuota, maxApiKeys: limits.maxApiKeys ?? null, maxWebhooks: limits.maxWebhooks ?? null } }; } async transfer(workspaceId: string, actorId: string, newOwnerId: string, req: any) { await assertRole(this.prisma, { workspaceId, userId: actorId }, ["OWNER"]); const target = await this.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId: newOwnerId } } }); if (!target) throw ApiError.notFound("New owner must be a workspace member."); const previous = await this.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId: actorId } } }); await this.prisma.$transaction([ this.prisma.workspaceMember.update({ where: { id: target.id }, data: { role: "OWNER" } }), ...(previous ? [this.prisma.workspaceMember.update({ where: { id: previous.id }, data: { role: "ADMIN" } })] : []), this.prisma.workspace.update({ where: { id: workspaceId }, data: { ownerId: newOwnerId } }) ]); await this.audit.audit(req, req?.auth, { action: "workspace.transferred", resourceType: "workspace", resourceId: workspaceId, workspaceId, metadata: { from: actorId, to: newOwnerId } }); await this.events.publish(EVENTS.PERMISSION_CHANGED, { workspaceId, userId: newOwnerId, role: "OWNER" }); return { ownerId: newOwnerId }; } // ---------- T-158: space activity feed ---------- /** * The space's activity: audit rows in this workspace (everyone's actions * on it), newest-first, keyset-paginated. Membership-gated (non-member → * 403, the workspace no-leak contract — same as archive/stats). */ async activity(workspaceId: string, userId: string, query: { cursor?: string; limit?: string }) { const ws = await this.getWorkspace(workspaceId); await this.requireMembership(ws.id, userId); return pageActivity(this.prisma, { workspaceId: ws.id }, query); } } function slugify(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 40) || "workspace"; }