@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.
- package/LICENSE +144 -0
- package/bin/index.mjs +2 -0
- package/package.json +36 -0
- package/src/copy.mjs +45 -0
- package/src/log.mjs +23 -0
- package/src/main.mjs +221 -0
- package/src/run.mjs +52 -0
- package/src/substitute.mjs +40 -0
- package/template/.env.example +36 -0
- package/template/.github/workflows/ci.yml +36 -0
- package/template/.husky/pre-commit +1 -0
- package/template/README.md +146 -0
- package/template/_editorconfig +8 -0
- package/template/_gitignore +7 -0
- package/template/_nvmrc +1 -0
- package/template/_package.json +107 -0
- package/template/_prettierignore +5 -0
- package/template/_prettierrc +6 -0
- package/template/docs/API_REFERENCE.md +123 -0
- package/template/docs/GETTING_STARTED.md +65 -0
- package/template/docs/MODULE_COMPLETION_CHECKLIST.md +40 -0
- package/template/docs/OAUTH.md +46 -0
- package/template/docs/SAMPLE_MODULE.md +23 -0
- package/template/docs/api.http +269 -0
- package/template/eslint.config.mjs +51 -0
- package/template/nest-cli.json +8 -0
- package/template/prisma/migrations/20260530000000_init/migration.sql +248 -0
- package/template/prisma/schema.prisma +299 -0
- package/template/prisma/seed.ts +44 -0
- package/template/scripts/db-create.mjs +126 -0
- package/template/scripts/gen-module.mjs +217 -0
- package/template/scripts/seed-test-user-org.ts +264 -0
- package/template/scripts/setup-local.mjs +224 -0
- package/template/scripts/test-db.mjs +69 -0
- package/template/src/app.module.ts +58 -0
- package/template/src/common/decorators/.gitkeep +1 -0
- package/template/src/common/dto/error-response.dto.ts +17 -0
- package/template/src/common/dto/membership-response.dto.ts +51 -0
- package/template/src/common/dto/mutation-response.dto.ts +11 -0
- package/template/src/common/dto/role-summary.dto.ts +18 -0
- package/template/src/common/dto/user-summary.dto.ts +23 -0
- package/template/src/common/enums/.gitkeep +1 -0
- package/template/src/common/filters/.gitkeep +1 -0
- package/template/src/common/filters/http-exception.filter.ts +78 -0
- package/template/src/common/guards/.gitkeep +1 -0
- package/template/src/common/interceptors/.gitkeep +1 -0
- package/template/src/common/pipes/.gitkeep +1 -0
- package/template/src/common/swagger/api-error-responses.ts +54 -0
- package/template/src/common/types/.gitkeep +1 -0
- package/template/src/config/app.config.ts +7 -0
- package/template/src/config/auth.config.ts +33 -0
- package/template/src/config/database.config.ts +6 -0
- package/template/src/config/env.validation.ts +131 -0
- package/template/src/config/index.ts +5 -0
- package/template/src/config/rbac.config.ts +6 -0
- package/template/src/database/prisma/prisma-transaction.ts +22 -0
- package/template/src/database/prisma/prisma.module.ts +9 -0
- package/template/src/database/prisma/prisma.service.ts +16 -0
- package/template/src/main.ts +42 -0
- package/template/src/modules/access-control/access-control.module.ts +24 -0
- package/template/src/modules/access-control/application/route-registry.validator.ts +289 -0
- package/template/src/modules/access-control/application/services/ability.factory.ts +28 -0
- package/template/src/modules/access-control/application/services/access-control.service.ts +478 -0
- package/template/src/modules/access-control/application/services/permission.guard.ts +77 -0
- package/template/src/modules/access-control/application/services/rbac-cache.service.ts +148 -0
- package/template/src/modules/access-control/dto/access-control-response.dto.ts +79 -0
- package/template/src/modules/access-control/dto/create-role.dto.ts +18 -0
- package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +23 -0
- package/template/src/modules/access-control/dto/update-role.dto.ts +19 -0
- package/template/src/modules/access-control/presentation/access-control.controller.ts +157 -0
- package/template/src/modules/access-control/presentation/permissions.decorator.ts +8 -0
- package/template/src/modules/access-control/presentation/public.decorator.ts +7 -0
- package/template/src/modules/access-control/types/permission-key.ts +37 -0
- package/template/src/modules/access-control/types/rbac-context.ts +11 -0
- package/template/src/modules/access-control/types/route-permission-registry.ts +129 -0
- package/template/src/modules/audit/application/services/audit.service.ts +97 -0
- package/template/src/modules/audit/audit.module.ts +13 -0
- package/template/src/modules/audit/dto/audit-response.dto.ts +75 -0
- package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +75 -0
- package/template/src/modules/audit/presentation/audit.controller.ts +37 -0
- package/template/src/modules/auth/application/services/auth.service.ts +509 -0
- package/template/src/modules/auth/application/services/password.service.ts +15 -0
- package/template/src/modules/auth/application/services/token.service.ts +95 -0
- package/template/src/modules/auth/auth.module.ts +73 -0
- package/template/src/modules/auth/dto/auth-response.dto.ts +29 -0
- package/template/src/modules/auth/dto/login.dto.ts +15 -0
- package/template/src/modules/auth/dto/logout.dto.ts +3 -0
- package/template/src/modules/auth/dto/oauth-exchange.dto.ts +15 -0
- package/template/src/modules/auth/dto/refresh-token.dto.ts +14 -0
- package/template/src/modules/auth/dto/signup.dto.ts +27 -0
- package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +27 -0
- package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +56 -0
- package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +5 -0
- package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +43 -0
- package/template/src/modules/auth/presentation/auth.controller.ts +148 -0
- package/template/src/modules/auth/presentation/current-user.decorator.ts +11 -0
- package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +33 -0
- package/template/src/modules/auth/types/authenticated-user.ts +7 -0
- package/template/src/modules/auth/types/google-auth-profile.ts +6 -0
- package/template/src/modules/auth/types/jwt-payload.ts +5 -0
- package/template/src/modules/health/dto/health-response.dto.ts +9 -0
- package/template/src/modules/health/health.module.ts +7 -0
- package/template/src/modules/health/presentation/health.controller.ts +33 -0
- package/template/src/modules/invitations/application/services/invitations.service.ts +967 -0
- package/template/src/modules/invitations/dto/accept-invitation.dto.ts +24 -0
- package/template/src/modules/invitations/dto/create-invitation.dto.ts +100 -0
- package/template/src/modules/invitations/dto/invitation-response.dto.ts +108 -0
- package/template/src/modules/invitations/dto/invitation-token.dto.ts +15 -0
- package/template/src/modules/invitations/invitations.module.ts +12 -0
- package/template/src/modules/invitations/presentation/invitations.controller.ts +149 -0
- package/template/src/modules/memberships/application/services/memberships.service.ts +455 -0
- package/template/src/modules/memberships/dto/transfer-owner.dto.ts +11 -0
- package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +8 -0
- package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +8 -0
- package/template/src/modules/memberships/dto/update-membership-role.dto.ts +11 -0
- package/template/src/modules/memberships/dto/update-membership-status.dto.ts +9 -0
- package/template/src/modules/memberships/memberships.module.ts +12 -0
- package/template/src/modules/memberships/presentation/memberships.controller.ts +193 -0
- package/template/src/modules/organisations/application/services/organisations.service.ts +147 -0
- package/template/src/modules/organisations/dto/create-organisation.dto.ts +32 -0
- package/template/src/modules/organisations/dto/organisation-response.dto.ts +62 -0
- package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +24 -0
- package/template/src/modules/organisations/organisations.module.ts +12 -0
- package/template/src/modules/organisations/presentation/organisations.controller.ts +37 -0
- package/template/src/modules/organisations/types/default-organisation-data.ts +18 -0
- package/template/src/modules/platform-admin/.gitkeep +1 -0
- package/template/src/modules/request-context/application/services/request-context.service.ts +79 -0
- package/template/src/modules/request-context/presentation/org-scope.guard.ts +26 -0
- package/template/src/modules/request-context/presentation/request-context.interceptor.ts +26 -0
- package/template/src/modules/request-context/presentation/request-context.middleware.ts +31 -0
- package/template/src/modules/request-context/request-context.module.ts +25 -0
- package/template/src/modules/request-context/types/request-context.ts +29 -0
- package/template/src/modules/sample/application/services/sample.service.ts +67 -0
- package/template/src/modules/sample/dto/sample-echo.dto.ts +10 -0
- package/template/src/modules/sample/dto/sample-response.dto.ts +41 -0
- package/template/src/modules/sample/presentation/sample.controller.ts +63 -0
- package/template/src/modules/sample/sample.module.ts +11 -0
- package/template/src/modules/settings/application/services/settings.service.ts +139 -0
- package/template/src/modules/settings/dto/setting-response.dto.ts +27 -0
- package/template/src/modules/settings/dto/update-setting.dto.ts +16 -0
- package/template/src/modules/settings/presentation/settings.controller.ts +66 -0
- package/template/src/modules/settings/settings.module.ts +12 -0
- package/template/src/modules/settings/types/setting-definitions.ts +104 -0
- package/template/src/modules/users/.gitkeep +1 -0
- package/template/test/.gitkeep +1 -0
- package/template/test/jest-e2e.json +9 -0
- package/template/test/permission.guard.spec.ts +22 -0
- package/template/test/route-registry.validator.spec.ts +90 -0
- package/template/test/security.e2e-spec.ts +102 -0
- package/template/tsconfig.build.json +4 -0
- package/template/tsconfig.json +18 -0
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BadRequestException,
|
|
3
|
+
ConflictException,
|
|
4
|
+
GoneException,
|
|
5
|
+
Injectable,
|
|
6
|
+
NotFoundException,
|
|
7
|
+
UnauthorizedException,
|
|
8
|
+
} from "@nestjs/common";
|
|
9
|
+
import {
|
|
10
|
+
AuthProvider,
|
|
11
|
+
InvitationStatus,
|
|
12
|
+
InvitationTargetType,
|
|
13
|
+
MembershipStatus,
|
|
14
|
+
Prisma,
|
|
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";
|
|
28
|
+
|
|
29
|
+
const DEFAULT_INVITATION_EXPIRY_DAYS = 7;
|
|
30
|
+
const mobileTargetPattern = /^\+?[1-9]\d{6,14}$/;
|
|
31
|
+
|
|
32
|
+
const invitationDetailInclude = {
|
|
33
|
+
role: {
|
|
34
|
+
select: {
|
|
35
|
+
id: true,
|
|
36
|
+
name: true,
|
|
37
|
+
description: true,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
invitedByUser: {
|
|
41
|
+
select: {
|
|
42
|
+
id: true,
|
|
43
|
+
email: true,
|
|
44
|
+
mobile: true,
|
|
45
|
+
displayName: true,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
acceptedByUser: {
|
|
49
|
+
select: {
|
|
50
|
+
id: true,
|
|
51
|
+
email: true,
|
|
52
|
+
mobile: true,
|
|
53
|
+
displayName: true,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
} satisfies Prisma.InvitationInclude;
|
|
57
|
+
|
|
58
|
+
const membershipDetailInclude = {
|
|
59
|
+
user: {
|
|
60
|
+
select: {
|
|
61
|
+
id: true,
|
|
62
|
+
email: true,
|
|
63
|
+
mobile: true,
|
|
64
|
+
displayName: true,
|
|
65
|
+
isActive: true,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
role: {
|
|
69
|
+
select: {
|
|
70
|
+
id: true,
|
|
71
|
+
name: true,
|
|
72
|
+
description: true,
|
|
73
|
+
isSystemSeeded: true,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
} satisfies Prisma.MembershipInclude;
|
|
77
|
+
|
|
78
|
+
type PrismaClient = Prisma.TransactionClient | PrismaService;
|
|
79
|
+
type InvitationDetail = Prisma.InvitationGetPayload<{
|
|
80
|
+
include: typeof invitationDetailInclude;
|
|
81
|
+
}>;
|
|
82
|
+
|
|
83
|
+
@Injectable()
|
|
84
|
+
export class InvitationsService {
|
|
85
|
+
constructor(
|
|
86
|
+
private readonly membershipsService: MembershipsService,
|
|
87
|
+
private readonly passwordService: PasswordService,
|
|
88
|
+
private readonly prisma: PrismaService,
|
|
89
|
+
private readonly rbacCache: RbacCacheService,
|
|
90
|
+
private readonly requestContext: RequestContextService,
|
|
91
|
+
) {}
|
|
92
|
+
|
|
93
|
+
async createInvitation(
|
|
94
|
+
currentUser: AuthenticatedUser,
|
|
95
|
+
orgId: string,
|
|
96
|
+
dto: CreateInvitationDto,
|
|
97
|
+
) {
|
|
98
|
+
this.requestContext.assertOrgScope(orgId);
|
|
99
|
+
|
|
100
|
+
const normalizedTargetValue = normalizeTarget(
|
|
101
|
+
dto.targetType,
|
|
102
|
+
dto.targetValue,
|
|
103
|
+
);
|
|
104
|
+
const targetValue = dto.targetValue.trim();
|
|
105
|
+
const inviteToken = createInvitationToken();
|
|
106
|
+
const tokenHash = hashInvitationToken(inviteToken);
|
|
107
|
+
const expiresAt = addDays(
|
|
108
|
+
new Date(),
|
|
109
|
+
dto.expiresInDays ?? DEFAULT_INVITATION_EXPIRY_DAYS,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
let invitation: InvitationDetail;
|
|
113
|
+
try {
|
|
114
|
+
invitation = await this.prisma.$transaction(async (tx) => {
|
|
115
|
+
await this.membershipsService.assertActiveMembership(
|
|
116
|
+
currentUser.id,
|
|
117
|
+
orgId,
|
|
118
|
+
tx,
|
|
119
|
+
);
|
|
120
|
+
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
|
+
);
|
|
128
|
+
await this.assertTargetIsNotExistingMember(
|
|
129
|
+
tx,
|
|
130
|
+
orgId,
|
|
131
|
+
dto.targetType,
|
|
132
|
+
normalizedTargetValue,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const created = await tx.invitation.create({
|
|
136
|
+
data: {
|
|
137
|
+
orgId,
|
|
138
|
+
targetType: dto.targetType,
|
|
139
|
+
targetValue,
|
|
140
|
+
normalizedTargetValue,
|
|
141
|
+
roleId: dto.roleId,
|
|
142
|
+
tokenHash,
|
|
143
|
+
expiresAt,
|
|
144
|
+
invitedByUserId: currentUser.id,
|
|
145
|
+
},
|
|
146
|
+
include: invitationDetailInclude,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await this.writeAudit(tx, {
|
|
150
|
+
orgId,
|
|
151
|
+
actorUserId: currentUser.id,
|
|
152
|
+
action: "invitation.create",
|
|
153
|
+
targetId: created.id,
|
|
154
|
+
metadata: {
|
|
155
|
+
targetType: created.targetType,
|
|
156
|
+
normalizedTargetValue: created.normalizedTargetValue,
|
|
157
|
+
roleId: created.roleId,
|
|
158
|
+
expiresAt: created.expiresAt.toISOString(),
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return created;
|
|
163
|
+
});
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (isPrismaUniqueError(error)) {
|
|
166
|
+
throw new ConflictException(
|
|
167
|
+
"A pending invitation already exists for this target.",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
invitation: serializeInvitation(invitation),
|
|
176
|
+
inviteToken,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async listInvitations(currentUser: AuthenticatedUser, orgId: string) {
|
|
181
|
+
this.requestContext.assertOrgScope(orgId);
|
|
182
|
+
await this.membershipsService.assertActiveMembership(currentUser.id, orgId);
|
|
183
|
+
|
|
184
|
+
const invitations = await this.prisma.invitation.findMany({
|
|
185
|
+
where: { orgId },
|
|
186
|
+
include: invitationDetailInclude,
|
|
187
|
+
orderBy: { createdAt: "desc" },
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return invitations.map(serializeInvitation);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async revokeInvitation(
|
|
194
|
+
currentUser: AuthenticatedUser,
|
|
195
|
+
orgId: string,
|
|
196
|
+
invitationId: string,
|
|
197
|
+
) {
|
|
198
|
+
this.requestContext.assertOrgScope(orgId);
|
|
199
|
+
|
|
200
|
+
return this.prisma.$transaction(async (tx) => {
|
|
201
|
+
await this.membershipsService.assertActiveMembership(
|
|
202
|
+
currentUser.id,
|
|
203
|
+
orgId,
|
|
204
|
+
tx,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const invitation = await this.findInvitationInOrgForUpdate(
|
|
208
|
+
tx,
|
|
209
|
+
orgId,
|
|
210
|
+
invitationId,
|
|
211
|
+
);
|
|
212
|
+
this.assertPendingInvitation(invitation);
|
|
213
|
+
this.assertInvitationNotExpiredInTransaction(invitation);
|
|
214
|
+
|
|
215
|
+
const result = await tx.invitation.updateMany({
|
|
216
|
+
where: {
|
|
217
|
+
id: invitation.id,
|
|
218
|
+
status: InvitationStatus.PENDING,
|
|
219
|
+
},
|
|
220
|
+
data: {
|
|
221
|
+
status: InvitationStatus.REVOKED,
|
|
222
|
+
tokenHash: deadTokenHash(),
|
|
223
|
+
revokedAt: new Date(),
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
if (result.count !== 1) {
|
|
228
|
+
throw invitationStateChanged();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const revoked = await tx.invitation.findUniqueOrThrow({
|
|
232
|
+
where: { id: invitation.id },
|
|
233
|
+
include: invitationDetailInclude,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
await this.writeAudit(tx, {
|
|
237
|
+
orgId,
|
|
238
|
+
actorUserId: currentUser.id,
|
|
239
|
+
action: "invitation.revoke",
|
|
240
|
+
targetId: invitation.id,
|
|
241
|
+
metadata: {
|
|
242
|
+
targetType: invitation.targetType,
|
|
243
|
+
normalizedTargetValue: invitation.normalizedTargetValue,
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return serializeInvitation(revoked);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async resendInvitation(
|
|
252
|
+
currentUser: AuthenticatedUser,
|
|
253
|
+
orgId: string,
|
|
254
|
+
invitationId: string,
|
|
255
|
+
) {
|
|
256
|
+
this.requestContext.assertOrgScope(orgId);
|
|
257
|
+
|
|
258
|
+
const inviteToken = createInvitationToken();
|
|
259
|
+
const tokenHash = hashInvitationToken(inviteToken);
|
|
260
|
+
|
|
261
|
+
const invitation = await this.prisma.$transaction(async (tx) => {
|
|
262
|
+
await this.membershipsService.assertActiveMembership(
|
|
263
|
+
currentUser.id,
|
|
264
|
+
orgId,
|
|
265
|
+
tx,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const existing = await this.findInvitationInOrgForUpdate(
|
|
269
|
+
tx,
|
|
270
|
+
orgId,
|
|
271
|
+
invitationId,
|
|
272
|
+
);
|
|
273
|
+
this.assertResendableInvitation(existing);
|
|
274
|
+
await this.assertRoleBelongsToOrg(tx, orgId, existing.roleId);
|
|
275
|
+
await this.assertTargetIsNotExistingMember(
|
|
276
|
+
tx,
|
|
277
|
+
orgId,
|
|
278
|
+
existing.targetType,
|
|
279
|
+
existing.normalizedTargetValue,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const result = await tx.invitation.updateMany({
|
|
283
|
+
where: {
|
|
284
|
+
id: existing.id,
|
|
285
|
+
status: {
|
|
286
|
+
in: [InvitationStatus.PENDING, InvitationStatus.EXPIRED],
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
data: {
|
|
290
|
+
status: InvitationStatus.PENDING,
|
|
291
|
+
tokenHash,
|
|
292
|
+
expiresAt: addDays(new Date(), DEFAULT_INVITATION_EXPIRY_DAYS),
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
if (result.count !== 1) {
|
|
297
|
+
throw invitationStateChanged();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const updated = await tx.invitation.findUniqueOrThrow({
|
|
301
|
+
where: { id: existing.id },
|
|
302
|
+
include: invitationDetailInclude,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await this.writeAudit(tx, {
|
|
306
|
+
orgId,
|
|
307
|
+
actorUserId: currentUser.id,
|
|
308
|
+
action: "invitation.resend",
|
|
309
|
+
targetId: existing.id,
|
|
310
|
+
metadata: {
|
|
311
|
+
targetType: existing.targetType,
|
|
312
|
+
normalizedTargetValue: existing.normalizedTargetValue,
|
|
313
|
+
expiresAt: updated.expiresAt.toISOString(),
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return updated;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
invitation: serializeInvitation(invitation),
|
|
322
|
+
inviteToken,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async acceptInvitation(dto: AcceptInvitationDto) {
|
|
327
|
+
const tokenHash = hashInvitationToken(dto.token);
|
|
328
|
+
const invitation = await this.findInvitationByToken(tokenHash);
|
|
329
|
+
await this.assertInvitationNotExpired(invitation);
|
|
330
|
+
|
|
331
|
+
const result = await this.prisma.$transaction(async (tx) => {
|
|
332
|
+
const pendingInvitation = await this.findInvitationByTokenForUpdate(
|
|
333
|
+
tx,
|
|
334
|
+
tokenHash,
|
|
335
|
+
);
|
|
336
|
+
this.assertPendingInvitation(pendingInvitation);
|
|
337
|
+
this.assertInvitationNotExpiredInTransaction(pendingInvitation);
|
|
338
|
+
await this.assertRoleBelongsToOrg(
|
|
339
|
+
tx,
|
|
340
|
+
pendingInvitation.orgId,
|
|
341
|
+
pendingInvitation.roleId,
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const user = await this.resolveTargetUser(tx, pendingInvitation, dto);
|
|
345
|
+
await this.assertMembershipCanBeCreated(
|
|
346
|
+
tx,
|
|
347
|
+
pendingInvitation.orgId,
|
|
348
|
+
user.id,
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const membership = await tx.membership.create({
|
|
352
|
+
data: {
|
|
353
|
+
userId: user.id,
|
|
354
|
+
orgId: pendingInvitation.orgId,
|
|
355
|
+
roleId: pendingInvitation.roleId,
|
|
356
|
+
status: MembershipStatus.ACTIVE,
|
|
357
|
+
isOwner: false,
|
|
358
|
+
isBillingContact: false,
|
|
359
|
+
},
|
|
360
|
+
include: membershipDetailInclude,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const result = await tx.invitation.updateMany({
|
|
364
|
+
where: {
|
|
365
|
+
id: pendingInvitation.id,
|
|
366
|
+
status: InvitationStatus.PENDING,
|
|
367
|
+
},
|
|
368
|
+
data: {
|
|
369
|
+
status: InvitationStatus.ACCEPTED,
|
|
370
|
+
tokenHash: deadTokenHash(),
|
|
371
|
+
acceptedByUserId: user.id,
|
|
372
|
+
acceptedAt: new Date(),
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
if (result.count !== 1) {
|
|
377
|
+
throw invitationStateChanged();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const accepted = await tx.invitation.findUniqueOrThrow({
|
|
381
|
+
where: { id: pendingInvitation.id },
|
|
382
|
+
include: invitationDetailInclude,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
await this.writeAudit(tx, {
|
|
386
|
+
orgId: pendingInvitation.orgId,
|
|
387
|
+
actorUserId: user.id,
|
|
388
|
+
action: "invitation.accept",
|
|
389
|
+
targetId: pendingInvitation.id,
|
|
390
|
+
metadata: {
|
|
391
|
+
targetType: pendingInvitation.targetType,
|
|
392
|
+
normalizedTargetValue: pendingInvitation.normalizedTargetValue,
|
|
393
|
+
membershipId: membership.id,
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
await this.writeAudit(tx, {
|
|
398
|
+
orgId: pendingInvitation.orgId,
|
|
399
|
+
actorUserId: user.id,
|
|
400
|
+
action: "membership.created",
|
|
401
|
+
targetType: "Membership",
|
|
402
|
+
targetId: membership.id,
|
|
403
|
+
metadata: {
|
|
404
|
+
invitedByUserId: pendingInvitation.invitedByUserId,
|
|
405
|
+
invitationId: pendingInvitation.id,
|
|
406
|
+
roleId: membership.roleId,
|
|
407
|
+
userId: user.id,
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
invitation: serializeInvitation(accepted),
|
|
413
|
+
membership,
|
|
414
|
+
user: toSafeUser(user),
|
|
415
|
+
rbacInvalidation: {
|
|
416
|
+
userId: user.id,
|
|
417
|
+
orgId: pendingInvitation.orgId,
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
this.rbacCache.invalidate(
|
|
423
|
+
result.rbacInvalidation.userId,
|
|
424
|
+
result.rbacInvalidation.orgId,
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
const { rbacInvalidation: _rbacInvalidation, ...response } = result;
|
|
428
|
+
return response;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async declineInvitation(dto: InvitationTokenDto) {
|
|
432
|
+
const tokenHash = hashInvitationToken(dto.token);
|
|
433
|
+
const invitation = await this.findInvitationByToken(tokenHash);
|
|
434
|
+
await this.assertInvitationNotExpired(invitation);
|
|
435
|
+
|
|
436
|
+
return this.prisma.$transaction(async (tx) => {
|
|
437
|
+
const pendingInvitation = await this.findInvitationByTokenForUpdate(
|
|
438
|
+
tx,
|
|
439
|
+
tokenHash,
|
|
440
|
+
);
|
|
441
|
+
this.assertPendingInvitation(pendingInvitation);
|
|
442
|
+
this.assertInvitationNotExpiredInTransaction(pendingInvitation);
|
|
443
|
+
|
|
444
|
+
const result = await tx.invitation.updateMany({
|
|
445
|
+
where: {
|
|
446
|
+
id: pendingInvitation.id,
|
|
447
|
+
status: InvitationStatus.PENDING,
|
|
448
|
+
},
|
|
449
|
+
data: {
|
|
450
|
+
status: InvitationStatus.DECLINED,
|
|
451
|
+
tokenHash: deadTokenHash(),
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
if (result.count !== 1) {
|
|
456
|
+
throw invitationStateChanged();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const declined = await tx.invitation.findUniqueOrThrow({
|
|
460
|
+
where: { id: pendingInvitation.id },
|
|
461
|
+
include: invitationDetailInclude,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
await this.writeAudit(tx, {
|
|
465
|
+
orgId: pendingInvitation.orgId,
|
|
466
|
+
action: "invitation.decline",
|
|
467
|
+
targetId: pendingInvitation.id,
|
|
468
|
+
metadata: {
|
|
469
|
+
targetType: pendingInvitation.targetType,
|
|
470
|
+
normalizedTargetValue: pendingInvitation.normalizedTargetValue,
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
return serializeInvitation(declined);
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private async findInvitationByToken(tokenHash: string) {
|
|
479
|
+
const invitation = await this.prisma.invitation.findUnique({
|
|
480
|
+
where: { tokenHash },
|
|
481
|
+
include: invitationDetailInclude,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
if (!invitation) {
|
|
485
|
+
throw invalidInvitationToken();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
this.assertPendingInvitation(invitation);
|
|
489
|
+
return invitation;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private async findInvitationByTokenForUpdate(
|
|
493
|
+
tx: Prisma.TransactionClient,
|
|
494
|
+
tokenHash: string,
|
|
495
|
+
) {
|
|
496
|
+
const rows = await tx.$queryRaw<Array<{ id: string }>>`
|
|
497
|
+
SELECT "id"
|
|
498
|
+
FROM "Invitation"
|
|
499
|
+
WHERE "tokenHash" = ${tokenHash}
|
|
500
|
+
FOR UPDATE
|
|
501
|
+
`;
|
|
502
|
+
|
|
503
|
+
if (rows.length === 0) {
|
|
504
|
+
throw invalidInvitationToken();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const invitation = await tx.invitation.findUnique({
|
|
508
|
+
where: { id: rows[0].id },
|
|
509
|
+
include: invitationDetailInclude,
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
if (!invitation) {
|
|
513
|
+
throw invalidInvitationToken();
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return invitation;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private async findInvitationInOrg(
|
|
520
|
+
client: PrismaClient,
|
|
521
|
+
orgId: string,
|
|
522
|
+
invitationId: string,
|
|
523
|
+
) {
|
|
524
|
+
const invitation = await client.invitation.findFirst({
|
|
525
|
+
where: {
|
|
526
|
+
id: invitationId,
|
|
527
|
+
orgId,
|
|
528
|
+
},
|
|
529
|
+
include: invitationDetailInclude,
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
if (!invitation) {
|
|
533
|
+
throw new NotFoundException(
|
|
534
|
+
"Invitation was not found in this organisation.",
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return invitation;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private async findInvitationInOrgForUpdate(
|
|
542
|
+
tx: Prisma.TransactionClient,
|
|
543
|
+
orgId: string,
|
|
544
|
+
invitationId: string,
|
|
545
|
+
) {
|
|
546
|
+
const rows = await tx.$queryRaw<Array<{ id: string }>>`
|
|
547
|
+
SELECT "id"
|
|
548
|
+
FROM "Invitation"
|
|
549
|
+
WHERE "id" = ${invitationId}
|
|
550
|
+
AND "orgId" = ${orgId}
|
|
551
|
+
FOR UPDATE
|
|
552
|
+
`;
|
|
553
|
+
|
|
554
|
+
if (rows.length === 0) {
|
|
555
|
+
throw new NotFoundException(
|
|
556
|
+
"Invitation was not found in this organisation.",
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return tx.invitation.findUniqueOrThrow({
|
|
561
|
+
where: { id: rows[0].id },
|
|
562
|
+
include: invitationDetailInclude,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private async lockOrganisation(tx: Prisma.TransactionClient, orgId: string) {
|
|
567
|
+
await tx.$queryRaw<Array<{ id: string }>>`
|
|
568
|
+
SELECT "id"
|
|
569
|
+
FROM "Organisation"
|
|
570
|
+
WHERE "id" = ${orgId}
|
|
571
|
+
FOR UPDATE
|
|
572
|
+
`;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private async assertRoleBelongsToOrg(
|
|
576
|
+
client: PrismaClient,
|
|
577
|
+
orgId: string,
|
|
578
|
+
roleId: string,
|
|
579
|
+
) {
|
|
580
|
+
const role = await client.role.findUnique({
|
|
581
|
+
where: {
|
|
582
|
+
id_orgId: {
|
|
583
|
+
id: roleId,
|
|
584
|
+
orgId,
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
select: { id: true },
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
if (!role) {
|
|
591
|
+
throw new NotFoundException("Role was not found in this organisation.");
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private async assertNoPendingInvitation(
|
|
596
|
+
client: PrismaClient,
|
|
597
|
+
orgId: string,
|
|
598
|
+
targetType: InvitationTargetType,
|
|
599
|
+
normalizedTargetValue: string,
|
|
600
|
+
) {
|
|
601
|
+
const existing = await client.invitation.findFirst({
|
|
602
|
+
where: {
|
|
603
|
+
orgId,
|
|
604
|
+
targetType,
|
|
605
|
+
normalizedTargetValue,
|
|
606
|
+
status: InvitationStatus.PENDING,
|
|
607
|
+
},
|
|
608
|
+
select: { id: true },
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
if (existing) {
|
|
612
|
+
throw new ConflictException(
|
|
613
|
+
"A pending invitation already exists for this target.",
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
private assertPendingInvitation(
|
|
619
|
+
invitation: Pick<InvitationDetail, "status">,
|
|
620
|
+
) {
|
|
621
|
+
if (invitation.status === InvitationStatus.EXPIRED) {
|
|
622
|
+
throw new GoneException("Invitation has expired.");
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (invitation.status !== InvitationStatus.PENDING) {
|
|
626
|
+
throw new ConflictException("Invitation is no longer pending.");
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private assertResendableInvitation(
|
|
631
|
+
invitation: Pick<InvitationDetail, "status">,
|
|
632
|
+
) {
|
|
633
|
+
if (
|
|
634
|
+
invitation.status !== InvitationStatus.PENDING &&
|
|
635
|
+
invitation.status !== InvitationStatus.EXPIRED
|
|
636
|
+
) {
|
|
637
|
+
throw new ConflictException(
|
|
638
|
+
"Invitation cannot be resent after it has been completed.",
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private async assertInvitationNotExpired(invitation: InvitationDetail) {
|
|
644
|
+
if (invitation.expiresAt > new Date()) {
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
await this.markInvitationExpired(invitation);
|
|
649
|
+
throw new GoneException("Invitation has expired.");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private assertInvitationNotExpiredInTransaction(
|
|
653
|
+
invitation: InvitationDetail,
|
|
654
|
+
) {
|
|
655
|
+
if (invitation.status === InvitationStatus.EXPIRED) {
|
|
656
|
+
throw new GoneException("Invitation has expired.");
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (invitation.expiresAt > new Date()) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
throw new GoneException("Invitation has expired.");
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private async markInvitationExpired(invitation: InvitationDetail) {
|
|
667
|
+
if (invitation.status !== InvitationStatus.PENDING) {
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
await this.prisma.$transaction((tx) =>
|
|
672
|
+
this.markInvitationExpiredInTransaction(tx, invitation),
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
private async markInvitationExpiredInTransaction(
|
|
677
|
+
tx: Prisma.TransactionClient,
|
|
678
|
+
invitation: InvitationDetail,
|
|
679
|
+
) {
|
|
680
|
+
const result = await tx.invitation.updateMany({
|
|
681
|
+
where: {
|
|
682
|
+
id: invitation.id,
|
|
683
|
+
status: InvitationStatus.PENDING,
|
|
684
|
+
},
|
|
685
|
+
data: {
|
|
686
|
+
status: InvitationStatus.EXPIRED,
|
|
687
|
+
tokenHash: deadTokenHash(),
|
|
688
|
+
},
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
if (result.count === 0) {
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
await this.writeAudit(tx, {
|
|
696
|
+
orgId: invitation.orgId,
|
|
697
|
+
action: "invitation.expired",
|
|
698
|
+
targetId: invitation.id,
|
|
699
|
+
metadata: {
|
|
700
|
+
targetType: invitation.targetType,
|
|
701
|
+
normalizedTargetValue: invitation.normalizedTargetValue,
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
private async resolveTargetUser(
|
|
707
|
+
tx: Prisma.TransactionClient,
|
|
708
|
+
invitation: InvitationDetail,
|
|
709
|
+
dto: AcceptInvitationDto,
|
|
710
|
+
) {
|
|
711
|
+
if (invitation.targetType === InvitationTargetType.EMAIL) {
|
|
712
|
+
return this.resolveEmailUser(tx, invitation.normalizedTargetValue, dto);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return this.resolveMobileUser(tx, invitation.normalizedTargetValue, dto);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
private async resolveEmailUser(
|
|
719
|
+
tx: Prisma.TransactionClient,
|
|
720
|
+
email: string,
|
|
721
|
+
dto: AcceptInvitationDto,
|
|
722
|
+
) {
|
|
723
|
+
const existing = await tx.user.findUnique({
|
|
724
|
+
where: { email },
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
if (existing) {
|
|
728
|
+
if (!existing.isActive) {
|
|
729
|
+
throw new ConflictException("The invited user is inactive.");
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return existing;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (!dto.password) {
|
|
736
|
+
throw new BadRequestException(
|
|
737
|
+
"Password is required when accepting an email invitation as a new user.",
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const passwordHash = await this.passwordService.hash(dto.password);
|
|
742
|
+
const user = await tx.user.create({
|
|
743
|
+
data: {
|
|
744
|
+
email,
|
|
745
|
+
passwordHash,
|
|
746
|
+
displayName: dto.displayName?.trim() || null,
|
|
747
|
+
},
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
await tx.authIdentity.create({
|
|
751
|
+
data: {
|
|
752
|
+
userId: user.id,
|
|
753
|
+
provider: AuthProvider.EMAIL_PASSWORD,
|
|
754
|
+
providerUserId: email,
|
|
755
|
+
email,
|
|
756
|
+
},
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
return user;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
private async resolveMobileUser(
|
|
763
|
+
tx: Prisma.TransactionClient,
|
|
764
|
+
mobile: string,
|
|
765
|
+
dto: AcceptInvitationDto,
|
|
766
|
+
) {
|
|
767
|
+
const existing = await tx.user.findUnique({
|
|
768
|
+
where: { mobile },
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
if (existing) {
|
|
772
|
+
if (!existing.isActive) {
|
|
773
|
+
throw new ConflictException("The invited user is inactive.");
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
return existing;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const user = await tx.user.create({
|
|
780
|
+
data: {
|
|
781
|
+
mobile,
|
|
782
|
+
displayName: dto.displayName?.trim() || null,
|
|
783
|
+
},
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
await tx.authIdentity.create({
|
|
787
|
+
data: {
|
|
788
|
+
userId: user.id,
|
|
789
|
+
provider: AuthProvider.MOBILE_OTP,
|
|
790
|
+
providerUserId: mobile,
|
|
791
|
+
mobile,
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
return user;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
private async assertMembershipCanBeCreated(
|
|
799
|
+
tx: Prisma.TransactionClient,
|
|
800
|
+
orgId: string,
|
|
801
|
+
userId: string,
|
|
802
|
+
) {
|
|
803
|
+
const existing = await tx.membership.findUnique({
|
|
804
|
+
where: {
|
|
805
|
+
userId_orgId: {
|
|
806
|
+
userId,
|
|
807
|
+
orgId,
|
|
808
|
+
},
|
|
809
|
+
},
|
|
810
|
+
select: {
|
|
811
|
+
id: true,
|
|
812
|
+
status: true,
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
if (!existing) {
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (existing.status === MembershipStatus.REVOKED) {
|
|
821
|
+
throw new ConflictException(
|
|
822
|
+
"Revoked memberships cannot be reused through an invitation.",
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
throw new ConflictException(
|
|
827
|
+
"User already has a membership in this organisation.",
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
private async assertTargetIsNotExistingMember(
|
|
832
|
+
tx: Prisma.TransactionClient,
|
|
833
|
+
orgId: string,
|
|
834
|
+
targetType: InvitationTargetType,
|
|
835
|
+
normalizedTargetValue: string,
|
|
836
|
+
) {
|
|
837
|
+
const user =
|
|
838
|
+
targetType === InvitationTargetType.EMAIL
|
|
839
|
+
? await tx.user.findUnique({
|
|
840
|
+
where: { email: normalizedTargetValue },
|
|
841
|
+
select: { id: true },
|
|
842
|
+
})
|
|
843
|
+
: await tx.user.findUnique({
|
|
844
|
+
where: { mobile: normalizedTargetValue },
|
|
845
|
+
select: { id: true },
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
if (!user) {
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const membership = await tx.membership.findUnique({
|
|
853
|
+
where: {
|
|
854
|
+
userId_orgId: {
|
|
855
|
+
userId: user.id,
|
|
856
|
+
orgId,
|
|
857
|
+
},
|
|
858
|
+
},
|
|
859
|
+
select: { status: true },
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
if (!membership) {
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (membership.status === MembershipStatus.REVOKED) {
|
|
867
|
+
throw new ConflictException(
|
|
868
|
+
"A revoked membership already exists for this invitation target.",
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
throw new ConflictException(
|
|
873
|
+
"Invitation target already has a membership in this organisation.",
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
private async writeAudit(
|
|
878
|
+
client: PrismaClient,
|
|
879
|
+
data: {
|
|
880
|
+
orgId: string;
|
|
881
|
+
actorUserId?: string;
|
|
882
|
+
action: string;
|
|
883
|
+
targetType?: string;
|
|
884
|
+
targetId: string;
|
|
885
|
+
metadata?: Prisma.InputJsonObject;
|
|
886
|
+
},
|
|
887
|
+
) {
|
|
888
|
+
await client.auditLog.create({
|
|
889
|
+
data: {
|
|
890
|
+
orgId: data.orgId,
|
|
891
|
+
actorUserId: data.actorUserId,
|
|
892
|
+
action: data.action,
|
|
893
|
+
targetType: data.targetType ?? "Invitation",
|
|
894
|
+
targetId: data.targetId,
|
|
895
|
+
metadata: data.metadata,
|
|
896
|
+
},
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function createInvitationToken() {
|
|
902
|
+
return randomBytes(32).toString("base64url");
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function hashInvitationToken(token: string) {
|
|
906
|
+
return createHash("sha256").update(token).digest("hex");
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const deadTokenHash = () =>
|
|
910
|
+
hashInvitationToken(randomBytes(32).toString("base64url"));
|
|
911
|
+
|
|
912
|
+
function addDays(date: Date, days: number) {
|
|
913
|
+
const next = new Date(date);
|
|
914
|
+
next.setUTCDate(next.getUTCDate() + days);
|
|
915
|
+
return next;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function normalizeTarget(type: InvitationTargetType, value: string) {
|
|
919
|
+
if (type === InvitationTargetType.EMAIL) {
|
|
920
|
+
const email = value.trim().toLowerCase();
|
|
921
|
+
if (!isEmail(email)) {
|
|
922
|
+
throw new BadRequestException("Invitation email must be valid.");
|
|
923
|
+
}
|
|
924
|
+
return email;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const mobile = value.trim().replace(/[\s()-]/g, "");
|
|
928
|
+
const normalized = mobile.startsWith("00") ? `+${mobile.slice(2)}` : mobile;
|
|
929
|
+
|
|
930
|
+
if (!mobileTargetPattern.test(normalized)) {
|
|
931
|
+
throw new BadRequestException(
|
|
932
|
+
"Invitation mobile number must be E.164-style.",
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
return normalized;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function serializeInvitation(invitation: InvitationDetail) {
|
|
940
|
+
const { tokenHash: _tokenHash, ...safeInvitation } = invitation;
|
|
941
|
+
return safeInvitation;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function toSafeUser(user: User) {
|
|
945
|
+
return {
|
|
946
|
+
id: user.id,
|
|
947
|
+
email: user.email,
|
|
948
|
+
mobile: user.mobile,
|
|
949
|
+
displayName: user.displayName,
|
|
950
|
+
isActive: user.isActive,
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function invalidInvitationToken() {
|
|
955
|
+
return new UnauthorizedException("Invalid invitation token.");
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function invitationStateChanged() {
|
|
959
|
+
return new ConflictException("Invitation is no longer pending.");
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function isPrismaUniqueError(error: unknown) {
|
|
963
|
+
return (
|
|
964
|
+
error instanceof Prisma.PrismaClientKnownRequestError &&
|
|
965
|
+
error.code === "P2002"
|
|
966
|
+
);
|
|
967
|
+
}
|