@ftisindia/create-app 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/LICENSE +144 -0
  2. package/bin/index.mjs +2 -0
  3. package/package.json +36 -0
  4. package/src/copy.mjs +45 -0
  5. package/src/log.mjs +23 -0
  6. package/src/main.mjs +221 -0
  7. package/src/run.mjs +52 -0
  8. package/src/substitute.mjs +40 -0
  9. package/template/.env.example +36 -0
  10. package/template/.github/workflows/ci.yml +36 -0
  11. package/template/.husky/pre-commit +1 -0
  12. package/template/README.md +146 -0
  13. package/template/_editorconfig +8 -0
  14. package/template/_gitignore +7 -0
  15. package/template/_nvmrc +1 -0
  16. package/template/_package.json +107 -0
  17. package/template/_prettierignore +5 -0
  18. package/template/_prettierrc +6 -0
  19. package/template/docs/API_REFERENCE.md +123 -0
  20. package/template/docs/GETTING_STARTED.md +65 -0
  21. package/template/docs/MODULE_COMPLETION_CHECKLIST.md +40 -0
  22. package/template/docs/OAUTH.md +46 -0
  23. package/template/docs/SAMPLE_MODULE.md +23 -0
  24. package/template/docs/api.http +269 -0
  25. package/template/eslint.config.mjs +51 -0
  26. package/template/nest-cli.json +8 -0
  27. package/template/prisma/migrations/20260530000000_init/migration.sql +248 -0
  28. package/template/prisma/schema.prisma +299 -0
  29. package/template/prisma/seed.ts +44 -0
  30. package/template/scripts/db-create.mjs +126 -0
  31. package/template/scripts/gen-module.mjs +217 -0
  32. package/template/scripts/seed-test-user-org.ts +264 -0
  33. package/template/scripts/setup-local.mjs +224 -0
  34. package/template/scripts/test-db.mjs +69 -0
  35. package/template/src/app.module.ts +58 -0
  36. package/template/src/common/decorators/.gitkeep +1 -0
  37. package/template/src/common/dto/error-response.dto.ts +17 -0
  38. package/template/src/common/dto/membership-response.dto.ts +51 -0
  39. package/template/src/common/dto/mutation-response.dto.ts +11 -0
  40. package/template/src/common/dto/role-summary.dto.ts +18 -0
  41. package/template/src/common/dto/user-summary.dto.ts +23 -0
  42. package/template/src/common/enums/.gitkeep +1 -0
  43. package/template/src/common/filters/.gitkeep +1 -0
  44. package/template/src/common/filters/http-exception.filter.ts +78 -0
  45. package/template/src/common/guards/.gitkeep +1 -0
  46. package/template/src/common/interceptors/.gitkeep +1 -0
  47. package/template/src/common/pipes/.gitkeep +1 -0
  48. package/template/src/common/swagger/api-error-responses.ts +54 -0
  49. package/template/src/common/types/.gitkeep +1 -0
  50. package/template/src/config/app.config.ts +7 -0
  51. package/template/src/config/auth.config.ts +33 -0
  52. package/template/src/config/database.config.ts +6 -0
  53. package/template/src/config/env.validation.ts +131 -0
  54. package/template/src/config/index.ts +5 -0
  55. package/template/src/config/rbac.config.ts +6 -0
  56. package/template/src/database/prisma/prisma-transaction.ts +22 -0
  57. package/template/src/database/prisma/prisma.module.ts +9 -0
  58. package/template/src/database/prisma/prisma.service.ts +16 -0
  59. package/template/src/main.ts +42 -0
  60. package/template/src/modules/access-control/access-control.module.ts +24 -0
  61. package/template/src/modules/access-control/application/route-registry.validator.ts +289 -0
  62. package/template/src/modules/access-control/application/services/ability.factory.ts +28 -0
  63. package/template/src/modules/access-control/application/services/access-control.service.ts +478 -0
  64. package/template/src/modules/access-control/application/services/permission.guard.ts +77 -0
  65. package/template/src/modules/access-control/application/services/rbac-cache.service.ts +148 -0
  66. package/template/src/modules/access-control/dto/access-control-response.dto.ts +79 -0
  67. package/template/src/modules/access-control/dto/create-role.dto.ts +18 -0
  68. package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +23 -0
  69. package/template/src/modules/access-control/dto/update-role.dto.ts +19 -0
  70. package/template/src/modules/access-control/presentation/access-control.controller.ts +157 -0
  71. package/template/src/modules/access-control/presentation/permissions.decorator.ts +8 -0
  72. package/template/src/modules/access-control/presentation/public.decorator.ts +7 -0
  73. package/template/src/modules/access-control/types/permission-key.ts +37 -0
  74. package/template/src/modules/access-control/types/rbac-context.ts +11 -0
  75. package/template/src/modules/access-control/types/route-permission-registry.ts +129 -0
  76. package/template/src/modules/audit/application/services/audit.service.ts +97 -0
  77. package/template/src/modules/audit/audit.module.ts +13 -0
  78. package/template/src/modules/audit/dto/audit-response.dto.ts +75 -0
  79. package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +75 -0
  80. package/template/src/modules/audit/presentation/audit.controller.ts +37 -0
  81. package/template/src/modules/auth/application/services/auth.service.ts +509 -0
  82. package/template/src/modules/auth/application/services/password.service.ts +15 -0
  83. package/template/src/modules/auth/application/services/token.service.ts +95 -0
  84. package/template/src/modules/auth/auth.module.ts +73 -0
  85. package/template/src/modules/auth/dto/auth-response.dto.ts +29 -0
  86. package/template/src/modules/auth/dto/login.dto.ts +15 -0
  87. package/template/src/modules/auth/dto/logout.dto.ts +3 -0
  88. package/template/src/modules/auth/dto/oauth-exchange.dto.ts +15 -0
  89. package/template/src/modules/auth/dto/refresh-token.dto.ts +14 -0
  90. package/template/src/modules/auth/dto/signup.dto.ts +27 -0
  91. package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +27 -0
  92. package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +56 -0
  93. package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +5 -0
  94. package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +43 -0
  95. package/template/src/modules/auth/presentation/auth.controller.ts +148 -0
  96. package/template/src/modules/auth/presentation/current-user.decorator.ts +11 -0
  97. package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +33 -0
  98. package/template/src/modules/auth/types/authenticated-user.ts +7 -0
  99. package/template/src/modules/auth/types/google-auth-profile.ts +6 -0
  100. package/template/src/modules/auth/types/jwt-payload.ts +5 -0
  101. package/template/src/modules/health/dto/health-response.dto.ts +9 -0
  102. package/template/src/modules/health/health.module.ts +7 -0
  103. package/template/src/modules/health/presentation/health.controller.ts +33 -0
  104. package/template/src/modules/invitations/application/services/invitations.service.ts +967 -0
  105. package/template/src/modules/invitations/dto/accept-invitation.dto.ts +24 -0
  106. package/template/src/modules/invitations/dto/create-invitation.dto.ts +100 -0
  107. package/template/src/modules/invitations/dto/invitation-response.dto.ts +108 -0
  108. package/template/src/modules/invitations/dto/invitation-token.dto.ts +15 -0
  109. package/template/src/modules/invitations/invitations.module.ts +12 -0
  110. package/template/src/modules/invitations/presentation/invitations.controller.ts +149 -0
  111. package/template/src/modules/memberships/application/services/memberships.service.ts +455 -0
  112. package/template/src/modules/memberships/dto/transfer-owner.dto.ts +11 -0
  113. package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +8 -0
  114. package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +8 -0
  115. package/template/src/modules/memberships/dto/update-membership-role.dto.ts +11 -0
  116. package/template/src/modules/memberships/dto/update-membership-status.dto.ts +9 -0
  117. package/template/src/modules/memberships/memberships.module.ts +12 -0
  118. package/template/src/modules/memberships/presentation/memberships.controller.ts +193 -0
  119. package/template/src/modules/organisations/application/services/organisations.service.ts +147 -0
  120. package/template/src/modules/organisations/dto/create-organisation.dto.ts +32 -0
  121. package/template/src/modules/organisations/dto/organisation-response.dto.ts +62 -0
  122. package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +24 -0
  123. package/template/src/modules/organisations/organisations.module.ts +12 -0
  124. package/template/src/modules/organisations/presentation/organisations.controller.ts +37 -0
  125. package/template/src/modules/organisations/types/default-organisation-data.ts +18 -0
  126. package/template/src/modules/platform-admin/.gitkeep +1 -0
  127. package/template/src/modules/request-context/application/services/request-context.service.ts +79 -0
  128. package/template/src/modules/request-context/presentation/org-scope.guard.ts +26 -0
  129. package/template/src/modules/request-context/presentation/request-context.interceptor.ts +26 -0
  130. package/template/src/modules/request-context/presentation/request-context.middleware.ts +31 -0
  131. package/template/src/modules/request-context/request-context.module.ts +25 -0
  132. package/template/src/modules/request-context/types/request-context.ts +29 -0
  133. package/template/src/modules/sample/application/services/sample.service.ts +67 -0
  134. package/template/src/modules/sample/dto/sample-echo.dto.ts +10 -0
  135. package/template/src/modules/sample/dto/sample-response.dto.ts +41 -0
  136. package/template/src/modules/sample/presentation/sample.controller.ts +63 -0
  137. package/template/src/modules/sample/sample.module.ts +11 -0
  138. package/template/src/modules/settings/application/services/settings.service.ts +139 -0
  139. package/template/src/modules/settings/dto/setting-response.dto.ts +27 -0
  140. package/template/src/modules/settings/dto/update-setting.dto.ts +16 -0
  141. package/template/src/modules/settings/presentation/settings.controller.ts +66 -0
  142. package/template/src/modules/settings/settings.module.ts +12 -0
  143. package/template/src/modules/settings/types/setting-definitions.ts +104 -0
  144. package/template/src/modules/users/.gitkeep +1 -0
  145. package/template/test/.gitkeep +1 -0
  146. package/template/test/jest-e2e.json +9 -0
  147. package/template/test/permission.guard.spec.ts +22 -0
  148. package/template/test/route-registry.validator.spec.ts +90 -0
  149. package/template/test/security.e2e-spec.ts +102 -0
  150. package/template/tsconfig.build.json +4 -0
  151. package/template/tsconfig.json +18 -0
@@ -0,0 +1,455 @@
1
+ import {
2
+ ConflictException,
3
+ ForbiddenException,
4
+ Injectable,
5
+ NotFoundException,
6
+ } from "@nestjs/common";
7
+ import { MembershipStatus, Prisma } from "@prisma/client";
8
+ import { PrismaService } from "../../../../database/prisma/prisma.service";
9
+ import { RbacCacheService } from "../../../access-control/application/services/rbac-cache.service";
10
+ import { AuthenticatedUser } from "../../../auth/types/authenticated-user";
11
+ import { RequestContextService } from "../../../request-context/application/services/request-context.service";
12
+
13
+ const membershipDetailInclude = {
14
+ user: {
15
+ select: {
16
+ id: true,
17
+ email: true,
18
+ mobile: true,
19
+ displayName: true,
20
+ isActive: true,
21
+ },
22
+ },
23
+ role: {
24
+ select: {
25
+ id: true,
26
+ name: true,
27
+ description: true,
28
+ isSystemSeeded: true,
29
+ },
30
+ },
31
+ } satisfies Prisma.MembershipInclude;
32
+
33
+ type PrismaClient = Prisma.TransactionClient | PrismaService;
34
+ type MembershipDetail = Prisma.MembershipGetPayload<{
35
+ include: typeof membershipDetailInclude;
36
+ }>;
37
+
38
+ @Injectable()
39
+ export class MembershipsService {
40
+ constructor(
41
+ private readonly prisma: PrismaService,
42
+ private readonly rbacCache: RbacCacheService,
43
+ private readonly requestContext: RequestContextService,
44
+ ) {}
45
+
46
+ async getCurrentMembership(currentUser: AuthenticatedUser, orgId: string) {
47
+ return this.assertActiveMembership(currentUser.id, orgId);
48
+ }
49
+
50
+ async listMemberships(currentUser: AuthenticatedUser, orgId: string) {
51
+ await this.assertActiveMembership(currentUser.id, orgId);
52
+
53
+ return this.prisma.membership.findMany({
54
+ where: { orgId },
55
+ include: membershipDetailInclude,
56
+ orderBy: [{ isOwner: "desc" }, { createdAt: "asc" }],
57
+ });
58
+ }
59
+
60
+ async updateStatus(
61
+ currentUser: AuthenticatedUser,
62
+ orgId: string,
63
+ membershipId: string,
64
+ status: MembershipStatus,
65
+ ) {
66
+ const result = await this.prisma.$transaction(async (tx) => {
67
+ await this.assertActiveMembership(currentUser.id, orgId, tx);
68
+
69
+ const target = await this.findMembershipInOrg(tx, orgId, membershipId);
70
+ this.assertRevokedMembershipIsNotReused(target, status);
71
+
72
+ if (
73
+ target.isOwner &&
74
+ target.status === MembershipStatus.ACTIVE &&
75
+ status !== MembershipStatus.ACTIVE
76
+ ) {
77
+ await this.assertActiveOwner(currentUser.id, orgId, tx);
78
+ await this.ensureOtherActiveOwner(tx, orgId, target.id);
79
+ }
80
+
81
+ const updated = await tx.membership.update({
82
+ where: { id: target.id },
83
+ data: { status },
84
+ include: membershipDetailInclude,
85
+ });
86
+
87
+ await this.writeAudit(tx, {
88
+ orgId,
89
+ actorUserId: currentUser.id,
90
+ action: "membership.status.update",
91
+ targetId: target.id,
92
+ metadata: {
93
+ previousStatus: target.status,
94
+ nextStatus: status,
95
+ targetUserId: target.userId,
96
+ },
97
+ });
98
+
99
+ return {
100
+ updated,
101
+ affectedUserIds: [target.userId],
102
+ };
103
+ });
104
+
105
+ this.rbacCache.invalidateMany(
106
+ result.affectedUserIds.map((userId) => ({ userId, orgId })),
107
+ );
108
+ return result.updated;
109
+ }
110
+
111
+ async assignRole(
112
+ currentUser: AuthenticatedUser,
113
+ orgId: string,
114
+ membershipId: string,
115
+ roleId: string,
116
+ ) {
117
+ const result = await this.prisma.$transaction(async (tx) => {
118
+ await this.assertActiveMembership(currentUser.id, orgId, tx);
119
+
120
+ const target = await this.findMembershipInOrg(tx, orgId, membershipId);
121
+ this.assertMembershipCanBeModified(target);
122
+
123
+ const role = await tx.role.findUnique({
124
+ where: {
125
+ id_orgId: {
126
+ id: roleId,
127
+ orgId,
128
+ },
129
+ },
130
+ select: { id: true, name: true },
131
+ });
132
+
133
+ if (!role) {
134
+ throw new NotFoundException("Role was not found in this organisation.");
135
+ }
136
+
137
+ const updated = await tx.membership.update({
138
+ where: { id: target.id },
139
+ data: { roleId },
140
+ include: membershipDetailInclude,
141
+ });
142
+
143
+ await this.writeAudit(tx, {
144
+ orgId,
145
+ actorUserId: currentUser.id,
146
+ action: "membership.role.update",
147
+ targetId: target.id,
148
+ metadata: {
149
+ previousRoleId: target.roleId,
150
+ nextRoleId: role.id,
151
+ nextRoleName: role.name,
152
+ targetUserId: target.userId,
153
+ },
154
+ });
155
+
156
+ return {
157
+ updated,
158
+ affectedUserIds: [target.userId],
159
+ };
160
+ });
161
+
162
+ this.rbacCache.invalidateMany(
163
+ result.affectedUserIds.map((userId) => ({ userId, orgId })),
164
+ );
165
+ return result.updated;
166
+ }
167
+
168
+ async updateOwner(
169
+ currentUser: AuthenticatedUser,
170
+ orgId: string,
171
+ membershipId: string,
172
+ isOwner: boolean,
173
+ ) {
174
+ const result = await this.prisma.$transaction(async (tx) => {
175
+ await this.assertActiveOwner(currentUser.id, orgId, tx);
176
+
177
+ const target = await this.findMembershipInOrg(tx, orgId, membershipId);
178
+ this.assertMembershipCanBeModified(target);
179
+
180
+ if (isOwner && target.status !== MembershipStatus.ACTIVE) {
181
+ throw new ConflictException(
182
+ "Only active memberships can be made owners.",
183
+ );
184
+ }
185
+
186
+ if (
187
+ !isOwner &&
188
+ target.isOwner &&
189
+ target.status === MembershipStatus.ACTIVE
190
+ ) {
191
+ await this.ensureOtherActiveOwner(tx, orgId, target.id);
192
+ }
193
+
194
+ const updated = await tx.membership.update({
195
+ where: { id: target.id },
196
+ data: { isOwner },
197
+ include: membershipDetailInclude,
198
+ });
199
+
200
+ await this.writeAudit(tx, {
201
+ orgId,
202
+ actorUserId: currentUser.id,
203
+ action: "membership.owner.update",
204
+ targetId: target.id,
205
+ metadata: {
206
+ previousIsOwner: target.isOwner,
207
+ nextIsOwner: isOwner,
208
+ targetUserId: target.userId,
209
+ },
210
+ });
211
+
212
+ return {
213
+ updated,
214
+ affectedUserIds: [target.userId],
215
+ };
216
+ });
217
+
218
+ this.rbacCache.invalidateMany(
219
+ result.affectedUserIds.map((userId) => ({ userId, orgId })),
220
+ );
221
+ return result.updated;
222
+ }
223
+
224
+ async updateBillingContact(
225
+ currentUser: AuthenticatedUser,
226
+ orgId: string,
227
+ membershipId: string,
228
+ isBillingContact: boolean,
229
+ ) {
230
+ const result = await this.prisma.$transaction(async (tx) => {
231
+ await this.assertActiveMembership(currentUser.id, orgId, tx);
232
+
233
+ const target = await this.findMembershipInOrg(tx, orgId, membershipId);
234
+ this.assertMembershipCanBeModified(target);
235
+
236
+ const updated = await tx.membership.update({
237
+ where: { id: target.id },
238
+ data: { isBillingContact },
239
+ include: membershipDetailInclude,
240
+ });
241
+
242
+ await this.writeAudit(tx, {
243
+ orgId,
244
+ actorUserId: currentUser.id,
245
+ action: "membership.billing_contact.update",
246
+ targetId: target.id,
247
+ metadata: {
248
+ previousIsBillingContact: target.isBillingContact,
249
+ nextIsBillingContact: isBillingContact,
250
+ targetUserId: target.userId,
251
+ },
252
+ });
253
+
254
+ return {
255
+ updated,
256
+ affectedUserIds: [target.userId],
257
+ };
258
+ });
259
+
260
+ this.rbacCache.invalidateMany(
261
+ result.affectedUserIds.map((userId) => ({ userId, orgId })),
262
+ );
263
+ return result.updated;
264
+ }
265
+
266
+ async transferOwner(
267
+ currentUser: AuthenticatedUser,
268
+ orgId: string,
269
+ toMembershipId: string,
270
+ ) {
271
+ const result = await this.prisma.$transaction(async (tx) => {
272
+ const actorMembership = await this.assertActiveOwner(
273
+ currentUser.id,
274
+ orgId,
275
+ tx,
276
+ );
277
+ const target = await this.findMembershipInOrg(tx, orgId, toMembershipId);
278
+
279
+ if (target.status !== MembershipStatus.ACTIVE) {
280
+ throw new ConflictException(
281
+ "Ownership can only be transferred to an active membership.",
282
+ );
283
+ }
284
+
285
+ if (actorMembership.id === target.id) {
286
+ return {
287
+ updated: target,
288
+ affectedUserIds: [],
289
+ };
290
+ }
291
+
292
+ await tx.membership.update({
293
+ where: { id: target.id },
294
+ data: { isOwner: true },
295
+ });
296
+
297
+ await tx.membership.update({
298
+ where: { id: actorMembership.id },
299
+ data: { isOwner: false },
300
+ });
301
+
302
+ await this.writeAudit(tx, {
303
+ orgId,
304
+ actorUserId: currentUser.id,
305
+ action: "membership.owner.transfer",
306
+ targetId: target.id,
307
+ metadata: {
308
+ fromMembershipId: actorMembership.id,
309
+ toMembershipId: target.id,
310
+ toUserId: target.userId,
311
+ },
312
+ });
313
+
314
+ const updated = await tx.membership.findUniqueOrThrow({
315
+ where: { id: target.id },
316
+ include: membershipDetailInclude,
317
+ });
318
+
319
+ return {
320
+ updated,
321
+ affectedUserIds: [actorMembership.userId, target.userId],
322
+ };
323
+ });
324
+
325
+ this.rbacCache.invalidateMany(
326
+ result.affectedUserIds.map((userId) => ({ userId, orgId })),
327
+ );
328
+ return result.updated;
329
+ }
330
+
331
+ async assertActiveMembership(
332
+ userId: string,
333
+ orgId: string,
334
+ client: PrismaClient = this.prisma,
335
+ ) {
336
+ this.requestContext.assertOrgScope(orgId);
337
+
338
+ const membership = await client.membership.findUnique({
339
+ where: {
340
+ userId_orgId: {
341
+ userId,
342
+ orgId,
343
+ },
344
+ },
345
+ include: membershipDetailInclude,
346
+ });
347
+
348
+ if (!membership || membership.status !== MembershipStatus.ACTIVE) {
349
+ throw new ForbiddenException(
350
+ "Active organisation membership is required.",
351
+ );
352
+ }
353
+
354
+ return membership;
355
+ }
356
+
357
+ async assertActiveOwner(
358
+ userId: string,
359
+ orgId: string,
360
+ client: PrismaClient = this.prisma,
361
+ ) {
362
+ const membership = await this.assertActiveMembership(userId, orgId, client);
363
+
364
+ if (!membership.isOwner) {
365
+ throw new ForbiddenException("Organisation owner access is required.");
366
+ }
367
+
368
+ return membership;
369
+ }
370
+
371
+ private async findMembershipInOrg(
372
+ client: PrismaClient,
373
+ orgId: string,
374
+ membershipId: string,
375
+ ) {
376
+ const membership = await client.membership.findFirst({
377
+ where: {
378
+ id: membershipId,
379
+ orgId,
380
+ },
381
+ include: membershipDetailInclude,
382
+ });
383
+
384
+ if (!membership) {
385
+ throw new NotFoundException(
386
+ "Membership was not found in this organisation.",
387
+ );
388
+ }
389
+
390
+ return membership;
391
+ }
392
+
393
+ private assertRevokedMembershipIsNotReused(
394
+ membership: MembershipDetail,
395
+ nextStatus: MembershipStatus,
396
+ ) {
397
+ if (
398
+ membership.status === MembershipStatus.REVOKED &&
399
+ nextStatus !== MembershipStatus.REVOKED
400
+ ) {
401
+ throw new ConflictException(
402
+ "Revoked memberships cannot be reactivated or reused.",
403
+ );
404
+ }
405
+ }
406
+
407
+ private assertMembershipCanBeModified(membership: MembershipDetail) {
408
+ if (membership.status === MembershipStatus.REVOKED) {
409
+ throw new ConflictException("Revoked memberships cannot be modified.");
410
+ }
411
+ }
412
+
413
+ private async ensureOtherActiveOwner(
414
+ tx: Prisma.TransactionClient,
415
+ orgId: string,
416
+ targetMembershipId: string,
417
+ ) {
418
+ const activeOwners = await tx.$queryRaw<Array<{ id: string }>>`
419
+ SELECT "id"
420
+ FROM "Membership"
421
+ WHERE "orgId" = ${orgId}
422
+ AND "isOwner" = true
423
+ AND "status" = 'ACTIVE'::"MembershipStatus"
424
+ FOR UPDATE
425
+ `;
426
+
427
+ if (!activeOwners.some((owner) => owner.id !== targetMembershipId)) {
428
+ throw new ConflictException(
429
+ "The last active owner cannot be removed, suspended, or demoted.",
430
+ );
431
+ }
432
+ }
433
+
434
+ private async writeAudit(
435
+ client: PrismaClient,
436
+ data: {
437
+ orgId: string;
438
+ actorUserId: string;
439
+ action: string;
440
+ targetId: string;
441
+ metadata?: Prisma.InputJsonObject;
442
+ },
443
+ ) {
444
+ await client.auditLog.create({
445
+ data: {
446
+ orgId: data.orgId,
447
+ actorUserId: data.actorUserId,
448
+ action: data.action,
449
+ targetType: "Membership",
450
+ targetId: data.targetId,
451
+ metadata: data.metadata,
452
+ },
453
+ });
454
+ }
455
+ }
@@ -0,0 +1,11 @@
1
+ import { ApiProperty } from "@nestjs/swagger";
2
+ import { IsUUID } from "class-validator";
3
+
4
+ export class TransferOwnerDto {
5
+ @ApiProperty({
6
+ example: "0a57fb4a-95c6-4f7e-bd5a-f96dbe0599e3",
7
+ format: "uuid",
8
+ })
9
+ @IsUUID()
10
+ toMembershipId!: string;
11
+ }
@@ -0,0 +1,8 @@
1
+ import { ApiProperty } from "@nestjs/swagger";
2
+ import { IsBoolean } from "class-validator";
3
+
4
+ export class UpdateBillingContactDto {
5
+ @ApiProperty({ example: true })
6
+ @IsBoolean()
7
+ isBillingContact!: boolean;
8
+ }
@@ -0,0 +1,8 @@
1
+ import { ApiProperty } from "@nestjs/swagger";
2
+ import { IsBoolean } from "class-validator";
3
+
4
+ export class UpdateMembershipOwnerDto {
5
+ @ApiProperty({ example: true })
6
+ @IsBoolean()
7
+ isOwner!: boolean;
8
+ }
@@ -0,0 +1,11 @@
1
+ import { ApiProperty } from "@nestjs/swagger";
2
+ import { IsUUID } from "class-validator";
3
+
4
+ export class UpdateMembershipRoleDto {
5
+ @ApiProperty({
6
+ example: "f602c057-04f4-4ef8-8c84-1b7c62fbf8c5",
7
+ format: "uuid",
8
+ })
9
+ @IsUUID()
10
+ roleId!: string;
11
+ }
@@ -0,0 +1,9 @@
1
+ import { ApiProperty } from "@nestjs/swagger";
2
+ import { MembershipStatus } from "@prisma/client";
3
+ import { IsEnum } from "class-validator";
4
+
5
+ export class UpdateMembershipStatusDto {
6
+ @ApiProperty({ enum: MembershipStatus, example: MembershipStatus.SUSPENDED })
7
+ @IsEnum(MembershipStatus)
8
+ status!: MembershipStatus;
9
+ }
@@ -0,0 +1,12 @@
1
+ import { Module } from "@nestjs/common";
2
+ import { AuthModule } from "../auth/auth.module";
3
+ import { MembershipsService } from "./application/services/memberships.service";
4
+ import { MembershipsController } from "./presentation/memberships.controller";
5
+
6
+ @Module({
7
+ imports: [AuthModule],
8
+ controllers: [MembershipsController],
9
+ providers: [MembershipsService],
10
+ exports: [MembershipsService],
11
+ })
12
+ export class MembershipsModule {}
@@ -0,0 +1,193 @@
1
+ import {
2
+ Body,
3
+ Controller,
4
+ Get,
5
+ HttpCode,
6
+ Param,
7
+ Patch,
8
+ Post,
9
+ UseGuards,
10
+ } from "@nestjs/common";
11
+ import {
12
+ ApiBearerAuth,
13
+ ApiOkResponse,
14
+ ApiOperation,
15
+ ApiParam,
16
+ ApiTags,
17
+ } from "@nestjs/swagger";
18
+ import { MembershipResponseDto } from "../../../common/dto/membership-response.dto";
19
+ import { ApiProtectedErrorResponses } from "../../../common/swagger/api-error-responses";
20
+ import { PermissionGuard } from "../../access-control/application/services/permission.guard";
21
+ import { RequirePermissions } from "../../access-control/presentation/permissions.decorator";
22
+ import { JwtAuthGuard } from "../../auth/infrastructure/passport/jwt-auth.guard";
23
+ import { CurrentUser } from "../../auth/presentation/current-user.decorator";
24
+ import { AuthenticatedUser } from "../../auth/types/authenticated-user";
25
+ import { OrgScopeGuard } from "../../request-context/presentation/org-scope.guard";
26
+ import { MembershipsService } from "../application/services/memberships.service";
27
+ import { TransferOwnerDto } from "../dto/transfer-owner.dto";
28
+ import { UpdateBillingContactDto } from "../dto/update-billing-contact.dto";
29
+ import { UpdateMembershipOwnerDto } from "../dto/update-membership-owner.dto";
30
+ import { UpdateMembershipRoleDto } from "../dto/update-membership-role.dto";
31
+ import { UpdateMembershipStatusDto } from "../dto/update-membership-status.dto";
32
+
33
+ @ApiTags("Memberships")
34
+ @ApiBearerAuth()
35
+ @ApiParam({ name: "orgId", description: "Organisation ID.", format: "uuid" })
36
+ @ApiProtectedErrorResponses(404, 409)
37
+ @Controller("organisations/:orgId/memberships")
38
+ @UseGuards(JwtAuthGuard, OrgScopeGuard, PermissionGuard)
39
+ export class MembershipsController {
40
+ constructor(private readonly membershipsService: MembershipsService) {}
41
+
42
+ @Get("me")
43
+ @RequirePermissions("memberships.read")
44
+ @ApiOperation({
45
+ summary: "Return the current user membership in the organisation.",
46
+ })
47
+ @ApiOkResponse({
48
+ description: "Current membership.",
49
+ type: MembershipResponseDto,
50
+ })
51
+ me(@CurrentUser() user: AuthenticatedUser, @Param("orgId") orgId: string) {
52
+ return this.membershipsService.getCurrentMembership(user, orgId);
53
+ }
54
+
55
+ @Get()
56
+ @RequirePermissions("memberships.read")
57
+ @ApiOperation({ summary: "List organisation memberships." })
58
+ @ApiOkResponse({
59
+ description: "Organisation memberships.",
60
+ type: [MembershipResponseDto],
61
+ })
62
+ list(@CurrentUser() user: AuthenticatedUser, @Param("orgId") orgId: string) {
63
+ return this.membershipsService.listMemberships(user, orgId);
64
+ }
65
+
66
+ @Patch(":membershipId/status")
67
+ @RequirePermissions("memberships.revoke")
68
+ @ApiParam({
69
+ name: "membershipId",
70
+ description: "Membership ID.",
71
+ format: "uuid",
72
+ })
73
+ @ApiOperation({ summary: "Update membership status." })
74
+ @ApiOkResponse({
75
+ description: "Updated membership.",
76
+ type: MembershipResponseDto,
77
+ })
78
+ updateStatus(
79
+ @CurrentUser() user: AuthenticatedUser,
80
+ @Param("orgId") orgId: string,
81
+ @Param("membershipId") membershipId: string,
82
+ @Body() dto: UpdateMembershipStatusDto,
83
+ ) {
84
+ return this.membershipsService.updateStatus(
85
+ user,
86
+ orgId,
87
+ membershipId,
88
+ dto.status,
89
+ );
90
+ }
91
+
92
+ @Patch(":membershipId/role")
93
+ @RequirePermissions("roles.manage")
94
+ @ApiParam({
95
+ name: "membershipId",
96
+ description: "Membership ID.",
97
+ format: "uuid",
98
+ })
99
+ @ApiOperation({ summary: "Assign a role to a membership." })
100
+ @ApiOkResponse({
101
+ description: "Updated membership.",
102
+ type: MembershipResponseDto,
103
+ })
104
+ updateRole(
105
+ @CurrentUser() user: AuthenticatedUser,
106
+ @Param("orgId") orgId: string,
107
+ @Param("membershipId") membershipId: string,
108
+ @Body() dto: UpdateMembershipRoleDto,
109
+ ) {
110
+ return this.membershipsService.assignRole(
111
+ user,
112
+ orgId,
113
+ membershipId,
114
+ dto.roleId,
115
+ );
116
+ }
117
+
118
+ @Patch(":membershipId/owner")
119
+ @RequirePermissions("roles.manage")
120
+ @ApiParam({
121
+ name: "membershipId",
122
+ description: "Membership ID.",
123
+ format: "uuid",
124
+ })
125
+ @ApiOperation({ summary: "Grant or remove owner status on a membership." })
126
+ @ApiOkResponse({
127
+ description: "Updated membership.",
128
+ type: MembershipResponseDto,
129
+ })
130
+ updateOwner(
131
+ @CurrentUser() user: AuthenticatedUser,
132
+ @Param("orgId") orgId: string,
133
+ @Param("membershipId") membershipId: string,
134
+ @Body() dto: UpdateMembershipOwnerDto,
135
+ ) {
136
+ return this.membershipsService.updateOwner(
137
+ user,
138
+ orgId,
139
+ membershipId,
140
+ dto.isOwner,
141
+ );
142
+ }
143
+
144
+ @Patch(":membershipId/billing-contact")
145
+ @RequirePermissions("billing.manage")
146
+ @ApiParam({
147
+ name: "membershipId",
148
+ description: "Membership ID.",
149
+ format: "uuid",
150
+ })
151
+ @ApiOperation({
152
+ summary: "Grant or remove billing contact status on a membership.",
153
+ })
154
+ @ApiOkResponse({
155
+ description: "Updated membership.",
156
+ type: MembershipResponseDto,
157
+ })
158
+ updateBillingContact(
159
+ @CurrentUser() user: AuthenticatedUser,
160
+ @Param("orgId") orgId: string,
161
+ @Param("membershipId") membershipId: string,
162
+ @Body() dto: UpdateBillingContactDto,
163
+ ) {
164
+ return this.membershipsService.updateBillingContact(
165
+ user,
166
+ orgId,
167
+ membershipId,
168
+ dto.isBillingContact,
169
+ );
170
+ }
171
+
172
+ @Post("transfer-owner")
173
+ @HttpCode(200)
174
+ @RequirePermissions("roles.manage")
175
+ @ApiOperation({
176
+ summary: "Transfer organisation ownership to another active membership.",
177
+ })
178
+ @ApiOkResponse({
179
+ description: "New owner membership.",
180
+ type: MembershipResponseDto,
181
+ })
182
+ transferOwner(
183
+ @CurrentUser() user: AuthenticatedUser,
184
+ @Param("orgId") orgId: string,
185
+ @Body() dto: TransferOwnerDto,
186
+ ) {
187
+ return this.membershipsService.transferOwner(
188
+ user,
189
+ orgId,
190
+ dto.toMembershipId,
191
+ );
192
+ }
193
+ }