1import { Inject, Injectable } from "@nestjs/common";
2import { PrismaService } from "../../common/prisma.service.js";
3import { AuditService } from "../../common/audit.service.js";
4import { ApiError } from "../../common/errors.js";
5import { randomToken } from "../../lib/crypto.js";
6import { slugifyName } from "./util.js";
7import type {
8 CreateOrgInput,
9 InviteOrgMemberInput,
10 UpdateOrgInput
11} from "./dto.js";
12
13
20@Injectable()
21export class OrgsService {
22 constructor(
23 @Inject(PrismaService) private readonly prisma: PrismaService,
24 @Inject(AuditService) private readonly audit: AuditService
25 ) {}
26
27 private async requireOrgMember(orgId: string, userId: string) {
28 const member = await this.prisma.organizationMember.findUnique({
29 where: { organizationId_userId: { organizationId: orgId, userId } }
30 });
31 if (!member) throw ApiError.forbidden("Not a member of this organization.", [{ reason: "not_org_member" }]);
32 return member;
33 }
34
35 private async requireOrgOwner(orgId: string, userId: string) {
36 const org = await this.prisma.organization.findUnique({ where: { id: orgId } });
37 if (!org) throw ApiError.notFound("Organization not found.");
38 if (org.ownerId !== userId) {
39 throw ApiError.forbidden("Organization owner required.", [{ reason: "requires_org_owner" }]);
40 }
41 return org;
42 }
43
44 async listMine(userId: string) {
45 const memberships = await this.prisma.organizationMember.findMany({
46 where: { userId },
47 include: {
48 organization: {
49 select: {
50 id: true, name: true, slug: true, logoUrl: true, ownerId: true,
51 createdAt: true,
52 _count: { select: { members: true, workspaces: true } }
53 }
54 }
55 }
56 });
57 return {
58 data: memberships.map((m) => ({
59 id: m.organization.id,
60 name: m.organization.name,
61 slug: m.organization.slug,
62 logoUrl: m.organization.logoUrl,
63 ownerId: m.organization.ownerId,
64 role: m.role,
65 memberCount: m.organization._count.members,
66 workspaceCount: m.organization._count.workspaces,
67 createdAt: m.organization.createdAt
68 }))
69 };
70 }
71
72 async create(userId: string, input: CreateOrgInput) {
73 const slug = await this.uniqueSlug(input.name);
74 const org = await this.prisma.organization.create({
75 data: { name: input.name, slug, ownerId: userId, policy: {} as object }
76 });
77 await this.prisma.organizationMember.create({
78 data: { organizationId: org.id, userId, role: "OWNER" }
79 });
80 await this.audit.write({
81 actorId: userId,
82 action: "org.created",
83 resourceType: "organization",
84 resourceId: org.id,
85 metadata: { name: input.name }
86 });
87 return org;
88 }
89
90 async get(orgId: string, userId: string) {
91 const member = await this.requireOrgMember(orgId, userId);
92 const org = await this.prisma.organization.findUnique({
93 where: { id: orgId },
94 include: {
95 members: {
96 include: { user: { select: { id: true, email: true, name: true, avatarUrl: true } } },
97 orderBy: { createdAt: "asc" }
98 },
99 workspaces: { select: { id: true, name: true, slug: true }, orderBy: { createdAt: "asc" } },
100 retentionPolicies: { orderBy: { createdAt: "asc" } },
101
102
103 ssoProvider: { select: { id: true, name: true, type: true, enforce: true } }
104 }
105 });
106 if (!org) throw ApiError.notFound("Organization not found.");
107 return {
108 id: org.id,
109 name: org.name,
110 slug: org.slug,
111 logoUrl: org.logoUrl,
112 ownerId: org.ownerId,
113 myRole: member.role,
114 policy: org.policy,
115 ssoConfig: org.ssoConfig,
116 scimConfig: org.scimConfig,
117 ssoProvider: org.ssoProvider,
118 members: org.members.map((m) => ({
119 userId: m.userId,
120 email: m.user.email,
121 name: m.user.name,
122 avatarUrl: m.user.avatarUrl,
123 role: m.role,
124 joinedAt: m.createdAt
125 })),
126 workspaces: org.workspaces,
127 retentionPolicies: org.retentionPolicies.map((r) => ({
128 id: r.id,
129 type: r.type,
130 scope: r.scope,
131 retainDays: r.retainDays,
132 config: r.config
133 })),
134 createdAt: org.createdAt,
135 updatedAt: org.updatedAt
136 };
137 }
138
139 async update(orgId: string, userId: string, input: UpdateOrgInput, req: any) {
140 const org = await this.requireOrgOwner(orgId, userId);
141 const data: Record<string, unknown> = {};
142 if (input.name !== undefined) data.name = input.name;
143 if (input.logoUrl !== undefined) data.logoUrl = input.logoUrl;
144 if (input.policy !== undefined) data.policy = input.policy as object;
145 if (input.ssoConfig !== undefined) data.ssoConfig = input.ssoConfig as object | undefined;
146 if (input.scimConfig !== undefined) data.scimConfig = input.scimConfig as object | undefined;
147
148 if (input.retentionPolicies) {
149 await this.prisma.retentionPolicy.deleteMany({ where: { organizationId: orgId } });
150 for (const rp of input.retentionPolicies) {
151 await this.prisma.retentionPolicy.create({
152 data: {
153 organizationId: orgId,
154 type: rp.type,
155 scope: rp.scope,
156 retainDays: rp.retainDays ?? null,
157 config: rp.config as object
158 }
159 });
160 }
161 }
162
163 const updated = await this.prisma.organization.update({ where: { id: orgId }, data });
164 await this.audit.audit(req, req?.auth, {
165 action: "org.updated",
166 resourceType: "organization",
167 resourceId: orgId,
168 metadata: { fields: Object.keys(input), retentionReplaced: Boolean(input.retentionPolicies) }
169 });
170 void org;
171 return { id: updated.id, name: updated.name, slug: updated.slug, logoUrl: updated.logoUrl, updatedAt: updated.updatedAt };
172 }
173
174 async inviteMember(orgId: string, userId: string, input: InviteOrgMemberInput, req: any) {
175 await this.requireOrgOwner(orgId, userId);
176 const org = await this.prisma.organization.findUniqueOrThrow({ where: { id: orgId } });
177 const email = input.email.toLowerCase();
178 const user = await this.prisma.user.findUnique({ where: { email } });
179 if (user) {
180 const existing = await this.prisma.organizationMember.findUnique({
181 where: { organizationId_userId: { organizationId: orgId, userId: user.id } }
182 });
183 if (existing) {
184 throw new ApiError("CONFLICT", 409, "User is already an org member.", [{ field: "email", issue: "already_member" }]);
185 }
186 await this.prisma.organizationMember.create({
187 data: { organizationId: orgId, userId: user.id, role: input.role as never }
188 });
189 await this.audit.audit(req, req?.auth, {
190 action: "org.member_added",
191 resourceType: "organization_member",
192 resourceId: user.id,
193 metadata: { orgId, email, role: input.role }
194 });
195 return { added: true, email, role: input.role };
196 }
197
198
199
200 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).", [
201 { field: "email", issue: "user_not_found" }
202 ]);
203 }
204
205 private async uniqueSlug(name: string): Promise<string> {
206 const base = slugifyName(name);
207 for (let i = 0; i < 5; i++) {
208 const slug = i === 0 ? base : `${base}-${randomToken(4).toLowerCase().replace(/[^a-z0-9]/g, "x").slice(0, 4)}`;
209 const taken = await this.prisma.organization.findUnique({ where: { slug } });
210 if (!taken) return slug;
211 }
212 throw ApiError.conflict("Could not allocate a unique org slug.");
213 }
214}
215