@ftisindia/create-app 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/LICENSE +144 -0
  2. package/bin/index.mjs +2 -0
  3. package/package.json +36 -0
  4. package/src/copy.mjs +45 -0
  5. package/src/log.mjs +23 -0
  6. package/src/main.mjs +221 -0
  7. package/src/run.mjs +52 -0
  8. package/src/substitute.mjs +40 -0
  9. package/template/.env.example +36 -0
  10. package/template/.github/workflows/ci.yml +36 -0
  11. package/template/.husky/pre-commit +1 -0
  12. package/template/README.md +146 -0
  13. package/template/_editorconfig +8 -0
  14. package/template/_gitignore +7 -0
  15. package/template/_nvmrc +1 -0
  16. package/template/_package.json +107 -0
  17. package/template/_prettierignore +5 -0
  18. package/template/_prettierrc +6 -0
  19. package/template/docs/API_REFERENCE.md +123 -0
  20. package/template/docs/GETTING_STARTED.md +65 -0
  21. package/template/docs/MODULE_COMPLETION_CHECKLIST.md +40 -0
  22. package/template/docs/OAUTH.md +46 -0
  23. package/template/docs/SAMPLE_MODULE.md +23 -0
  24. package/template/docs/api.http +269 -0
  25. package/template/eslint.config.mjs +51 -0
  26. package/template/nest-cli.json +8 -0
  27. package/template/prisma/migrations/20260530000000_init/migration.sql +248 -0
  28. package/template/prisma/schema.prisma +299 -0
  29. package/template/prisma/seed.ts +44 -0
  30. package/template/scripts/db-create.mjs +126 -0
  31. package/template/scripts/gen-module.mjs +217 -0
  32. package/template/scripts/seed-test-user-org.ts +264 -0
  33. package/template/scripts/setup-local.mjs +224 -0
  34. package/template/scripts/test-db.mjs +69 -0
  35. package/template/src/app.module.ts +58 -0
  36. package/template/src/common/decorators/.gitkeep +1 -0
  37. package/template/src/common/dto/error-response.dto.ts +17 -0
  38. package/template/src/common/dto/membership-response.dto.ts +51 -0
  39. package/template/src/common/dto/mutation-response.dto.ts +11 -0
  40. package/template/src/common/dto/role-summary.dto.ts +18 -0
  41. package/template/src/common/dto/user-summary.dto.ts +23 -0
  42. package/template/src/common/enums/.gitkeep +1 -0
  43. package/template/src/common/filters/.gitkeep +1 -0
  44. package/template/src/common/filters/http-exception.filter.ts +78 -0
  45. package/template/src/common/guards/.gitkeep +1 -0
  46. package/template/src/common/interceptors/.gitkeep +1 -0
  47. package/template/src/common/pipes/.gitkeep +1 -0
  48. package/template/src/common/swagger/api-error-responses.ts +54 -0
  49. package/template/src/common/types/.gitkeep +1 -0
  50. package/template/src/config/app.config.ts +7 -0
  51. package/template/src/config/auth.config.ts +33 -0
  52. package/template/src/config/database.config.ts +6 -0
  53. package/template/src/config/env.validation.ts +131 -0
  54. package/template/src/config/index.ts +5 -0
  55. package/template/src/config/rbac.config.ts +6 -0
  56. package/template/src/database/prisma/prisma-transaction.ts +22 -0
  57. package/template/src/database/prisma/prisma.module.ts +9 -0
  58. package/template/src/database/prisma/prisma.service.ts +16 -0
  59. package/template/src/main.ts +42 -0
  60. package/template/src/modules/access-control/access-control.module.ts +24 -0
  61. package/template/src/modules/access-control/application/route-registry.validator.ts +289 -0
  62. package/template/src/modules/access-control/application/services/ability.factory.ts +28 -0
  63. package/template/src/modules/access-control/application/services/access-control.service.ts +478 -0
  64. package/template/src/modules/access-control/application/services/permission.guard.ts +77 -0
  65. package/template/src/modules/access-control/application/services/rbac-cache.service.ts +148 -0
  66. package/template/src/modules/access-control/dto/access-control-response.dto.ts +79 -0
  67. package/template/src/modules/access-control/dto/create-role.dto.ts +18 -0
  68. package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +23 -0
  69. package/template/src/modules/access-control/dto/update-role.dto.ts +19 -0
  70. package/template/src/modules/access-control/presentation/access-control.controller.ts +157 -0
  71. package/template/src/modules/access-control/presentation/permissions.decorator.ts +8 -0
  72. package/template/src/modules/access-control/presentation/public.decorator.ts +7 -0
  73. package/template/src/modules/access-control/types/permission-key.ts +37 -0
  74. package/template/src/modules/access-control/types/rbac-context.ts +11 -0
  75. package/template/src/modules/access-control/types/route-permission-registry.ts +129 -0
  76. package/template/src/modules/audit/application/services/audit.service.ts +97 -0
  77. package/template/src/modules/audit/audit.module.ts +13 -0
  78. package/template/src/modules/audit/dto/audit-response.dto.ts +75 -0
  79. package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +75 -0
  80. package/template/src/modules/audit/presentation/audit.controller.ts +37 -0
  81. package/template/src/modules/auth/application/services/auth.service.ts +509 -0
  82. package/template/src/modules/auth/application/services/password.service.ts +15 -0
  83. package/template/src/modules/auth/application/services/token.service.ts +95 -0
  84. package/template/src/modules/auth/auth.module.ts +73 -0
  85. package/template/src/modules/auth/dto/auth-response.dto.ts +29 -0
  86. package/template/src/modules/auth/dto/login.dto.ts +15 -0
  87. package/template/src/modules/auth/dto/logout.dto.ts +3 -0
  88. package/template/src/modules/auth/dto/oauth-exchange.dto.ts +15 -0
  89. package/template/src/modules/auth/dto/refresh-token.dto.ts +14 -0
  90. package/template/src/modules/auth/dto/signup.dto.ts +27 -0
  91. package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +27 -0
  92. package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +56 -0
  93. package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +5 -0
  94. package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +43 -0
  95. package/template/src/modules/auth/presentation/auth.controller.ts +148 -0
  96. package/template/src/modules/auth/presentation/current-user.decorator.ts +11 -0
  97. package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +33 -0
  98. package/template/src/modules/auth/types/authenticated-user.ts +7 -0
  99. package/template/src/modules/auth/types/google-auth-profile.ts +6 -0
  100. package/template/src/modules/auth/types/jwt-payload.ts +5 -0
  101. package/template/src/modules/health/dto/health-response.dto.ts +9 -0
  102. package/template/src/modules/health/health.module.ts +7 -0
  103. package/template/src/modules/health/presentation/health.controller.ts +33 -0
  104. package/template/src/modules/invitations/application/services/invitations.service.ts +967 -0
  105. package/template/src/modules/invitations/dto/accept-invitation.dto.ts +24 -0
  106. package/template/src/modules/invitations/dto/create-invitation.dto.ts +100 -0
  107. package/template/src/modules/invitations/dto/invitation-response.dto.ts +108 -0
  108. package/template/src/modules/invitations/dto/invitation-token.dto.ts +15 -0
  109. package/template/src/modules/invitations/invitations.module.ts +12 -0
  110. package/template/src/modules/invitations/presentation/invitations.controller.ts +149 -0
  111. package/template/src/modules/memberships/application/services/memberships.service.ts +455 -0
  112. package/template/src/modules/memberships/dto/transfer-owner.dto.ts +11 -0
  113. package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +8 -0
  114. package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +8 -0
  115. package/template/src/modules/memberships/dto/update-membership-role.dto.ts +11 -0
  116. package/template/src/modules/memberships/dto/update-membership-status.dto.ts +9 -0
  117. package/template/src/modules/memberships/memberships.module.ts +12 -0
  118. package/template/src/modules/memberships/presentation/memberships.controller.ts +193 -0
  119. package/template/src/modules/organisations/application/services/organisations.service.ts +147 -0
  120. package/template/src/modules/organisations/dto/create-organisation.dto.ts +32 -0
  121. package/template/src/modules/organisations/dto/organisation-response.dto.ts +62 -0
  122. package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +24 -0
  123. package/template/src/modules/organisations/organisations.module.ts +12 -0
  124. package/template/src/modules/organisations/presentation/organisations.controller.ts +37 -0
  125. package/template/src/modules/organisations/types/default-organisation-data.ts +18 -0
  126. package/template/src/modules/platform-admin/.gitkeep +1 -0
  127. package/template/src/modules/request-context/application/services/request-context.service.ts +79 -0
  128. package/template/src/modules/request-context/presentation/org-scope.guard.ts +26 -0
  129. package/template/src/modules/request-context/presentation/request-context.interceptor.ts +26 -0
  130. package/template/src/modules/request-context/presentation/request-context.middleware.ts +31 -0
  131. package/template/src/modules/request-context/request-context.module.ts +25 -0
  132. package/template/src/modules/request-context/types/request-context.ts +29 -0
  133. package/template/src/modules/sample/application/services/sample.service.ts +67 -0
  134. package/template/src/modules/sample/dto/sample-echo.dto.ts +10 -0
  135. package/template/src/modules/sample/dto/sample-response.dto.ts +41 -0
  136. package/template/src/modules/sample/presentation/sample.controller.ts +63 -0
  137. package/template/src/modules/sample/sample.module.ts +11 -0
  138. package/template/src/modules/settings/application/services/settings.service.ts +139 -0
  139. package/template/src/modules/settings/dto/setting-response.dto.ts +27 -0
  140. package/template/src/modules/settings/dto/update-setting.dto.ts +16 -0
  141. package/template/src/modules/settings/presentation/settings.controller.ts +66 -0
  142. package/template/src/modules/settings/settings.module.ts +12 -0
  143. package/template/src/modules/settings/types/setting-definitions.ts +104 -0
  144. package/template/src/modules/users/.gitkeep +1 -0
  145. package/template/test/.gitkeep +1 -0
  146. package/template/test/jest-e2e.json +9 -0
  147. package/template/test/permission.guard.spec.ts +22 -0
  148. package/template/test/route-registry.validator.spec.ts +90 -0
  149. package/template/test/security.e2e-spec.ts +102 -0
  150. package/template/tsconfig.build.json +4 -0
  151. package/template/tsconfig.json +18 -0
@@ -0,0 +1,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
+ }