@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,299 @@
|
|
|
1
|
+
generator client {
|
|
2
|
+
provider = "prisma-client-js"
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
datasource db {
|
|
6
|
+
provider = "postgresql"
|
|
7
|
+
url = env("DATABASE_URL")
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
enum OrganisationStatus {
|
|
11
|
+
ACTIVE
|
|
12
|
+
SUSPENDED
|
|
13
|
+
DELETED
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
enum MembershipStatus {
|
|
17
|
+
ACTIVE
|
|
18
|
+
SUSPENDED
|
|
19
|
+
REVOKED
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
enum AuthProvider {
|
|
23
|
+
EMAIL_PASSWORD
|
|
24
|
+
GOOGLE
|
|
25
|
+
FACEBOOK
|
|
26
|
+
MOBILE_OTP
|
|
27
|
+
MAGIC_LINK
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
enum InvitationTargetType {
|
|
31
|
+
EMAIL
|
|
32
|
+
MOBILE
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
enum InvitationStatus {
|
|
36
|
+
PENDING
|
|
37
|
+
ACCEPTED
|
|
38
|
+
DECLINED
|
|
39
|
+
EXPIRED
|
|
40
|
+
REVOKED
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Relation delete policy:
|
|
44
|
+
// Required foreign keys use Prisma's default Restrict behavior so referenced
|
|
45
|
+
// records cannot be deleted while dependent rows still exist. RolePermission.role
|
|
46
|
+
// is the intentional exception: deleting a role cascades its permission join rows,
|
|
47
|
+
// while RolePermission.permission remains restricted because permissions are
|
|
48
|
+
// immutable seed data.
|
|
49
|
+
|
|
50
|
+
model User {
|
|
51
|
+
id String @id @default(uuid())
|
|
52
|
+
email String? @unique
|
|
53
|
+
mobile String? @unique
|
|
54
|
+
passwordHash String?
|
|
55
|
+
displayName String?
|
|
56
|
+
isActive Boolean @default(true)
|
|
57
|
+
|
|
58
|
+
memberships Membership[]
|
|
59
|
+
identities AuthIdentity[]
|
|
60
|
+
refreshTokens RefreshToken[]
|
|
61
|
+
authExchangeCodes AuthExchangeCode[]
|
|
62
|
+
passwordResetTokens PasswordResetToken[]
|
|
63
|
+
auditLogs AuditLog[]
|
|
64
|
+
sentInvitations Invitation[] @relation("InvitedByUser")
|
|
65
|
+
acceptedInvitations Invitation[] @relation("AcceptedByUser")
|
|
66
|
+
|
|
67
|
+
createdAt DateTime @default(now())
|
|
68
|
+
updatedAt DateTime @updatedAt
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
model AuthIdentity {
|
|
72
|
+
id String @id @default(uuid())
|
|
73
|
+
|
|
74
|
+
userId String
|
|
75
|
+
provider AuthProvider
|
|
76
|
+
providerUserId String
|
|
77
|
+
email String?
|
|
78
|
+
mobile String?
|
|
79
|
+
|
|
80
|
+
user User @relation(fields: [userId], references: [id])
|
|
81
|
+
|
|
82
|
+
createdAt DateTime @default(now())
|
|
83
|
+
updatedAt DateTime @updatedAt
|
|
84
|
+
|
|
85
|
+
@@unique([provider, providerUserId])
|
|
86
|
+
@@index([userId])
|
|
87
|
+
@@index([provider])
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
model RefreshToken {
|
|
91
|
+
id String @id @default(uuid())
|
|
92
|
+
|
|
93
|
+
userId String
|
|
94
|
+
tokenHash String @unique
|
|
95
|
+
expiresAt DateTime
|
|
96
|
+
revokedAt DateTime?
|
|
97
|
+
replacedByTokenId String?
|
|
98
|
+
|
|
99
|
+
user User @relation(fields: [userId], references: [id])
|
|
100
|
+
|
|
101
|
+
createdAt DateTime @default(now())
|
|
102
|
+
|
|
103
|
+
@@index([userId])
|
|
104
|
+
@@index([expiresAt])
|
|
105
|
+
@@index([revokedAt])
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
model AuthExchangeCode {
|
|
109
|
+
id String @id @default(uuid())
|
|
110
|
+
|
|
111
|
+
userId String
|
|
112
|
+
tokenHash String @unique
|
|
113
|
+
expiresAt DateTime
|
|
114
|
+
usedAt DateTime?
|
|
115
|
+
|
|
116
|
+
user User @relation(fields: [userId], references: [id])
|
|
117
|
+
|
|
118
|
+
createdAt DateTime @default(now())
|
|
119
|
+
|
|
120
|
+
@@index([userId])
|
|
121
|
+
@@index([expiresAt])
|
|
122
|
+
@@index([usedAt])
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
model PasswordResetToken {
|
|
126
|
+
id String @id @default(uuid())
|
|
127
|
+
|
|
128
|
+
userId String
|
|
129
|
+
tokenHash String @unique
|
|
130
|
+
expiresAt DateTime
|
|
131
|
+
usedAt DateTime?
|
|
132
|
+
|
|
133
|
+
user User @relation(fields: [userId], references: [id])
|
|
134
|
+
|
|
135
|
+
createdAt DateTime @default(now())
|
|
136
|
+
|
|
137
|
+
@@index([userId])
|
|
138
|
+
@@index([expiresAt])
|
|
139
|
+
@@index([usedAt])
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
model Organisation {
|
|
143
|
+
id String @id @default(uuid())
|
|
144
|
+
name String
|
|
145
|
+
slug String @unique
|
|
146
|
+
status OrganisationStatus @default(ACTIVE)
|
|
147
|
+
|
|
148
|
+
memberships Membership[]
|
|
149
|
+
roles Role[]
|
|
150
|
+
settings OrganisationSetting[]
|
|
151
|
+
invitations Invitation[]
|
|
152
|
+
|
|
153
|
+
createdAt DateTime @default(now())
|
|
154
|
+
updatedAt DateTime @updatedAt
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
model OrganisationSetting {
|
|
158
|
+
id String @id @default(uuid())
|
|
159
|
+
|
|
160
|
+
orgId String
|
|
161
|
+
key String
|
|
162
|
+
value Json
|
|
163
|
+
|
|
164
|
+
organisation Organisation @relation(fields: [orgId], references: [id])
|
|
165
|
+
|
|
166
|
+
createdAt DateTime @default(now())
|
|
167
|
+
updatedAt DateTime @updatedAt
|
|
168
|
+
|
|
169
|
+
@@unique([orgId, key])
|
|
170
|
+
@@index([orgId])
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
model Membership {
|
|
174
|
+
id String @id @default(uuid())
|
|
175
|
+
|
|
176
|
+
userId String
|
|
177
|
+
orgId String
|
|
178
|
+
roleId String
|
|
179
|
+
|
|
180
|
+
status MembershipStatus @default(ACTIVE)
|
|
181
|
+
isOwner Boolean @default(false)
|
|
182
|
+
isBillingContact Boolean @default(false)
|
|
183
|
+
|
|
184
|
+
user User @relation(fields: [userId], references: [id])
|
|
185
|
+
organisation Organisation @relation(fields: [orgId], references: [id])
|
|
186
|
+
role Role @relation(fields: [roleId, orgId], references: [id, orgId])
|
|
187
|
+
|
|
188
|
+
createdAt DateTime @default(now())
|
|
189
|
+
updatedAt DateTime @updatedAt
|
|
190
|
+
|
|
191
|
+
@@unique([userId, orgId])
|
|
192
|
+
@@index([orgId])
|
|
193
|
+
@@index([userId])
|
|
194
|
+
@@index([status])
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
model Role {
|
|
198
|
+
id String @id @default(uuid())
|
|
199
|
+
|
|
200
|
+
orgId String
|
|
201
|
+
name String
|
|
202
|
+
description String?
|
|
203
|
+
isSystemSeeded Boolean @default(false)
|
|
204
|
+
|
|
205
|
+
organisation Organisation @relation(fields: [orgId], references: [id])
|
|
206
|
+
memberships Membership[]
|
|
207
|
+
permissions RolePermission[]
|
|
208
|
+
invitations Invitation[]
|
|
209
|
+
|
|
210
|
+
createdAt DateTime @default(now())
|
|
211
|
+
updatedAt DateTime @updatedAt
|
|
212
|
+
|
|
213
|
+
@@unique([orgId, name])
|
|
214
|
+
// Required by Membership.role so the database enforces same-org role assignment.
|
|
215
|
+
@@unique([id, orgId])
|
|
216
|
+
@@index([orgId])
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
model Invitation {
|
|
220
|
+
id String @id @default(uuid())
|
|
221
|
+
|
|
222
|
+
orgId String
|
|
223
|
+
targetType InvitationTargetType
|
|
224
|
+
targetValue String
|
|
225
|
+
normalizedTargetValue String
|
|
226
|
+
|
|
227
|
+
roleId String
|
|
228
|
+
tokenHash String @unique
|
|
229
|
+
status InvitationStatus @default(PENDING)
|
|
230
|
+
expiresAt DateTime
|
|
231
|
+
|
|
232
|
+
invitedByUserId String
|
|
233
|
+
acceptedByUserId String?
|
|
234
|
+
|
|
235
|
+
organisation Organisation @relation(fields: [orgId], references: [id])
|
|
236
|
+
role Role @relation(fields: [roleId, orgId], references: [id, orgId])
|
|
237
|
+
invitedByUser User @relation("InvitedByUser", fields: [invitedByUserId], references: [id])
|
|
238
|
+
acceptedByUser User? @relation("AcceptedByUser", fields: [acceptedByUserId], references: [id])
|
|
239
|
+
|
|
240
|
+
createdAt DateTime @default(now())
|
|
241
|
+
acceptedAt DateTime?
|
|
242
|
+
revokedAt DateTime?
|
|
243
|
+
|
|
244
|
+
@@index([orgId])
|
|
245
|
+
@@index([targetType, normalizedTargetValue])
|
|
246
|
+
@@index([status])
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
model Permission {
|
|
250
|
+
id String @id @default(uuid())
|
|
251
|
+
|
|
252
|
+
key String @unique
|
|
253
|
+
module String
|
|
254
|
+
action String
|
|
255
|
+
subject String
|
|
256
|
+
description String?
|
|
257
|
+
|
|
258
|
+
roles RolePermission[]
|
|
259
|
+
|
|
260
|
+
createdAt DateTime @default(now())
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
model RolePermission {
|
|
264
|
+
roleId String
|
|
265
|
+
permissionId String
|
|
266
|
+
|
|
267
|
+
// Role deletion clears join rows; Permission deletion stays restricted for immutable seed data.
|
|
268
|
+
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
|
269
|
+
permission Permission @relation(fields: [permissionId], references: [id])
|
|
270
|
+
|
|
271
|
+
createdAt DateTime @default(now())
|
|
272
|
+
|
|
273
|
+
@@id([roleId, permissionId])
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
model AuditLog {
|
|
277
|
+
id String @id @default(uuid())
|
|
278
|
+
|
|
279
|
+
orgId String?
|
|
280
|
+
actorUserId String?
|
|
281
|
+
action String
|
|
282
|
+
targetType String
|
|
283
|
+
targetId String?
|
|
284
|
+
metadata Json?
|
|
285
|
+
|
|
286
|
+
ipAddress String?
|
|
287
|
+
userAgent String?
|
|
288
|
+
|
|
289
|
+
createdAt DateTime @default(now())
|
|
290
|
+
|
|
291
|
+
actorUser User? @relation(fields: [actorUserId], references: [id])
|
|
292
|
+
|
|
293
|
+
@@index([orgId])
|
|
294
|
+
@@index([orgId, createdAt])
|
|
295
|
+
@@index([actorUserId])
|
|
296
|
+
@@index([action])
|
|
297
|
+
@@index([targetType, targetId])
|
|
298
|
+
@@index([createdAt])
|
|
299
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { PrismaClient } from '@prisma/client';
|
|
2
|
+
import { permissionKeys } from '../src/modules/access-control/types/permission-key';
|
|
3
|
+
|
|
4
|
+
const prisma = new PrismaClient();
|
|
5
|
+
|
|
6
|
+
function describePermission(key: string) {
|
|
7
|
+
const [module, action] = key.split('.');
|
|
8
|
+
return `${action} ${module}`.replaceAll('_', ' ');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function main() {
|
|
12
|
+
await prisma.$transaction(
|
|
13
|
+
permissionKeys.map((key) => {
|
|
14
|
+
const [module, action] = key.split('.');
|
|
15
|
+
|
|
16
|
+
return prisma.permission.upsert({
|
|
17
|
+
where: { key },
|
|
18
|
+
update: {
|
|
19
|
+
module,
|
|
20
|
+
action,
|
|
21
|
+
subject: module,
|
|
22
|
+
description: describePermission(key),
|
|
23
|
+
},
|
|
24
|
+
create: {
|
|
25
|
+
key,
|
|
26
|
+
module,
|
|
27
|
+
action,
|
|
28
|
+
subject: module,
|
|
29
|
+
description: describePermission(key),
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
main()
|
|
37
|
+
.then(async () => {
|
|
38
|
+
await prisma.$disconnect();
|
|
39
|
+
})
|
|
40
|
+
.catch(async (error) => {
|
|
41
|
+
console.error(error);
|
|
42
|
+
await prisma.$disconnect();
|
|
43
|
+
process.exit(1);
|
|
44
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
|
|
3
|
+
loadEnvFile();
|
|
4
|
+
|
|
5
|
+
const { Client } = await import('pg').catch(() => {
|
|
6
|
+
throw new Error(
|
|
7
|
+
'The pg package is required for db:create. Run npm install first, then rerun npm run setup:local.',
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const databaseUrls = collectDatabaseUrls();
|
|
12
|
+
for (const databaseUrl of databaseUrls) {
|
|
13
|
+
await ensureDatabase(databaseUrl);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
console.log('Database check complete.');
|
|
17
|
+
|
|
18
|
+
function collectDatabaseUrls() {
|
|
19
|
+
const urls = new Set();
|
|
20
|
+
const databaseUrl = process.env.DATABASE_URL;
|
|
21
|
+
|
|
22
|
+
if (!databaseUrl) {
|
|
23
|
+
throw new Error('DATABASE_URL is required. Set it in .env or run npm run setup:local.');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
urls.add(databaseUrl);
|
|
27
|
+
urls.add(process.env.TEST_DATABASE_URL || deriveTestDatabaseUrl(databaseUrl));
|
|
28
|
+
return [...urls];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function ensureDatabase(databaseUrl) {
|
|
32
|
+
const parsed = new URL(databaseUrl);
|
|
33
|
+
const databaseName = decodeURIComponent(parsed.pathname.replace(/^\//u, ''));
|
|
34
|
+
if (!databaseName) {
|
|
35
|
+
throw new Error(`Database URL must include a database name: ${redactUrl(databaseUrl)}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const maintenanceUrl = new URL(databaseUrl);
|
|
39
|
+
maintenanceUrl.pathname = '/postgres';
|
|
40
|
+
|
|
41
|
+
const client = new Client({ connectionString: maintenanceUrl.toString() });
|
|
42
|
+
try {
|
|
43
|
+
await client.connect();
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
[
|
|
47
|
+
`Could not connect to PostgreSQL at ${maintenanceUrl.host}.`,
|
|
48
|
+
'Start PostgreSQL locally or update DATABASE_URL in .env.',
|
|
49
|
+
`Original error: ${error.message}`,
|
|
50
|
+
].join('\n'),
|
|
51
|
+
{ cause: error },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const existing = await client.query('SELECT 1 FROM pg_database WHERE datname = $1', [
|
|
57
|
+
databaseName,
|
|
58
|
+
]);
|
|
59
|
+
if (existing.rowCount > 0) {
|
|
60
|
+
console.log(`[x] Database ${databaseName} exists`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await client.query(`CREATE DATABASE ${quoteIdentifier(databaseName)}`);
|
|
65
|
+
console.log(`[x] Created database ${databaseName}`);
|
|
66
|
+
} finally {
|
|
67
|
+
await client.end();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function deriveTestDatabaseUrl(databaseUrl) {
|
|
72
|
+
const url = new URL(databaseUrl);
|
|
73
|
+
const databaseName = decodeURIComponent(url.pathname.replace(/^\//u, '')) || 'app';
|
|
74
|
+
url.pathname = `/${databaseName.endsWith('_test') ? databaseName : `${databaseName}_test`}`;
|
|
75
|
+
return url.toString();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function quoteIdentifier(value) {
|
|
79
|
+
return `"${value.replaceAll('"', '""')}"`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function redactUrl(value) {
|
|
83
|
+
const url = new URL(value);
|
|
84
|
+
if (url.password) {
|
|
85
|
+
url.password = '***';
|
|
86
|
+
}
|
|
87
|
+
return url.toString();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function loadEnvFile() {
|
|
91
|
+
if (!existsSync('.env')) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const lines = readFileSync('.env', 'utf8').split(/\r?\n/);
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
const trimmed = line.trim();
|
|
98
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const equalsIndex = trimmed.indexOf('=');
|
|
103
|
+
if (equalsIndex === -1) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const key = trimmed.slice(0, equalsIndex).trim();
|
|
108
|
+
const rawValue = trimmed.slice(equalsIndex + 1).trim();
|
|
109
|
+
if (!key || process.env[key] !== undefined) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
process.env[key] = stripQuotes(rawValue);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function stripQuotes(value) {
|
|
118
|
+
if (
|
|
119
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
120
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
121
|
+
) {
|
|
122
|
+
return value.slice(1, -1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
|
|
4
|
+
const rawName = process.argv[2];
|
|
5
|
+
if (!rawName) {
|
|
6
|
+
throw new Error('Usage: npm run gen:module -- <module-name>');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const names = toNames(rawName);
|
|
10
|
+
const moduleRoot = join('src', 'modules', names.kebab);
|
|
11
|
+
const files = new Map([
|
|
12
|
+
[
|
|
13
|
+
join(moduleRoot, `${names.kebab}.module.ts`),
|
|
14
|
+
`import { Module } from '@nestjs/common';
|
|
15
|
+
import { ${names.pascal}Controller } from './presentation/${names.kebab}.controller';
|
|
16
|
+
import { ${names.pascal}Service } from './application/services/${names.kebab}.service';
|
|
17
|
+
|
|
18
|
+
@Module({
|
|
19
|
+
controllers: [${names.pascal}Controller],
|
|
20
|
+
providers: [${names.pascal}Service],
|
|
21
|
+
})
|
|
22
|
+
export class ${names.pascal}Module {}
|
|
23
|
+
`,
|
|
24
|
+
],
|
|
25
|
+
[
|
|
26
|
+
join(moduleRoot, 'dto', `${names.kebab}-echo.dto.ts`),
|
|
27
|
+
`import { ApiProperty } from '@nestjs/swagger';
|
|
28
|
+
import { IsString, MaxLength, MinLength } from 'class-validator';
|
|
29
|
+
|
|
30
|
+
export class ${names.pascal}EchoDto {
|
|
31
|
+
@ApiProperty({ example: 'Hello from ${names.kebab}.' })
|
|
32
|
+
@IsString()
|
|
33
|
+
@MinLength(1)
|
|
34
|
+
@MaxLength(200)
|
|
35
|
+
message!: string;
|
|
36
|
+
}
|
|
37
|
+
`,
|
|
38
|
+
],
|
|
39
|
+
[
|
|
40
|
+
join(moduleRoot, 'application', 'services', `${names.kebab}.service.ts`),
|
|
41
|
+
`import { Injectable } from '@nestjs/common';
|
|
42
|
+
import { Prisma } from '@prisma/client';
|
|
43
|
+
import { PrismaService } from '../../../../database/prisma/prisma.service';
|
|
44
|
+
import { AuthenticatedUser } from '../../../auth/types/authenticated-user';
|
|
45
|
+
import { RequestContextService } from '../../../request-context/application/services/request-context.service';
|
|
46
|
+
import { ${names.pascal}EchoDto } from '../../dto/${names.kebab}-echo.dto';
|
|
47
|
+
|
|
48
|
+
@Injectable()
|
|
49
|
+
export class ${names.pascal}Service {
|
|
50
|
+
constructor(
|
|
51
|
+
private readonly prisma: PrismaService,
|
|
52
|
+
private readonly requestContext: RequestContextService,
|
|
53
|
+
) {}
|
|
54
|
+
|
|
55
|
+
getStatus(orgId: string) {
|
|
56
|
+
this.requestContext.assertOrgScope(orgId);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
ok: true,
|
|
60
|
+
orgId,
|
|
61
|
+
contextOrgId: this.requestContext.getOrgId(),
|
|
62
|
+
requestId: this.requestContext.getRequestId(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async echo(user: AuthenticatedUser, orgId: string, dto: ${names.pascal}EchoDto) {
|
|
67
|
+
this.requestContext.assertOrgScope(orgId);
|
|
68
|
+
const message = dto.message.trim();
|
|
69
|
+
|
|
70
|
+
await this.prisma.auditLog.create({
|
|
71
|
+
data: {
|
|
72
|
+
orgId,
|
|
73
|
+
actorUserId: user.id,
|
|
74
|
+
action: '${names.kebab}.echo',
|
|
75
|
+
targetType: '${names.pascal}',
|
|
76
|
+
targetId: orgId,
|
|
77
|
+
metadata: {
|
|
78
|
+
messageLength: message.length,
|
|
79
|
+
requestId: this.requestContext.getRequestId(),
|
|
80
|
+
} satisfies Prisma.InputJsonObject,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
orgId,
|
|
86
|
+
contextOrgId: this.requestContext.getOrgId(),
|
|
87
|
+
message,
|
|
88
|
+
requestId: this.requestContext.getRequestId(),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
`,
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
join(moduleRoot, 'presentation', `${names.kebab}.controller.ts`),
|
|
96
|
+
`import { Body, Controller, Get, HttpCode, Param, Post, UseGuards } from '@nestjs/common';
|
|
97
|
+
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
|
98
|
+
import { PermissionGuard } from '../../access-control/application/services/permission.guard';
|
|
99
|
+
import { RequirePermissions } from '../../access-control/presentation/permissions.decorator';
|
|
100
|
+
import { JwtAuthGuard } from '../../auth/infrastructure/passport/jwt-auth.guard';
|
|
101
|
+
import { CurrentUser } from '../../auth/presentation/current-user.decorator';
|
|
102
|
+
import { AuthenticatedUser } from '../../auth/types/authenticated-user';
|
|
103
|
+
import { OrgScopeGuard } from '../../request-context/presentation/org-scope.guard';
|
|
104
|
+
import { ${names.pascal}EchoDto } from '../dto/${names.kebab}-echo.dto';
|
|
105
|
+
import { ${names.pascal}Service } from '../application/services/${names.kebab}.service';
|
|
106
|
+
|
|
107
|
+
@ApiTags('${names.title}')
|
|
108
|
+
@ApiBearerAuth()
|
|
109
|
+
@Controller('organisations/:orgId/${names.kebab}')
|
|
110
|
+
@UseGuards(JwtAuthGuard, OrgScopeGuard, PermissionGuard)
|
|
111
|
+
export class ${names.pascal}Controller {
|
|
112
|
+
constructor(private readonly ${names.camel}Service: ${names.pascal}Service) {}
|
|
113
|
+
|
|
114
|
+
@Get('status')
|
|
115
|
+
@RequirePermissions('${names.permissionSubject}.read')
|
|
116
|
+
status(@Param('orgId') orgId: string) {
|
|
117
|
+
return this.${names.camel}Service.getStatus(orgId);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@Post('echo')
|
|
121
|
+
@HttpCode(200)
|
|
122
|
+
@RequirePermissions('${names.permissionSubject}.update')
|
|
123
|
+
echo(@CurrentUser() user: AuthenticatedUser, @Param('orgId') orgId: string, @Body() dto: ${names.pascal}EchoDto) {
|
|
124
|
+
return this.${names.camel}Service.echo(user, orgId, dto);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
`,
|
|
128
|
+
],
|
|
129
|
+
[
|
|
130
|
+
join('test', `${names.kebab}.spec.ts`),
|
|
131
|
+
`import { ${names.pascal}Service } from '../src/modules/${names.kebab}/application/services/${names.kebab}.service';
|
|
132
|
+
|
|
133
|
+
describe('${names.pascal}Service', () => {
|
|
134
|
+
it('can be imported', () => {
|
|
135
|
+
expect(${names.pascal}Service).toBeDefined();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
`,
|
|
139
|
+
],
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
for (const [path, content] of files) {
|
|
143
|
+
if (existsSync(path)) {
|
|
144
|
+
throw new Error(`Refusing to overwrite existing file: ${path}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
148
|
+
writeFileSync(path, content);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
appendPermissionKeys(names);
|
|
152
|
+
appendRouteRegistryEntries(names);
|
|
153
|
+
|
|
154
|
+
console.log(`Generated ${names.pascal}Module.`);
|
|
155
|
+
console.log(`Add this to src/app.module.ts imports: ${names.pascal}Module`);
|
|
156
|
+
console.log(`Import path: ./modules/${names.kebab}/${names.kebab}.module`);
|
|
157
|
+
|
|
158
|
+
function appendPermissionKeys(names) {
|
|
159
|
+
const path = join('src', 'modules', 'access-control', 'types', 'permission-key.ts');
|
|
160
|
+
const source = readFileSync(path, 'utf8');
|
|
161
|
+
const keys = [`${names.permissionSubject}.read`, `${names.permissionSubject}.update`];
|
|
162
|
+
const missing = keys.filter((key) => !source.includes(`'${key}'`));
|
|
163
|
+
if (missing.length === 0) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const updated = source.replace(
|
|
168
|
+
'] as const;',
|
|
169
|
+
`${missing.map((key) => ` '${key}',`).join('\n')}\n] as const;`,
|
|
170
|
+
);
|
|
171
|
+
writeFileSync(path, updated);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function appendRouteRegistryEntries(names) {
|
|
175
|
+
const path = join('src', 'modules', 'access-control', 'types', 'route-permission-registry.ts');
|
|
176
|
+
const source = readFileSync(path, 'utf8');
|
|
177
|
+
const statusPath = `/organisations/:orgId/${names.kebab}/status`;
|
|
178
|
+
if (source.includes(statusPath)) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const entries = ` {
|
|
183
|
+
method: 'GET',
|
|
184
|
+
path: '${statusPath}',
|
|
185
|
+
permissions: ['${names.permissionSubject}.read'],
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
method: 'POST',
|
|
189
|
+
path: '/organisations/:orgId/${names.kebab}/echo',
|
|
190
|
+
permissions: ['${names.permissionSubject}.update'],
|
|
191
|
+
},
|
|
192
|
+
`;
|
|
193
|
+
writeFileSync(path, source.replace('];', `${entries}];`));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function toNames(value) {
|
|
197
|
+
const kebab = value
|
|
198
|
+
.trim()
|
|
199
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
200
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
201
|
+
.replace(/^-+|-+$/g, '')
|
|
202
|
+
.toLowerCase();
|
|
203
|
+
|
|
204
|
+
if (!kebab) {
|
|
205
|
+
throw new Error('Module name must contain at least one letter or number.');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const parts = kebab.split('-');
|
|
209
|
+
const pascal = parts.map((part) => `${part[0].toUpperCase()}${part.slice(1)}`).join('');
|
|
210
|
+
return {
|
|
211
|
+
camel: `${pascal[0].toLowerCase()}${pascal.slice(1)}`,
|
|
212
|
+
kebab,
|
|
213
|
+
pascal,
|
|
214
|
+
permissionSubject: kebab,
|
|
215
|
+
title: parts.map((part) => `${part[0].toUpperCase()}${part.slice(1)}`).join(' '),
|
|
216
|
+
};
|
|
217
|
+
}
|