@ftisindia/create-app 0.1.3 → 0.1.4

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 (108) hide show
  1. package/package.json +1 -1
  2. package/template/README.md +1 -1
  3. package/template/_package.json +0 -2
  4. package/template/docs/API_REFERENCE.md +13 -0
  5. package/template/docs/OAUTH.md +7 -3
  6. package/template/scripts/gen-module.mjs +2 -0
  7. package/template/src/app.module.ts +16 -22
  8. package/template/src/common/dto/error-response.dto.ts +3 -3
  9. package/template/src/common/dto/membership-response.dto.ts +26 -14
  10. package/template/src/common/dto/mutation-response.dto.ts +1 -1
  11. package/template/src/common/dto/pagination-query.dto.ts +37 -0
  12. package/template/src/common/dto/role-summary.dto.ts +5 -5
  13. package/template/src/common/dto/user-summary.dto.ts +6 -6
  14. package/template/src/common/filters/http-exception.filter.ts +9 -19
  15. package/template/src/common/swagger/api-error-responses.ts +12 -12
  16. package/template/src/config/app.config.ts +3 -3
  17. package/template/src/config/auth.config.ts +3 -3
  18. package/template/src/config/database.config.ts +3 -3
  19. package/template/src/config/env.validation.ts +58 -40
  20. package/template/src/config/index.ts +5 -5
  21. package/template/src/config/rbac.config.ts +3 -3
  22. package/template/src/database/prisma/prisma-transaction.ts +1 -1
  23. package/template/src/database/prisma/prisma.module.ts +2 -2
  24. package/template/src/database/prisma/prisma.service.ts +3 -6
  25. package/template/src/main.ts +11 -11
  26. package/template/src/modules/access-control/access-control.module.ts +9 -9
  27. package/template/src/modules/access-control/application/role-permission-policy.ts +71 -0
  28. package/template/src/modules/access-control/application/route-registry.validator.ts +34 -63
  29. package/template/src/modules/access-control/application/services/ability.factory.ts +5 -9
  30. package/template/src/modules/access-control/application/services/access-control.service.ts +78 -85
  31. package/template/src/modules/access-control/application/services/permission.guard.ts +16 -21
  32. package/template/src/modules/access-control/application/services/rbac-cache.service.ts +7 -9
  33. package/template/src/modules/access-control/dto/access-control-response.dto.ts +32 -20
  34. package/template/src/modules/access-control/dto/create-role.dto.ts +6 -6
  35. package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +3 -10
  36. package/template/src/modules/access-control/dto/update-role.dto.ts +6 -6
  37. package/template/src/modules/access-control/presentation/access-control.controller.ts +69 -74
  38. package/template/src/modules/access-control/presentation/permissions.decorator.ts +3 -3
  39. package/template/src/modules/access-control/presentation/public.decorator.ts +2 -2
  40. package/template/src/modules/access-control/types/permission-key.ts +19 -19
  41. package/template/src/modules/access-control/types/route-permission-registry.ts +76 -76
  42. package/template/src/modules/audit/application/services/audit.service.ts +7 -7
  43. package/template/src/modules/audit/audit.module.ts +4 -4
  44. package/template/src/modules/audit/dto/audit-response.dto.ts +18 -18
  45. package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +14 -14
  46. package/template/src/modules/audit/presentation/audit.controller.ts +17 -23
  47. package/template/src/modules/auth/application/services/auth.service.ts +147 -110
  48. package/template/src/modules/auth/application/services/password.service.ts +2 -2
  49. package/template/src/modules/auth/application/services/token.service.ts +20 -21
  50. package/template/src/modules/auth/auth.module.ts +20 -47
  51. package/template/src/modules/auth/dto/auth-response.dto.ts +9 -10
  52. package/template/src/modules/auth/dto/login.dto.ts +4 -4
  53. package/template/src/modules/auth/dto/logout.dto.ts +1 -1
  54. package/template/src/modules/auth/dto/oauth-exchange.dto.ts +4 -5
  55. package/template/src/modules/auth/dto/refresh-token.dto.ts +4 -5
  56. package/template/src/modules/auth/dto/signup.dto.ts +5 -11
  57. package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +6 -14
  58. package/template/src/modules/auth/infrastructure/passport/google-oauth-state.store.ts +98 -0
  59. package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +21 -30
  60. package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +3 -3
  61. package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +11 -11
  62. package/template/src/modules/auth/presentation/auth.controller.ts +45 -45
  63. package/template/src/modules/auth/presentation/current-user.decorator.ts +3 -5
  64. package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +5 -10
  65. package/template/src/modules/health/dto/health-response.dto.ts +5 -5
  66. package/template/src/modules/health/health.module.ts +2 -2
  67. package/template/src/modules/health/presentation/health.controller.ts +13 -13
  68. package/template/src/modules/invitations/application/services/invitations.service.ts +127 -176
  69. package/template/src/modules/invitations/dto/accept-invitation.dto.ts +6 -7
  70. package/template/src/modules/invitations/dto/create-invitation.dto.ts +14 -15
  71. package/template/src/modules/invitations/dto/invitation-response.dto.ts +37 -29
  72. package/template/src/modules/invitations/dto/invitation-token.dto.ts +4 -4
  73. package/template/src/modules/invitations/invitations.module.ts +5 -5
  74. package/template/src/modules/invitations/presentation/invitations.controller.ts +61 -63
  75. package/template/src/modules/memberships/application/services/memberships.service.ts +70 -84
  76. package/template/src/modules/memberships/dto/transfer-owner.dto.ts +4 -4
  77. package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +2 -2
  78. package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +2 -2
  79. package/template/src/modules/memberships/dto/update-membership-role.dto.ts +4 -4
  80. package/template/src/modules/memberships/dto/update-membership-status.dto.ts +3 -3
  81. package/template/src/modules/memberships/memberships.module.ts +4 -4
  82. package/template/src/modules/memberships/presentation/memberships.controller.ts +83 -99
  83. package/template/src/modules/organisations/application/services/organisations.service.ts +21 -23
  84. package/template/src/modules/organisations/dto/create-organisation.dto.ts +6 -13
  85. package/template/src/modules/organisations/dto/organisation-response.dto.ts +14 -14
  86. package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +4 -7
  87. package/template/src/modules/organisations/organisations.module.ts +5 -5
  88. package/template/src/modules/organisations/presentation/organisations.controller.ts +14 -23
  89. package/template/src/modules/organisations/types/default-organisation-data.ts +3 -9
  90. package/template/src/modules/request-context/application/services/request-context.service.ts +15 -7
  91. package/template/src/modules/request-context/presentation/org-scope.guard.ts +4 -9
  92. package/template/src/modules/request-context/presentation/request-context.interceptor.ts +4 -9
  93. package/template/src/modules/request-context/presentation/request-context.middleware.ts +7 -8
  94. package/template/src/modules/request-context/request-context.module.ts +7 -7
  95. package/template/src/modules/request-context/types/request-context.ts +2 -2
  96. package/template/src/modules/sample/application/services/sample.service.ts +10 -8
  97. package/template/src/modules/sample/dto/sample-echo.dto.ts +3 -3
  98. package/template/src/modules/sample/dto/sample-response.dto.ts +12 -12
  99. package/template/src/modules/sample/presentation/sample.controller.ts +25 -42
  100. package/template/src/modules/sample/sample.module.ts +4 -4
  101. package/template/src/modules/settings/application/services/settings.service.ts +15 -27
  102. package/template/src/modules/settings/dto/setting-response.dto.ts +9 -9
  103. package/template/src/modules/settings/dto/update-setting.dto.ts +5 -5
  104. package/template/src/modules/settings/presentation/settings.controller.ts +29 -35
  105. package/template/src/modules/settings/settings.module.ts +5 -5
  106. package/template/src/modules/settings/types/setting-definitions.ts +49 -33
  107. package/template/test/auth-refresh.spec.ts +90 -0
  108. package/template/test/role-permission-policy.spec.ts +94 -0
@@ -5,7 +5,7 @@ import {
5
5
  Injectable,
6
6
  NotFoundException,
7
7
  UnauthorizedException,
8
- } from "@nestjs/common";
8
+ } from '@nestjs/common';
9
9
  import {
10
10
  AuthProvider,
11
11
  InvitationStatus,
@@ -13,18 +13,24 @@ import {
13
13
  MembershipStatus,
14
14
  Prisma,
15
15
  User,
16
- } from "@prisma/client";
17
- import { isEmail } from "class-validator";
18
- import { createHash, randomBytes } from "crypto";
19
- import { PrismaService } from "../../../../database/prisma/prisma.service";
20
- import { RbacCacheService } from "../../../access-control/application/services/rbac-cache.service";
21
- import { PasswordService } from "../../../auth/application/services/password.service";
22
- import { AuthenticatedUser } from "../../../auth/types/authenticated-user";
23
- import { MembershipsService } from "../../../memberships/application/services/memberships.service";
24
- import { RequestContextService } from "../../../request-context/application/services/request-context.service";
25
- import { AcceptInvitationDto } from "../../dto/accept-invitation.dto";
26
- import { CreateInvitationDto } from "../../dto/create-invitation.dto";
27
- import { InvitationTokenDto } from "../../dto/invitation-token.dto";
16
+ } from '@prisma/client';
17
+ import { isEmail } from 'class-validator';
18
+ import { createHash, randomBytes } from 'crypto';
19
+ import {
20
+ PaginationQueryDto,
21
+ resolvePageLimit,
22
+ toPage,
23
+ } from '../../../../common/dto/pagination-query.dto';
24
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
25
+ import { assertRoleWithinActorPermissions } from '../../../access-control/application/role-permission-policy';
26
+ import { RbacCacheService } from '../../../access-control/application/services/rbac-cache.service';
27
+ import { PasswordService } from '../../../auth/application/services/password.service';
28
+ import { AuthenticatedUser } from '../../../auth/types/authenticated-user';
29
+ import { MembershipsService } from '../../../memberships/application/services/memberships.service';
30
+ import { RequestContextService } from '../../../request-context/application/services/request-context.service';
31
+ import { AcceptInvitationDto } from '../../dto/accept-invitation.dto';
32
+ import { CreateInvitationDto } from '../../dto/create-invitation.dto';
33
+ import { InvitationTokenDto } from '../../dto/invitation-token.dto';
28
34
 
29
35
  const DEFAULT_INVITATION_EXPIRY_DAYS = 7;
30
36
  const mobileTargetPattern = /^\+?[1-9]\d{6,14}$/;
@@ -90,41 +96,22 @@ export class InvitationsService {
90
96
  private readonly requestContext: RequestContextService,
91
97
  ) {}
92
98
 
93
- async createInvitation(
94
- currentUser: AuthenticatedUser,
95
- orgId: string,
96
- dto: CreateInvitationDto,
97
- ) {
99
+ async createInvitation(currentUser: AuthenticatedUser, orgId: string, dto: CreateInvitationDto) {
98
100
  this.requestContext.assertOrgScope(orgId);
99
101
 
100
- const normalizedTargetValue = normalizeTarget(
101
- dto.targetType,
102
- dto.targetValue,
103
- );
102
+ const normalizedTargetValue = normalizeTarget(dto.targetType, dto.targetValue);
104
103
  const targetValue = dto.targetValue.trim();
105
104
  const inviteToken = createInvitationToken();
106
105
  const tokenHash = hashInvitationToken(inviteToken);
107
- const expiresAt = addDays(
108
- new Date(),
109
- dto.expiresInDays ?? DEFAULT_INVITATION_EXPIRY_DAYS,
110
- );
106
+ const expiresAt = addDays(new Date(), dto.expiresInDays ?? DEFAULT_INVITATION_EXPIRY_DAYS);
111
107
 
112
108
  let invitation: InvitationDetail;
113
109
  try {
114
110
  invitation = await this.prisma.$transaction(async (tx) => {
115
- await this.membershipsService.assertActiveMembership(
116
- currentUser.id,
117
- orgId,
118
- tx,
119
- );
111
+ await this.membershipsService.assertActiveMembership(currentUser.id, orgId, tx);
120
112
  await this.lockOrganisation(tx, orgId);
121
- await this.assertRoleBelongsToOrg(tx, orgId, dto.roleId);
122
- await this.assertNoPendingInvitation(
123
- tx,
124
- orgId,
125
- dto.targetType,
126
- normalizedTargetValue,
127
- );
113
+ await this.assertRoleWithinInviterPermissions(tx, currentUser, orgId, dto.roleId);
114
+ await this.assertNoPendingInvitation(tx, orgId, dto.targetType, normalizedTargetValue);
128
115
  await this.assertTargetIsNotExistingMember(
129
116
  tx,
130
117
  orgId,
@@ -149,7 +136,7 @@ export class InvitationsService {
149
136
  await this.writeAudit(tx, {
150
137
  orgId,
151
138
  actorUserId: currentUser.id,
152
- action: "invitation.create",
139
+ action: 'invitation.create',
153
140
  targetId: created.id,
154
141
  metadata: {
155
142
  targetType: created.targetType,
@@ -163,9 +150,7 @@ export class InvitationsService {
163
150
  });
164
151
  } catch (error) {
165
152
  if (isPrismaUniqueError(error)) {
166
- throw new ConflictException(
167
- "A pending invitation already exists for this target.",
168
- );
153
+ throw new ConflictException('A pending invitation already exists for this target.');
169
154
  }
170
155
 
171
156
  throw error;
@@ -177,38 +162,34 @@ export class InvitationsService {
177
162
  };
178
163
  }
179
164
 
180
- async listInvitations(currentUser: AuthenticatedUser, orgId: string) {
165
+ async listInvitations(currentUser: AuthenticatedUser, orgId: string, query: PaginationQueryDto) {
181
166
  this.requestContext.assertOrgScope(orgId);
182
167
  await this.membershipsService.assertActiveMembership(currentUser.id, orgId);
168
+ const limit = resolvePageLimit(query.limit);
183
169
 
184
170
  const invitations = await this.prisma.invitation.findMany({
185
171
  where: { orgId },
186
172
  include: invitationDetailInclude,
187
- orderBy: { createdAt: "desc" },
173
+ orderBy: { createdAt: 'desc' },
174
+ take: limit + 1,
175
+ ...(query.cursor
176
+ ? {
177
+ cursor: { id: query.cursor },
178
+ skip: 1,
179
+ }
180
+ : {}),
188
181
  });
189
182
 
190
- return invitations.map(serializeInvitation);
183
+ return toPage(invitations.map(serializeInvitation), limit);
191
184
  }
192
185
 
193
- async revokeInvitation(
194
- currentUser: AuthenticatedUser,
195
- orgId: string,
196
- invitationId: string,
197
- ) {
186
+ async revokeInvitation(currentUser: AuthenticatedUser, orgId: string, invitationId: string) {
198
187
  this.requestContext.assertOrgScope(orgId);
199
188
 
200
189
  return this.prisma.$transaction(async (tx) => {
201
- await this.membershipsService.assertActiveMembership(
202
- currentUser.id,
203
- orgId,
204
- tx,
205
- );
190
+ await this.membershipsService.assertActiveMembership(currentUser.id, orgId, tx);
206
191
 
207
- const invitation = await this.findInvitationInOrgForUpdate(
208
- tx,
209
- orgId,
210
- invitationId,
211
- );
192
+ const invitation = await this.findInvitationInOrgForUpdate(tx, orgId, invitationId);
212
193
  this.assertPendingInvitation(invitation);
213
194
  this.assertInvitationNotExpiredInTransaction(invitation);
214
195
 
@@ -236,7 +217,7 @@ export class InvitationsService {
236
217
  await this.writeAudit(tx, {
237
218
  orgId,
238
219
  actorUserId: currentUser.id,
239
- action: "invitation.revoke",
220
+ action: 'invitation.revoke',
240
221
  targetId: invitation.id,
241
222
  metadata: {
242
223
  targetType: invitation.targetType,
@@ -248,30 +229,18 @@ export class InvitationsService {
248
229
  });
249
230
  }
250
231
 
251
- async resendInvitation(
252
- currentUser: AuthenticatedUser,
253
- orgId: string,
254
- invitationId: string,
255
- ) {
232
+ async resendInvitation(currentUser: AuthenticatedUser, orgId: string, invitationId: string) {
256
233
  this.requestContext.assertOrgScope(orgId);
257
234
 
258
235
  const inviteToken = createInvitationToken();
259
236
  const tokenHash = hashInvitationToken(inviteToken);
260
237
 
261
238
  const invitation = await this.prisma.$transaction(async (tx) => {
262
- await this.membershipsService.assertActiveMembership(
263
- currentUser.id,
264
- orgId,
265
- tx,
266
- );
239
+ await this.membershipsService.assertActiveMembership(currentUser.id, orgId, tx);
267
240
 
268
- const existing = await this.findInvitationInOrgForUpdate(
269
- tx,
270
- orgId,
271
- invitationId,
272
- );
241
+ const existing = await this.findInvitationInOrgForUpdate(tx, orgId, invitationId);
273
242
  this.assertResendableInvitation(existing);
274
- await this.assertRoleBelongsToOrg(tx, orgId, existing.roleId);
243
+ await this.assertRoleWithinInviterPermissions(tx, currentUser, orgId, existing.roleId);
275
244
  await this.assertTargetIsNotExistingMember(
276
245
  tx,
277
246
  orgId,
@@ -305,7 +274,7 @@ export class InvitationsService {
305
274
  await this.writeAudit(tx, {
306
275
  orgId,
307
276
  actorUserId: currentUser.id,
308
- action: "invitation.resend",
277
+ action: 'invitation.resend',
309
278
  targetId: existing.id,
310
279
  metadata: {
311
280
  targetType: existing.targetType,
@@ -329,24 +298,13 @@ export class InvitationsService {
329
298
  await this.assertInvitationNotExpired(invitation);
330
299
 
331
300
  const result = await this.prisma.$transaction(async (tx) => {
332
- const pendingInvitation = await this.findInvitationByTokenForUpdate(
333
- tx,
334
- tokenHash,
335
- );
301
+ const pendingInvitation = await this.findInvitationByTokenForUpdate(tx, tokenHash);
336
302
  this.assertPendingInvitation(pendingInvitation);
337
303
  this.assertInvitationNotExpiredInTransaction(pendingInvitation);
338
- await this.assertRoleBelongsToOrg(
339
- tx,
340
- pendingInvitation.orgId,
341
- pendingInvitation.roleId,
342
- );
304
+ await this.assertRoleBelongsToOrg(tx, pendingInvitation.orgId, pendingInvitation.roleId);
343
305
 
344
306
  const user = await this.resolveTargetUser(tx, pendingInvitation, dto);
345
- await this.assertMembershipCanBeCreated(
346
- tx,
347
- pendingInvitation.orgId,
348
- user.id,
349
- );
307
+ await this.assertMembershipCanBeCreated(tx, pendingInvitation.orgId, user.id);
350
308
 
351
309
  const membership = await tx.membership.create({
352
310
  data: {
@@ -385,7 +343,7 @@ export class InvitationsService {
385
343
  await this.writeAudit(tx, {
386
344
  orgId: pendingInvitation.orgId,
387
345
  actorUserId: user.id,
388
- action: "invitation.accept",
346
+ action: 'invitation.accept',
389
347
  targetId: pendingInvitation.id,
390
348
  metadata: {
391
349
  targetType: pendingInvitation.targetType,
@@ -397,8 +355,8 @@ export class InvitationsService {
397
355
  await this.writeAudit(tx, {
398
356
  orgId: pendingInvitation.orgId,
399
357
  actorUserId: user.id,
400
- action: "membership.created",
401
- targetType: "Membership",
358
+ action: 'membership.created',
359
+ targetType: 'Membership',
402
360
  targetId: membership.id,
403
361
  metadata: {
404
362
  invitedByUserId: pendingInvitation.invitedByUserId,
@@ -419,10 +377,7 @@ export class InvitationsService {
419
377
  };
420
378
  });
421
379
 
422
- this.rbacCache.invalidate(
423
- result.rbacInvalidation.userId,
424
- result.rbacInvalidation.orgId,
425
- );
380
+ this.rbacCache.invalidate(result.rbacInvalidation.userId, result.rbacInvalidation.orgId);
426
381
 
427
382
  const { rbacInvalidation: _rbacInvalidation, ...response } = result;
428
383
  return response;
@@ -434,10 +389,7 @@ export class InvitationsService {
434
389
  await this.assertInvitationNotExpired(invitation);
435
390
 
436
391
  return this.prisma.$transaction(async (tx) => {
437
- const pendingInvitation = await this.findInvitationByTokenForUpdate(
438
- tx,
439
- tokenHash,
440
- );
392
+ const pendingInvitation = await this.findInvitationByTokenForUpdate(tx, tokenHash);
441
393
  this.assertPendingInvitation(pendingInvitation);
442
394
  this.assertInvitationNotExpiredInTransaction(pendingInvitation);
443
395
 
@@ -463,7 +415,7 @@ export class InvitationsService {
463
415
 
464
416
  await this.writeAudit(tx, {
465
417
  orgId: pendingInvitation.orgId,
466
- action: "invitation.decline",
418
+ action: 'invitation.decline',
467
419
  targetId: pendingInvitation.id,
468
420
  metadata: {
469
421
  targetType: pendingInvitation.targetType,
@@ -489,10 +441,7 @@ export class InvitationsService {
489
441
  return invitation;
490
442
  }
491
443
 
492
- private async findInvitationByTokenForUpdate(
493
- tx: Prisma.TransactionClient,
494
- tokenHash: string,
495
- ) {
444
+ private async findInvitationByTokenForUpdate(tx: Prisma.TransactionClient, tokenHash: string) {
496
445
  const rows = await tx.$queryRaw<Array<{ id: string }>>`
497
446
  SELECT "id"
498
447
  FROM "Invitation"
@@ -516,11 +465,7 @@ export class InvitationsService {
516
465
  return invitation;
517
466
  }
518
467
 
519
- private async findInvitationInOrg(
520
- client: PrismaClient,
521
- orgId: string,
522
- invitationId: string,
523
- ) {
468
+ private async findInvitationInOrg(client: PrismaClient, orgId: string, invitationId: string) {
524
469
  const invitation = await client.invitation.findFirst({
525
470
  where: {
526
471
  id: invitationId,
@@ -530,9 +475,7 @@ export class InvitationsService {
530
475
  });
531
476
 
532
477
  if (!invitation) {
533
- throw new NotFoundException(
534
- "Invitation was not found in this organisation.",
535
- );
478
+ throw new NotFoundException('Invitation was not found in this organisation.');
536
479
  }
537
480
 
538
481
  return invitation;
@@ -552,9 +495,7 @@ export class InvitationsService {
552
495
  `;
553
496
 
554
497
  if (rows.length === 0) {
555
- throw new NotFoundException(
556
- "Invitation was not found in this organisation.",
557
- );
498
+ throw new NotFoundException('Invitation was not found in this organisation.');
558
499
  }
559
500
 
560
501
  return tx.invitation.findUniqueOrThrow({
@@ -572,8 +513,29 @@ export class InvitationsService {
572
513
  `;
573
514
  }
574
515
 
575
- private async assertRoleBelongsToOrg(
516
+ private async assertRoleBelongsToOrg(client: PrismaClient, orgId: string, roleId: string) {
517
+ const role = await client.role.findUnique({
518
+ where: {
519
+ id_orgId: {
520
+ id: roleId,
521
+ orgId,
522
+ },
523
+ },
524
+ select: { id: true },
525
+ });
526
+
527
+ if (!role) {
528
+ throw new NotFoundException('Role was not found in this organisation.');
529
+ }
530
+ }
531
+
532
+ // Used on the invite-create and resend paths (where there is an authenticated
533
+ // inviter): the role must exist in the org AND its permissions must be within
534
+ // the inviter's own, so a member cannot invite someone into a more privileged
535
+ // role than they hold. The public accept path keeps assertRoleBelongsToOrg.
536
+ private async assertRoleWithinInviterPermissions(
576
537
  client: PrismaClient,
538
+ currentUser: AuthenticatedUser,
577
539
  orgId: string,
578
540
  roleId: string,
579
541
  ) {
@@ -584,12 +546,23 @@ export class InvitationsService {
584
546
  orgId,
585
547
  },
586
548
  },
587
- select: { id: true },
549
+ select: {
550
+ permissions: {
551
+ select: { permission: { select: { key: true } } },
552
+ },
553
+ },
588
554
  });
589
555
 
590
556
  if (!role) {
591
- throw new NotFoundException("Role was not found in this organisation.");
557
+ throw new NotFoundException('Role was not found in this organisation.');
592
558
  }
559
+
560
+ const actorContext = await this.rbacCache.getContext(currentUser.id, orgId);
561
+ assertRoleWithinActorPermissions({
562
+ actorIsOwner: actorContext.isOwner,
563
+ actorPermissionKeys: actorContext.permissionKeys,
564
+ rolePermissionKeys: role.permissions.map((rolePermission) => rolePermission.permission.key),
565
+ });
593
566
  }
594
567
 
595
568
  private async assertNoPendingInvitation(
@@ -609,34 +582,26 @@ export class InvitationsService {
609
582
  });
610
583
 
611
584
  if (existing) {
612
- throw new ConflictException(
613
- "A pending invitation already exists for this target.",
614
- );
585
+ throw new ConflictException('A pending invitation already exists for this target.');
615
586
  }
616
587
  }
617
588
 
618
- private assertPendingInvitation(
619
- invitation: Pick<InvitationDetail, "status">,
620
- ) {
589
+ private assertPendingInvitation(invitation: Pick<InvitationDetail, 'status'>) {
621
590
  if (invitation.status === InvitationStatus.EXPIRED) {
622
- throw new GoneException("Invitation has expired.");
591
+ throw new GoneException('Invitation has expired.');
623
592
  }
624
593
 
625
594
  if (invitation.status !== InvitationStatus.PENDING) {
626
- throw new ConflictException("Invitation is no longer pending.");
595
+ throw new ConflictException('Invitation is no longer pending.');
627
596
  }
628
597
  }
629
598
 
630
- private assertResendableInvitation(
631
- invitation: Pick<InvitationDetail, "status">,
632
- ) {
599
+ private assertResendableInvitation(invitation: Pick<InvitationDetail, 'status'>) {
633
600
  if (
634
601
  invitation.status !== InvitationStatus.PENDING &&
635
602
  invitation.status !== InvitationStatus.EXPIRED
636
603
  ) {
637
- throw new ConflictException(
638
- "Invitation cannot be resent after it has been completed.",
639
- );
604
+ throw new ConflictException('Invitation cannot be resent after it has been completed.');
640
605
  }
641
606
  }
642
607
 
@@ -646,21 +611,19 @@ export class InvitationsService {
646
611
  }
647
612
 
648
613
  await this.markInvitationExpired(invitation);
649
- throw new GoneException("Invitation has expired.");
614
+ throw new GoneException('Invitation has expired.');
650
615
  }
651
616
 
652
- private assertInvitationNotExpiredInTransaction(
653
- invitation: InvitationDetail,
654
- ) {
617
+ private assertInvitationNotExpiredInTransaction(invitation: InvitationDetail) {
655
618
  if (invitation.status === InvitationStatus.EXPIRED) {
656
- throw new GoneException("Invitation has expired.");
619
+ throw new GoneException('Invitation has expired.');
657
620
  }
658
621
 
659
622
  if (invitation.expiresAt > new Date()) {
660
623
  return;
661
624
  }
662
625
 
663
- throw new GoneException("Invitation has expired.");
626
+ throw new GoneException('Invitation has expired.');
664
627
  }
665
628
 
666
629
  private async markInvitationExpired(invitation: InvitationDetail) {
@@ -668,9 +631,7 @@ export class InvitationsService {
668
631
  return;
669
632
  }
670
633
 
671
- await this.prisma.$transaction((tx) =>
672
- this.markInvitationExpiredInTransaction(tx, invitation),
673
- );
634
+ await this.prisma.$transaction((tx) => this.markInvitationExpiredInTransaction(tx, invitation));
674
635
  }
675
636
 
676
637
  private async markInvitationExpiredInTransaction(
@@ -694,7 +655,7 @@ export class InvitationsService {
694
655
 
695
656
  await this.writeAudit(tx, {
696
657
  orgId: invitation.orgId,
697
- action: "invitation.expired",
658
+ action: 'invitation.expired',
698
659
  targetId: invitation.id,
699
660
  metadata: {
700
661
  targetType: invitation.targetType,
@@ -726,7 +687,7 @@ export class InvitationsService {
726
687
 
727
688
  if (existing) {
728
689
  if (!existing.isActive) {
729
- throw new ConflictException("The invited user is inactive.");
690
+ throw new ConflictException('The invited user is inactive.');
730
691
  }
731
692
 
732
693
  return existing;
@@ -734,7 +695,7 @@ export class InvitationsService {
734
695
 
735
696
  if (!dto.password) {
736
697
  throw new BadRequestException(
737
- "Password is required when accepting an email invitation as a new user.",
698
+ 'Password is required when accepting an email invitation as a new user.',
738
699
  );
739
700
  }
740
701
 
@@ -770,7 +731,7 @@ export class InvitationsService {
770
731
 
771
732
  if (existing) {
772
733
  if (!existing.isActive) {
773
- throw new ConflictException("The invited user is inactive.");
734
+ throw new ConflictException('The invited user is inactive.');
774
735
  }
775
736
 
776
737
  return existing;
@@ -818,14 +779,10 @@ export class InvitationsService {
818
779
  }
819
780
 
820
781
  if (existing.status === MembershipStatus.REVOKED) {
821
- throw new ConflictException(
822
- "Revoked memberships cannot be reused through an invitation.",
823
- );
782
+ throw new ConflictException('Revoked memberships cannot be reused through an invitation.');
824
783
  }
825
784
 
826
- throw new ConflictException(
827
- "User already has a membership in this organisation.",
828
- );
785
+ throw new ConflictException('User already has a membership in this organisation.');
829
786
  }
830
787
 
831
788
  private async assertTargetIsNotExistingMember(
@@ -865,13 +822,11 @@ export class InvitationsService {
865
822
 
866
823
  if (membership.status === MembershipStatus.REVOKED) {
867
824
  throw new ConflictException(
868
- "A revoked membership already exists for this invitation target.",
825
+ 'A revoked membership already exists for this invitation target.',
869
826
  );
870
827
  }
871
828
 
872
- throw new ConflictException(
873
- "Invitation target already has a membership in this organisation.",
874
- );
829
+ throw new ConflictException('Invitation target already has a membership in this organisation.');
875
830
  }
876
831
 
877
832
  private async writeAudit(
@@ -890,24 +845,25 @@ export class InvitationsService {
890
845
  orgId: data.orgId,
891
846
  actorUserId: data.actorUserId,
892
847
  action: data.action,
893
- targetType: data.targetType ?? "Invitation",
848
+ targetType: data.targetType ?? 'Invitation',
894
849
  targetId: data.targetId,
895
850
  metadata: data.metadata,
851
+ ipAddress: this.requestContext.getIpAddress(),
852
+ userAgent: this.requestContext.getUserAgent(),
896
853
  },
897
854
  });
898
855
  }
899
856
  }
900
857
 
901
858
  function createInvitationToken() {
902
- return randomBytes(32).toString("base64url");
859
+ return randomBytes(32).toString('base64url');
903
860
  }
904
861
 
905
862
  function hashInvitationToken(token: string) {
906
- return createHash("sha256").update(token).digest("hex");
863
+ return createHash('sha256').update(token).digest('hex');
907
864
  }
908
865
 
909
- const deadTokenHash = () =>
910
- hashInvitationToken(randomBytes(32).toString("base64url"));
866
+ const deadTokenHash = () => hashInvitationToken(randomBytes(32).toString('base64url'));
911
867
 
912
868
  function addDays(date: Date, days: number) {
913
869
  const next = new Date(date);
@@ -919,18 +875,16 @@ function normalizeTarget(type: InvitationTargetType, value: string) {
919
875
  if (type === InvitationTargetType.EMAIL) {
920
876
  const email = value.trim().toLowerCase();
921
877
  if (!isEmail(email)) {
922
- throw new BadRequestException("Invitation email must be valid.");
878
+ throw new BadRequestException('Invitation email must be valid.');
923
879
  }
924
880
  return email;
925
881
  }
926
882
 
927
- const mobile = value.trim().replace(/[\s()-]/g, "");
928
- const normalized = mobile.startsWith("00") ? `+${mobile.slice(2)}` : mobile;
883
+ const mobile = value.trim().replace(/[\s()-]/g, '');
884
+ const normalized = mobile.startsWith('00') ? `+${mobile.slice(2)}` : mobile;
929
885
 
930
886
  if (!mobileTargetPattern.test(normalized)) {
931
- throw new BadRequestException(
932
- "Invitation mobile number must be E.164-style.",
933
- );
887
+ throw new BadRequestException('Invitation mobile number must be E.164-style.');
934
888
  }
935
889
 
936
890
  return normalized;
@@ -952,16 +906,13 @@ function toSafeUser(user: User) {
952
906
  }
953
907
 
954
908
  function invalidInvitationToken() {
955
- return new UnauthorizedException("Invalid invitation token.");
909
+ return new UnauthorizedException('Invalid invitation token.');
956
910
  }
957
911
 
958
912
  function invitationStateChanged() {
959
- return new ConflictException("Invitation is no longer pending.");
913
+ return new ConflictException('Invitation is no longer pending.');
960
914
  }
961
915
 
962
916
  function isPrismaUniqueError(error: unknown) {
963
- return (
964
- error instanceof Prisma.PrismaClientKnownRequestError &&
965
- error.code === "P2002"
966
- );
917
+ return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
967
918
  }
@@ -1,12 +1,11 @@
1
- import { ApiPropertyOptional } from "@nestjs/swagger";
2
- import { IsOptional, IsString, MaxLength, MinLength } from "class-validator";
3
- import { InvitationTokenDto } from "./invitation-token.dto";
1
+ import { ApiPropertyOptional } from '@nestjs/swagger';
2
+ import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
3
+ import { InvitationTokenDto } from './invitation-token.dto';
4
4
 
5
5
  export class AcceptInvitationDto extends InvitationTokenDto {
6
6
  @ApiPropertyOptional({
7
- description:
8
- "Required only when the invitation target is an email with no existing user.",
9
- example: "dev-password-123",
7
+ description: 'Required only when the invitation target is an email with no existing user.',
8
+ example: 'dev-password-123',
10
9
  minLength: 8,
11
10
  maxLength: 128,
12
11
  })
@@ -16,7 +15,7 @@ export class AcceptInvitationDto extends InvitationTokenDto {
16
15
  @MaxLength(128)
17
16
  password?: string;
18
17
 
19
- @ApiPropertyOptional({ example: "New Member", maxLength: 120 })
18
+ @ApiPropertyOptional({ example: 'New Member', maxLength: 120 })
20
19
  @IsOptional()
21
20
  @IsString()
22
21
  @MaxLength(120)