import { Inject, Injectable } from "@nestjs/common"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { ApiError } from "../../common/errors.js"; import { randomToken } from "../../lib/crypto.js"; import { slugifyName } from "./util.js"; import type { CreateOrgInput, InviteOrgMemberInput, UpdateOrgInput } from "./dto.js"; /** * Organizations: the container above workspaces (enterprise layer). * * Membership is via OrganizationMember (role = workspace role enum). * Policies, SSO/SCIM config and retention policies are STORED RAW here — * enforcement (SSO sign-in, SCIM provisioning, retention jobs) is Phase 14. */ @Injectable() export class OrgsService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService ) {} private async requireOrgMember(orgId: string, userId: string) { const member = await this.prisma.organizationMember.findUnique({ where: { organizationId_userId: { organizationId: orgId, userId } } }); if (!member) throw ApiError.forbidden("Not a member of this organization.", [{ reason: "not_org_member" }]); return member; } private async requireOrgOwner(orgId: string, userId: string) { const org = await this.prisma.organization.findUnique({ where: { id: orgId } }); if (!org) throw ApiError.notFound("Organization not found."); if (org.ownerId !== userId) { throw ApiError.forbidden("Organization owner required.", [{ reason: "requires_org_owner" }]); } return org; } async listMine(userId: string) { const memberships = await this.prisma.organizationMember.findMany({ where: { userId }, include: { organization: { select: { id: true, name: true, slug: true, logoUrl: true, ownerId: true, createdAt: true, _count: { select: { members: true, workspaces: true } } } } } }); return { data: memberships.map((m) => ({ id: m.organization.id, name: m.organization.name, slug: m.organization.slug, logoUrl: m.organization.logoUrl, ownerId: m.organization.ownerId, role: m.role, memberCount: m.organization._count.members, workspaceCount: m.organization._count.workspaces, createdAt: m.organization.createdAt })) }; } async create(userId: string, input: CreateOrgInput) { const slug = await this.uniqueSlug(input.name); const org = await this.prisma.organization.create({ data: { name: input.name, slug, ownerId: userId, policy: {} as object } }); await this.prisma.organizationMember.create({ data: { organizationId: org.id, userId, role: "OWNER" } }); await this.audit.write({ actorId: userId, action: "org.created", resourceType: "organization", resourceId: org.id, metadata: { name: input.name } }); return org; } async get(orgId: string, userId: string) { const member = await this.requireOrgMember(orgId, userId); const org = await this.prisma.organization.findUnique({ where: { id: orgId }, include: { members: { include: { user: { select: { id: true, email: true, name: true, avatarUrl: true } } }, orderBy: { createdAt: "asc" } }, workspaces: { select: { id: true, name: true, slug: true }, orderBy: { createdAt: "asc" } }, retentionPolicies: { orderBy: { createdAt: "asc" } }, // SsoProvider has no `provider`/`status` columns — the real fields // are `type` (the SSO provider kind) + `enforce`. ssoProvider: { select: { id: true, name: true, type: true, enforce: true } } } }); if (!org) throw ApiError.notFound("Organization not found."); return { id: org.id, name: org.name, slug: org.slug, logoUrl: org.logoUrl, ownerId: org.ownerId, myRole: member.role, policy: org.policy, ssoConfig: org.ssoConfig, scimConfig: org.scimConfig, ssoProvider: org.ssoProvider, members: org.members.map((m) => ({ userId: m.userId, email: m.user.email, name: m.user.name, avatarUrl: m.user.avatarUrl, role: m.role, joinedAt: m.createdAt })), workspaces: org.workspaces, retentionPolicies: org.retentionPolicies.map((r) => ({ id: r.id, type: r.type, scope: r.scope, retainDays: r.retainDays, config: r.config })), createdAt: org.createdAt, updatedAt: org.updatedAt }; } async update(orgId: string, userId: string, input: UpdateOrgInput, req: any) { const org = await this.requireOrgOwner(orgId, userId); const data: Record = {}; if (input.name !== undefined) data.name = input.name; if (input.logoUrl !== undefined) data.logoUrl = input.logoUrl; if (input.policy !== undefined) data.policy = input.policy as object; if (input.ssoConfig !== undefined) data.ssoConfig = input.ssoConfig as object | undefined; if (input.scimConfig !== undefined) data.scimConfig = input.scimConfig as object | undefined; if (input.retentionPolicies) { await this.prisma.retentionPolicy.deleteMany({ where: { organizationId: orgId } }); for (const rp of input.retentionPolicies) { await this.prisma.retentionPolicy.create({ data: { organizationId: orgId, type: rp.type, scope: rp.scope, retainDays: rp.retainDays ?? null, config: rp.config as object } }); } } const updated = await this.prisma.organization.update({ where: { id: orgId }, data }); await this.audit.audit(req, req?.auth, { action: "org.updated", resourceType: "organization", resourceId: orgId, metadata: { fields: Object.keys(input), retentionReplaced: Boolean(input.retentionPolicies) } }); void org; return { id: updated.id, name: updated.name, slug: updated.slug, logoUrl: updated.logoUrl, updatedAt: updated.updatedAt }; } async inviteMember(orgId: string, userId: string, input: InviteOrgMemberInput, req: any) { await this.requireOrgOwner(orgId, userId); const org = await this.prisma.organization.findUniqueOrThrow({ where: { id: orgId } }); const email = input.email.toLowerCase(); const user = await this.prisma.user.findUnique({ where: { email } }); if (user) { const existing = await this.prisma.organizationMember.findUnique({ where: { organizationId_userId: { organizationId: orgId, userId: user.id } } }); if (existing) { throw new ApiError("CONFLICT", 409, "User is already an org member.", [{ field: "email", issue: "already_member" }]); } await this.prisma.organizationMember.create({ data: { organizationId: orgId, userId: user.id, role: input.role as never } }); await this.audit.audit(req, req?.auth, { action: "org.member_added", resourceType: "organization_member", resourceId: user.id, metadata: { orgId, email, role: input.role } }); return { added: true, email, role: input.role }; } // Org invitations for users who do not exist yet are not modelled in // the Phase 14 schema (Invitation requires a workspace) — invite them // into a workspace first, or wait for the identity module. throw new ApiError("NOT_FOUND", 404, "No user with that email — invite them into a workspace first (org invites for new users land in Phase 14).", [ { field: "email", issue: "user_not_found" } ]); } private async uniqueSlug(name: string): Promise { const base = slugifyName(name); for (let i = 0; i < 5; i++) { const slug = i === 0 ? base : `${base}-${randomToken(4).toLowerCase().replace(/[^a-z0-9]/g, "x").slice(0, 4)}`; const taken = await this.prisma.organization.findUnique({ where: { slug } }); if (!taken) return slug; } throw ApiError.conflict("Could not allocate a unique org slug."); } }