@ftisindia/create-app 0.1.5 → 0.2.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 (167) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +28 -0
  3. package/template/README.md +51 -0
  4. package/template/_gitignore +6 -0
  5. package/template/_package.json +10 -1
  6. package/template/docs/FORMS.md +188 -0
  7. package/template/docs/FORMS_CHECKLIST.md +69 -0
  8. package/template/docs/REPORTS.md +255 -0
  9. package/template/docs/REPORTS_CHECKLIST.md +152 -0
  10. package/template/prisma/migrations/20260612000000_add_form_builder/migration.sql +147 -0
  11. package/template/prisma/migrations/20260613000000_add_report_builder/migration.sql +129 -0
  12. package/template/prisma/migrations/20260616000000_add_form_outbox_claimed_by/migration.sql +5 -0
  13. package/template/prisma/schema.prisma +289 -0
  14. package/template/scripts/export-openapi.ts +85 -0
  15. package/template/scripts/gen-form.mjs +149 -0
  16. package/template/scripts/push-form.ts +124 -0
  17. package/template/src/app.module.ts +30 -8
  18. package/template/src/common/dto/membership-response.dto.ts +1 -0
  19. package/template/src/common/dto/role-summary.dto.ts +3 -3
  20. package/template/src/common/dto/user-summary.dto.ts +3 -3
  21. package/template/src/config/env.validation.ts +28 -0
  22. package/template/src/config/forms.config.ts +13 -0
  23. package/template/src/config/index.ts +2 -0
  24. package/template/src/config/openapi.ts +12 -0
  25. package/template/src/config/reports-secret.ts +15 -0
  26. package/template/src/config/reports.config.ts +18 -0
  27. package/template/src/main.ts +3 -12
  28. package/template/src/modules/access-control/dto/access-control-response.dto.ts +3 -0
  29. package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +5 -1
  30. package/template/src/modules/access-control/types/permission-key.ts +27 -0
  31. package/template/src/modules/access-control/types/route-permission-registry.ts +183 -0
  32. package/template/src/modules/audit/dto/audit-response.dto.ts +7 -3
  33. package/template/src/modules/auth/auth.module.ts +3 -1
  34. package/template/src/modules/auth/dto/auth-response.dto.ts +1 -1
  35. package/template/src/modules/forms/application/services/file-gc.service.ts +85 -0
  36. package/template/src/modules/forms/application/services/forms-definitions.service.ts +137 -0
  37. package/template/src/modules/forms/application/services/forms-error.mapper.ts +64 -0
  38. package/template/src/modules/forms/application/services/forms-export.service.ts +210 -0
  39. package/template/src/modules/forms/application/services/forms-files.service.ts +164 -0
  40. package/template/src/modules/forms/application/services/forms-public.service.ts +49 -0
  41. package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +53 -0
  42. package/template/src/modules/forms/application/services/forms-submissions.service.ts +103 -0
  43. package/template/src/modules/forms/application/services/handlers/authenticate.action.ts +37 -0
  44. package/template/src/modules/forms/application/services/handlers/logging-email.handler.ts +22 -0
  45. package/template/src/modules/forms/application/services/handlers/send-confirmation-email.action.ts +40 -0
  46. package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts +319 -0
  47. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +89 -0
  48. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +131 -0
  49. package/template/src/modules/forms/dto/create-form-definition.dto.ts +12 -0
  50. package/template/src/modules/forms/dto/data-source-response.dto.ts +19 -0
  51. package/template/src/modules/forms/dto/export-submissions-query.dto.ts +33 -0
  52. package/template/src/modules/forms/dto/file-upload-response.dto.ts +24 -0
  53. package/template/src/modules/forms/dto/form-definition-response.dto.ts +50 -0
  54. package/template/src/modules/forms/dto/form-render-response.dto.ts +17 -0
  55. package/template/src/modules/forms/dto/list-form-definitions-query.dto.ts +10 -0
  56. package/template/src/modules/forms/dto/list-submissions-query.dto.ts +10 -0
  57. package/template/src/modules/forms/dto/public-submit-form.dto.ts +24 -0
  58. package/template/src/modules/forms/dto/set-public-access.dto.ts +8 -0
  59. package/template/src/modules/forms/dto/submission-response.dto.ts +99 -0
  60. package/template/src/modules/forms/dto/submit-form.dto.ts +50 -0
  61. package/template/src/modules/forms/dto/update-form-definition.dto.ts +12 -0
  62. package/template/src/modules/forms/dto/upload-file-query.dto.ts +33 -0
  63. package/template/src/modules/forms/dto/validate-submission.dto.ts +22 -0
  64. package/template/src/modules/forms/examples/abstract-submission.form.json +80 -0
  65. package/template/src/modules/forms/examples/login.form.json +24 -0
  66. package/template/src/modules/forms/examples/registration.form.json +44 -0
  67. package/template/src/modules/forms/forms.module.ts +228 -0
  68. package/template/src/modules/forms/forms.tokens.ts +6 -0
  69. package/template/src/modules/forms/infrastructure/audit-sink.adapter.ts +30 -0
  70. package/template/src/modules/forms/infrastructure/casl-forms-authorization.ts +31 -0
  71. package/template/src/modules/forms/infrastructure/prisma-tx-runner.ts +17 -0
  72. package/template/src/modules/forms/infrastructure/registry/form-extension.decorators.ts +17 -0
  73. package/template/src/modules/forms/infrastructure/registry/registry-bootstrap.service.ts +82 -0
  74. package/template/src/modules/forms/infrastructure/request-forms-context.ts +60 -0
  75. package/template/src/modules/forms/infrastructure/schema-check/forms-schema-check.service.ts +76 -0
  76. package/template/src/modules/forms/infrastructure/storage/local-disk-storage.adapter.ts +43 -0
  77. package/template/src/modules/forms/infrastructure/stores/index.ts +5 -0
  78. package/template/src/modules/forms/infrastructure/stores/prisma-action-log.store.ts +37 -0
  79. package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +108 -0
  80. package/template/src/modules/forms/infrastructure/stores/prisma-form-definition.store.ts +147 -0
  81. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +156 -0
  82. package/template/src/modules/forms/infrastructure/stores/prisma-submission.store.ts +164 -0
  83. package/template/src/modules/forms/presentation/forms-data-sources.controller.ts +58 -0
  84. package/template/src/modules/forms/presentation/forms-definitions.controller.ts +191 -0
  85. package/template/src/modules/forms/presentation/forms-files.controller.ts +79 -0
  86. package/template/src/modules/forms/presentation/forms-submissions.controller.ts +154 -0
  87. package/template/src/modules/forms/presentation/forms-upload.interceptor.ts +33 -0
  88. package/template/src/modules/forms/presentation/public-forms.controller.ts +51 -0
  89. package/template/src/modules/invitations/dto/invitation-response.dto.ts +4 -0
  90. package/template/src/modules/organisations/dto/organisation-response.dto.ts +1 -0
  91. package/template/src/modules/reports/application/services/reports-actions.service.ts +54 -0
  92. package/template/src/modules/reports/application/services/reports-definitions.service.ts +66 -0
  93. package/template/src/modules/reports/application/services/reports-error.mapper.ts +97 -0
  94. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +205 -0
  95. package/template/src/modules/reports/application/services/reports-exports.service.ts +78 -0
  96. package/template/src/modules/reports/application/services/reports-queries.service.ts +35 -0
  97. package/template/src/modules/reports/application/services/reports-settings-reader.service.ts +49 -0
  98. package/template/src/modules/reports/application/services/reports-views.service.ts +79 -0
  99. package/template/src/modules/reports/dto/action-result-response.dto.ts +21 -0
  100. package/template/src/modules/reports/dto/create-report-definition.dto.ts +86 -0
  101. package/template/src/modules/reports/dto/create-saved-view.dto.ts +26 -0
  102. package/template/src/modules/reports/dto/execute-action.dto.ts +71 -0
  103. package/template/src/modules/reports/dto/export-job-response.dto.ts +60 -0
  104. package/template/src/modules/reports/dto/export-request.dto.ts +34 -0
  105. package/template/src/modules/reports/dto/list-reports-query.dto.ts +10 -0
  106. package/template/src/modules/reports/dto/list-views-query.dto.ts +17 -0
  107. package/template/src/modules/reports/dto/prepare-action-response.dto.ts +14 -0
  108. package/template/src/modules/reports/dto/prepare-action.dto.ts +27 -0
  109. package/template/src/modules/reports/dto/query-response.dto.ts +64 -0
  110. package/template/src/modules/reports/dto/query-spec.dto.ts +120 -0
  111. package/template/src/modules/reports/dto/report-definition-response.dto.ts +64 -0
  112. package/template/src/modules/reports/dto/report-meta-query.dto.ts +16 -0
  113. package/template/src/modules/reports/dto/report-meta-response.dto.ts +113 -0
  114. package/template/src/modules/reports/dto/saved-view-response.dto.ts +66 -0
  115. package/template/src/modules/reports/dto/update-report-definition.dto.ts +9 -0
  116. package/template/src/modules/reports/dto/update-saved-view.dto.ts +27 -0
  117. package/template/src/modules/reports/examples/abstract-review-board.report.json +54 -0
  118. package/template/src/modules/reports/examples/org-members.report.json +55 -0
  119. package/template/src/modules/reports/infrastructure/audit-sink.adapter.ts +31 -0
  120. package/template/src/modules/reports/infrastructure/casl-reports-authorization.ts +39 -0
  121. package/template/src/modules/reports/infrastructure/forms-adapter/form-report-source.adapter.ts +292 -0
  122. package/template/src/modules/reports/infrastructure/forms-adapter/form-row-actions.ts +171 -0
  123. package/template/src/modules/reports/infrastructure/forms-adapter/forms-bridge-bootstrap.service.ts +32 -0
  124. package/template/src/modules/reports/infrastructure/prisma-catalog.adapter.ts +95 -0
  125. package/template/src/modules/reports/infrastructure/prisma-query-executor.ts +103 -0
  126. package/template/src/modules/reports/infrastructure/prisma-snapshot-runner.ts +47 -0
  127. package/template/src/modules/reports/infrastructure/prisma-tx-runner.ts +18 -0
  128. package/template/src/modules/reports/infrastructure/registry/registry-bootstrap.service.ts +61 -0
  129. package/template/src/modules/reports/infrastructure/registry/report-extension.decorators.ts +14 -0
  130. package/template/src/modules/reports/infrastructure/reports-job-queue.adapter.ts +28 -0
  131. package/template/src/modules/reports/infrastructure/request-reports-context.ts +42 -0
  132. package/template/src/modules/reports/infrastructure/schema-check/reports-schema-check.service.ts +116 -0
  133. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +92 -0
  134. package/template/src/modules/reports/infrastructure/stores/index.ts +5 -0
  135. package/template/src/modules/reports/infrastructure/stores/prisma-bulk-action-run.store.ts +89 -0
  136. package/template/src/modules/reports/infrastructure/stores/prisma-export-job.store.ts +93 -0
  137. package/template/src/modules/reports/infrastructure/stores/prisma-report-definition.store.ts +171 -0
  138. package/template/src/modules/reports/infrastructure/stores/prisma-row-tag.store.ts +110 -0
  139. package/template/src/modules/reports/infrastructure/stores/prisma-saved-view.store.ts +144 -0
  140. package/template/src/modules/reports/presentation/reports-actions.controller.ts +83 -0
  141. package/template/src/modules/reports/presentation/reports-definitions.controller.ts +156 -0
  142. package/template/src/modules/reports/presentation/reports-export-jobs.controller.ts +61 -0
  143. package/template/src/modules/reports/presentation/reports-export.controller.ts +76 -0
  144. package/template/src/modules/reports/presentation/reports-query.controller.ts +52 -0
  145. package/template/src/modules/reports/presentation/reports-views.controller.ts +140 -0
  146. package/template/src/modules/reports/reports-forms.module.ts +33 -0
  147. package/template/src/modules/reports/reports.module.ts +335 -0
  148. package/template/src/modules/reports/reports.tokens.ts +11 -0
  149. package/template/src/modules/reports/sources/org-members.source.ts +112 -0
  150. package/template/src/modules/settings/types/setting-definitions.ts +94 -0
  151. package/template/test/forms-captcha.e2e-spec.ts +163 -0
  152. package/template/test/forms-definitions.e2e-spec.ts +394 -0
  153. package/template/test/forms-export.e2e-spec.ts +390 -0
  154. package/template/test/forms-files.e2e-spec.ts +345 -0
  155. package/template/test/forms-outbox.e2e-spec.ts +570 -0
  156. package/template/test/forms-permission-sync.spec.ts +27 -0
  157. package/template/test/forms-public.e2e-spec.ts +293 -0
  158. package/template/test/forms-schema-check.e2e-spec.ts +65 -0
  159. package/template/test/forms-submissions.e2e-spec.ts +500 -0
  160. package/template/test/forms-throttling.e2e-spec.ts +146 -0
  161. package/template/test/forms-webhooks.e2e-spec.ts +403 -0
  162. package/template/test/jest-e2e.json +1 -0
  163. package/template/test/reports-advanced.e2e-spec.ts +381 -0
  164. package/template/test/reports-permission-sync.spec.ts +30 -0
  165. package/template/test/reports-query.e2e-spec.ts +402 -0
  166. package/template/test/reports-tiers.e2e-spec.ts +343 -0
  167. package/template/test/route-registry.validator.spec.ts +22 -0
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Push a form-definition JSON file into an organisation — headless
3
+ * definitions-as-code, through the REAL engine path (save-time lint, RBAC,
4
+ * audit rows, versioning). Nothing is bypassed: the script impersonates an
5
+ * existing member (their CASL permissions apply; org owners pass everything).
6
+ *
7
+ * npm run forms:push -- --file src/modules/forms/definitions/my-form.form.json \
8
+ * --org <org-id-or-slug> --user <member-email> [--publish]
9
+ *
10
+ * Idempotent: if the file matches the latest stored version's schema, nothing
11
+ * new is created (with --publish, a matching DRAFT is published in place).
12
+ * Runs under ts-node (it bootstraps Nest — tsx drops decorator metadata).
13
+ */
14
+ import { readFileSync } from 'node:fs';
15
+ import { NestFactory } from '@nestjs/core';
16
+ import type { FormDefinition } from '@ftisindia/form-builder';
17
+ import { AppModule } from '../src/app.module';
18
+ import { PrismaService } from '../src/database/prisma/prisma.service';
19
+ import { RbacCacheService } from '../src/modules/access-control/application/services/rbac-cache.service';
20
+ import { FormsDefinitionsService } from '../src/modules/forms/application/services/forms-definitions.service';
21
+ import { RequestContextService } from '../src/modules/request-context/application/services/request-context.service';
22
+
23
+ function argValue(flag: string): string | undefined {
24
+ const index = process.argv.indexOf(flag);
25
+ return index !== -1 ? process.argv[index + 1] : undefined;
26
+ }
27
+
28
+ function stableStringify(value: unknown): string {
29
+ if (Array.isArray(value)) {
30
+ return `[${value.map(stableStringify).join(',')}]`;
31
+ }
32
+ if (value && typeof value === 'object') {
33
+ const entries = Object.entries(value as Record<string, unknown>)
34
+ .filter(([, v]) => v !== undefined)
35
+ .sort(([a], [b]) => a.localeCompare(b))
36
+ .map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`);
37
+ return `{${entries.join(',')}}`;
38
+ }
39
+ return JSON.stringify(value);
40
+ }
41
+
42
+ /** Version/status are managed by the engine — compare the authored content only. */
43
+ function normalized(schema: FormDefinition): string {
44
+ const { version: _version, status: _status, ...rest } = schema;
45
+ return stableStringify(rest);
46
+ }
47
+
48
+ async function main() {
49
+ const file = argValue('--file');
50
+ const orgRef = argValue('--org');
51
+ const userEmail = argValue('--user');
52
+ const publish = process.argv.includes('--publish');
53
+ if (!file || !orgRef || !userEmail) {
54
+ throw new Error(
55
+ 'Usage: npm run forms:push -- --file <path> --org <org-id-or-slug> --user <member-email> [--publish]',
56
+ );
57
+ }
58
+
59
+ const definition = JSON.parse(readFileSync(file, 'utf8')) as FormDefinition;
60
+ if (!definition.key) {
61
+ throw new Error(`${file} does not look like a form definition (missing "key").`);
62
+ }
63
+
64
+ const app = await NestFactory.createApplicationContext(AppModule, {
65
+ logger: ['error', 'warn'],
66
+ });
67
+ try {
68
+ const prisma = app.get(PrismaService);
69
+ const user = await prisma.user.findFirst({ where: { email: userEmail } });
70
+ if (!user) {
71
+ throw new Error(`No user found with email "${userEmail}".`);
72
+ }
73
+ const org = await prisma.organisation.findFirst({
74
+ where: { OR: [{ id: orgRef }, { slug: orgRef }] },
75
+ });
76
+ if (!org) {
77
+ throw new Error(`No organisation found with id or slug "${orgRef}".`);
78
+ }
79
+
80
+ // Real membership + permissions — the engine's authz seam sees exactly
81
+ // what this user could do over HTTP.
82
+ const rbac = await app.get(RbacCacheService).getContext(user.id, org.id);
83
+ const requestContext = app.get(RequestContextService);
84
+ const definitions = app.get(FormsDefinitionsService);
85
+
86
+ await requestContext.run(
87
+ { source: 'worker', orgId: org.id, userId: user.id, rbac },
88
+ async () => {
89
+ const latest = await prisma.formDefinition.findFirst({
90
+ where: { orgId: org.id, key: definition.key },
91
+ orderBy: { version: 'desc' },
92
+ });
93
+
94
+ if (latest && normalized(latest.schema as unknown as FormDefinition) === normalized(definition)) {
95
+ if (publish && latest.status === 'DRAFT') {
96
+ await definitions.publish(org.id, definition.key, latest.version);
97
+ console.log(`Unchanged content — published existing draft v${latest.version}.`);
98
+ } else {
99
+ console.log(
100
+ `No changes — "${definition.key}" v${latest.version} (${latest.status}) already matches ${file}.`,
101
+ );
102
+ }
103
+ return;
104
+ }
105
+
106
+ const created = await definitions.create(org.id, {
107
+ definition: definition as unknown as Record<string, unknown>,
108
+ });
109
+ console.log(`Created "${created.key}" v${created.version} (DRAFT).`);
110
+ if (publish) {
111
+ await definitions.publish(org.id, created.key, created.version);
112
+ console.log(`Published "${created.key}" v${created.version}.`);
113
+ }
114
+ },
115
+ );
116
+ } finally {
117
+ await app.close();
118
+ }
119
+ }
120
+
121
+ main().catch((error) => {
122
+ console.error(error instanceof Error ? error.message : error);
123
+ process.exit(1);
124
+ });
@@ -2,15 +2,26 @@ import { Module } from '@nestjs/common';
2
2
  import { ConfigModule } from '@nestjs/config';
3
3
  import { APP_GUARD } from '@nestjs/core';
4
4
  import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
5
- import { appConfig, authConfig, databaseConfig, rbacConfig, validate } from './config';
5
+ import {
6
+ appConfig,
7
+ authConfig,
8
+ databaseConfig,
9
+ formsConfig,
10
+ rbacConfig,
11
+ reportsConfig,
12
+ validate,
13
+ } from './config';
6
14
  import { PrismaModule } from './database/prisma/prisma.module';
7
15
  import { AccessControlModule } from './modules/access-control/access-control.module';
8
16
  import { AuditModule } from './modules/audit/audit.module';
9
17
  import { AuthModule } from './modules/auth/auth.module';
18
+ import { FormsModule } from './modules/forms/forms.module';
10
19
  import { HealthModule } from './modules/health/health.module';
11
20
  import { InvitationsModule } from './modules/invitations/invitations.module';
12
21
  import { MembershipsModule } from './modules/memberships/memberships.module';
13
22
  import { OrganisationsModule } from './modules/organisations/organisations.module';
23
+ import { ReportsModule } from './modules/reports/reports.module';
24
+ import { ReportsFormsModule } from './modules/reports/reports-forms.module';
14
25
  import { RequestContextModule } from './modules/request-context/request-context.module';
15
26
  import { SampleModule } from './modules/sample/sample.module';
16
27
  import { SettingsModule } from './modules/settings/settings.module';
@@ -21,15 +32,21 @@ import { SettingsModule } from './modules/settings/settings.module';
21
32
  isGlobal: true,
22
33
  cache: true,
23
34
  expandVariables: true,
24
- load: [appConfig, authConfig, databaseConfig, rbacConfig],
35
+ load: [appConfig, authConfig, databaseConfig, formsConfig, rbacConfig, reportsConfig],
25
36
  validate,
26
37
  }),
27
- ThrottlerModule.forRoot([
28
- {
29
- ttl: 60_000,
30
- limit: 100,
31
- },
32
- ]),
38
+ ThrottlerModule.forRoot({
39
+ throttlers: [
40
+ {
41
+ ttl: 60_000,
42
+ limit: 100,
43
+ },
44
+ ],
45
+ // Most e2e suites create many users/submissions in seconds; enable
46
+ // throttling only in the dedicated abuse-control suite.
47
+ skipIf: () =>
48
+ process.env.NODE_ENV === 'test' && process.env.E2E_THROTTLE_ENABLED !== 'true',
49
+ }),
33
50
  PrismaModule,
34
51
  HealthModule,
35
52
  AuthModule,
@@ -40,6 +57,11 @@ import { SettingsModule } from './modules/settings/settings.module';
40
57
  MembershipsModule,
41
58
  InvitationsModule,
42
59
  SettingsModule,
60
+ FormsModule,
61
+ ReportsModule,
62
+ // Optional bridge: adds form-backed reports + delegated grid verbs. Remove
63
+ // this line and ReportsModule keeps working over custom sources alone.
64
+ ReportsFormsModule,
43
65
  SampleModule,
44
66
  ],
45
67
  providers: [
@@ -55,6 +55,7 @@ export class MembershipListResponseDto {
55
55
  items!: MembershipResponseDto[];
56
56
 
57
57
  @ApiPropertyOptional({
58
+ type: String,
58
59
  example: '0a57fb4a-95c6-4f7e-bd5a-f96dbe0599e3',
59
60
  format: 'uuid',
60
61
  nullable: true,
@@ -10,9 +10,9 @@ export class RoleSummaryDto {
10
10
  @ApiProperty({ example: 'Owner' })
11
11
  name!: string;
12
12
 
13
- @ApiPropertyOptional({ example: 'Full organisation access.', nullable: true })
13
+ @ApiPropertyOptional({ type: String, example: 'Full organisation access.', nullable: true })
14
14
  description?: string | null;
15
15
 
16
- @ApiPropertyOptional({ example: true })
17
- isSystemSeeded?: boolean;
16
+ @ApiProperty({ example: true })
17
+ isSystemSeeded!: boolean;
18
18
  }
@@ -7,13 +7,13 @@ export class UserSummaryDto {
7
7
  })
8
8
  id!: string;
9
9
 
10
- @ApiPropertyOptional({ example: 'owner@example.com', nullable: true })
10
+ @ApiPropertyOptional({ type: String, example: 'owner@example.com', nullable: true })
11
11
  email?: string | null;
12
12
 
13
- @ApiPropertyOptional({ example: '+14155552671', nullable: true })
13
+ @ApiPropertyOptional({ type: String, example: '+14155552671', nullable: true })
14
14
  mobile?: string | null;
15
15
 
16
- @ApiPropertyOptional({ example: 'Starter Owner', nullable: true })
16
+ @ApiPropertyOptional({ type: String, example: 'Starter Owner', nullable: true })
17
17
  displayName?: string | null;
18
18
  }
19
19
 
@@ -66,8 +66,36 @@ export const envSchema = z
66
66
  CORS_CREDENTIALS: booleanFromEnv.default(false),
67
67
  RBAC_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(60),
68
68
  ORG_CONTEXT_MODE: z.literal('path').default('path'),
69
+ FORMS_OUTBOX_ENABLED: booleanFromEnv.default(true),
70
+ FORMS_OUTBOX_POLL_MS: z.coerce.number().int().positive().default(5000),
71
+ FORMS_OUTBOX_HEARTBEAT_MS: z.coerce.number().int().positive().default(60_000),
72
+ FORMS_FILE_GC_INTERVAL_MS: z.coerce.number().int().positive().default(3_600_000),
73
+ FORMS_FILE_TEMP_TTL_HOURS: z.coerce.number().int().positive().default(24),
74
+ FORMS_MAX_UPLOAD_MB: z.coerce.number().int().positive().default(25),
75
+ FORMS_FILE_STORAGE_DIR: z.string().trim().min(1).default('./var/uploads'),
76
+ FORMS_SCHEMA_CHECK: z.enum(['on', 'off']).default('on'),
77
+ REPORTS_SCHEMA_CHECK: z.enum(['on', 'off']).default('on'),
78
+ // HMAC secret for report cursors and bulk-action tokens (report design
79
+ // §5.3/§6.3). Optional: when empty, a key is derived from JWT_SECRET via
80
+ // HKDF so existing apps need no new mandatory env. Rotating it invalidates
81
+ // outstanding cursors/tokens — clients restart from page one by design.
82
+ REPORTS_TOKEN_SECRET: z.string().trim().optional().default(''),
83
+ // Reports-owned async-export worker + file storage (report design §9).
84
+ REPORTS_EXPORT_WORKER_ENABLED: booleanFromEnv.default(true),
85
+ REPORTS_EXPORT_POLL_MS: z.coerce.number().int().positive().default(5000),
86
+ REPORTS_EXPORT_STORAGE_DIR: z.string().trim().min(1).default('./var/report-exports'),
87
+ REPORTS_EXPORT_RETENTION_DAYS: z.coerce.number().int().nonnegative().default(7),
88
+ REPORTS_EXPORT_RETENTION_SWEEP_MS: z.coerce.number().int().positive().default(3_600_000),
69
89
  })
70
90
  .superRefine((env, ctx) => {
91
+ if (env.REPORTS_TOKEN_SECRET.length > 0 && env.REPORTS_TOKEN_SECRET.length < 32) {
92
+ ctx.addIssue({
93
+ code: 'custom',
94
+ path: ['REPORTS_TOKEN_SECRET'],
95
+ message: 'REPORTS_TOKEN_SECRET must be at least 32 characters when set',
96
+ });
97
+ }
98
+
71
99
  const corsOrigins = parseCorsOrigins(env.CORS_ORIGINS);
72
100
 
73
101
  if (env.CORS_CREDENTIALS && corsOrigins.includes('*')) {
@@ -0,0 +1,13 @@
1
+ import { registerAs } from '@nestjs/config';
2
+ import { getEnv } from './env.validation';
3
+
4
+ export default registerAs('forms', () => ({
5
+ outboxEnabled: getEnv().FORMS_OUTBOX_ENABLED,
6
+ outboxPollMs: getEnv().FORMS_OUTBOX_POLL_MS,
7
+ outboxHeartbeatMs: getEnv().FORMS_OUTBOX_HEARTBEAT_MS,
8
+ fileGcIntervalMs: getEnv().FORMS_FILE_GC_INTERVAL_MS,
9
+ fileTempTtlHours: getEnv().FORMS_FILE_TEMP_TTL_HOURS,
10
+ maxUploadMb: getEnv().FORMS_MAX_UPLOAD_MB,
11
+ storageDir: getEnv().FORMS_FILE_STORAGE_DIR,
12
+ schemaCheck: getEnv().FORMS_SCHEMA_CHECK,
13
+ }));
@@ -1,5 +1,7 @@
1
1
  export { default as appConfig } from './app.config';
2
2
  export { default as authConfig } from './auth.config';
3
3
  export { default as databaseConfig } from './database.config';
4
+ export { default as formsConfig } from './forms.config';
4
5
  export { default as rbacConfig } from './rbac.config';
6
+ export { default as reportsConfig } from './reports.config';
5
7
  export { validate } from './env.validation';
@@ -0,0 +1,12 @@
1
+ import { DocumentBuilder } from '@nestjs/swagger';
2
+
3
+ export function buildOpenApiConfig() {
4
+ return new DocumentBuilder()
5
+ .setTitle('Foundation Starter API')
6
+ .setDescription(
7
+ 'Modular-monolith starter API with auth, organisations, RBAC, audit, and settings.',
8
+ )
9
+ .setVersion('0.1.0')
10
+ .addBearerAuth()
11
+ .build();
12
+ }
@@ -0,0 +1,15 @@
1
+ import { hkdfSync } from 'node:crypto';
2
+
3
+ /**
4
+ * Key bytes for report cursor/action-token HMACs (report design §5.3/§6.3).
5
+ * Uses REPORTS_TOKEN_SECRET verbatim when configured; otherwise derives a
6
+ * dedicated 32-byte key from JWT_SECRET via HKDF-SHA256 so the raw JWT secret
7
+ * never signs report tokens directly.
8
+ */
9
+ export function createHkdfSync(reportsSecret: string, jwtSecret: string): Uint8Array {
10
+ if (reportsSecret.length > 0) {
11
+ return new TextEncoder().encode(reportsSecret);
12
+ }
13
+
14
+ return new Uint8Array(hkdfSync('sha256', jwtSecret, '', 'ftis-reports-tokens', 32));
15
+ }
@@ -0,0 +1,18 @@
1
+ import { registerAs } from '@nestjs/config';
2
+ import { getEnv } from './env.validation';
3
+ import { createHkdfSync } from './reports-secret';
4
+
5
+ export default registerAs('reports', () => ({
6
+ schemaCheck: getEnv().REPORTS_SCHEMA_CHECK,
7
+ // Resolved key bytes for cursor/action-token HMACs (report design §5.3/§6.3):
8
+ // REPORTS_TOKEN_SECRET when set, else HKDF-derived from JWT_SECRET so the raw
9
+ // JWT secret never signs report tokens directly.
10
+ tokenSecret: createHkdfSync(getEnv().REPORTS_TOKEN_SECRET, getEnv().JWT_SECRET),
11
+ // Reports-owned async-export worker + file storage (report design §9) — no
12
+ // dependency on the forms outbox or UploadedFile machinery.
13
+ exportWorkerEnabled: getEnv().REPORTS_EXPORT_WORKER_ENABLED,
14
+ exportPollMs: getEnv().REPORTS_EXPORT_POLL_MS,
15
+ exportStorageDir: getEnv().REPORTS_EXPORT_STORAGE_DIR,
16
+ exportRetentionDays: getEnv().REPORTS_EXPORT_RETENTION_DAYS,
17
+ exportRetentionSweepMs: getEnv().REPORTS_EXPORT_RETENTION_SWEEP_MS,
18
+ }));
@@ -1,9 +1,10 @@
1
1
  import { ValidationPipe } from '@nestjs/common';
2
2
  import { ConfigService } from '@nestjs/config';
3
3
  import { NestFactory } from '@nestjs/core';
4
- import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
4
+ import { SwaggerModule } from '@nestjs/swagger';
5
5
  import { AppModule } from './app.module';
6
6
  import { HttpExceptionFilter } from './common/filters/http-exception.filter';
7
+ import { buildOpenApiConfig } from './config/openapi';
7
8
 
8
9
  async function bootstrap() {
9
10
  const app = await NestFactory.create(AppModule);
@@ -32,17 +33,7 @@ async function bootstrap() {
32
33
  }),
33
34
  );
34
35
 
35
- const document = SwaggerModule.createDocument(
36
- app,
37
- new DocumentBuilder()
38
- .setTitle('Foundation Starter API')
39
- .setDescription(
40
- 'Modular-monolith starter API with auth, organisations, RBAC, audit, and settings.',
41
- )
42
- .setVersion('0.1.0')
43
- .addBearerAuth()
44
- .build(),
45
- );
36
+ const document = SwaggerModule.createDocument(app, buildOpenApiConfig());
46
37
  SwaggerModule.setup('docs', app, document, {
47
38
  swaggerOptions: {
48
39
  persistAuthorization: true,
@@ -20,6 +20,7 @@ export class PermissionResponseDto {
20
20
  subject!: string;
21
21
 
22
22
  @ApiPropertyOptional({
23
+ type: String,
23
24
  example: 'Read organisation memberships.',
24
25
  nullable: true,
25
26
  })
@@ -57,6 +58,7 @@ export class RoleResponseDto {
57
58
  name!: string;
58
59
 
59
60
  @ApiPropertyOptional({
61
+ type: String,
60
62
  example: 'Can review support-facing records.',
61
63
  nullable: true,
62
64
  })
@@ -83,6 +85,7 @@ export class RoleListResponseDto {
83
85
  items!: RoleResponseDto[];
84
86
 
85
87
  @ApiPropertyOptional({
88
+ type: String,
86
89
  example: 'f602c057-04f4-4ef8-8c84-1b7c62fbf8c5',
87
90
  format: 'uuid',
88
91
  nullable: true,
@@ -26,6 +26,10 @@ export class CurrentAccessControlResponseDto {
26
26
  @ApiProperty({ example: true })
27
27
  isBillingContact!: boolean;
28
28
 
29
- @ApiProperty({ enum: permissionKeys, example: ['organisations.read', 'settings.read'] })
29
+ @ApiProperty({
30
+ enum: permissionKeys,
31
+ isArray: true,
32
+ example: ['organisations.read', 'settings.read'],
33
+ })
30
34
  permissionKeys!: PermissionKey[];
31
35
  }
@@ -17,6 +17,33 @@ export const permissionKeys = [
17
17
  'settings.update',
18
18
  'audit.read',
19
19
  'platform.admin',
20
+ // Form builder (@ftisindia/form-builder). Kept as literals so the as-const
21
+ // tuple stays narrow; test/forms-permission-sync.spec.ts asserts these match
22
+ // the engine's FORM_PERMISSION_KEYS export exactly.
23
+ 'forms.read',
24
+ 'forms.create',
25
+ 'forms.update',
26
+ 'forms.publish',
27
+ 'forms.archive',
28
+ 'forms.wireDangerous',
29
+ 'forms.managePublicAccess',
30
+ 'formSubmissions.read',
31
+ 'formSubmissions.create',
32
+ // Row-level edits delegated from report grids (report design §6.1) — and any
33
+ // other surface that patches submissions through the form engine.
34
+ 'formSubmissions.update',
35
+ 'formSubmissions.export',
36
+ 'formDataSources.manage',
37
+ // Report builder (@ftisindia/report-builder). Kept as literals so the
38
+ // as-const tuple stays narrow; test/reports-permission-sync.spec.ts asserts
39
+ // these match the engine's REPORT_PERMISSION_KEYS export exactly.
40
+ 'reports.read',
41
+ 'reports.create',
42
+ 'reports.update',
43
+ 'reports.publish',
44
+ 'reports.archive',
45
+ 'reports.export',
46
+ 'reportTags.manage',
20
47
  ] as const;
21
48
 
22
49
  export type PermissionKey = (typeof permissionKeys)[number];
@@ -126,4 +126,187 @@ export const routePermissionRegistry: RoutePermissionEntry[] = [
126
126
  path: '/organisations/:orgId/sample/echo',
127
127
  permissions: ['organisations.update'],
128
128
  },
129
+ {
130
+ method: 'GET',
131
+ path: '/organisations/:orgId/forms',
132
+ permissions: ['forms.read'],
133
+ },
134
+ {
135
+ method: 'POST',
136
+ path: '/organisations/:orgId/forms',
137
+ permissions: ['forms.create'],
138
+ },
139
+ {
140
+ method: 'GET',
141
+ path: '/organisations/:orgId/forms/:formKey',
142
+ permissions: ['forms.read'],
143
+ },
144
+ {
145
+ method: 'GET',
146
+ path: '/organisations/:orgId/forms/:formKey/render',
147
+ permissions: ['formSubmissions.create'],
148
+ },
149
+ {
150
+ method: 'GET',
151
+ path: '/organisations/:orgId/forms/:formKey/versions/:version',
152
+ permissions: ['forms.read'],
153
+ },
154
+ {
155
+ method: 'PATCH',
156
+ path: '/organisations/:orgId/forms/:formKey/versions/:version',
157
+ permissions: ['forms.update'],
158
+ },
159
+ {
160
+ method: 'POST',
161
+ path: '/organisations/:orgId/forms/:formKey/versions/:version/publish',
162
+ permissions: ['forms.publish'],
163
+ },
164
+ {
165
+ method: 'POST',
166
+ path: '/organisations/:orgId/forms/:formKey/versions/:version/archive',
167
+ permissions: ['forms.archive'],
168
+ },
169
+ {
170
+ method: 'POST',
171
+ path: '/organisations/:orgId/forms/:formKey/versions/:version/public-access',
172
+ permissions: ['forms.managePublicAccess'],
173
+ },
174
+ {
175
+ method: 'POST',
176
+ path: '/organisations/:orgId/forms/:formKey/submissions',
177
+ permissions: ['formSubmissions.create'],
178
+ },
179
+ {
180
+ method: 'POST',
181
+ path: '/organisations/:orgId/forms/:formKey/submissions/validate',
182
+ permissions: ['formSubmissions.create'],
183
+ },
184
+ {
185
+ method: 'GET',
186
+ path: '/organisations/:orgId/forms/:formKey/submissions',
187
+ permissions: ['formSubmissions.read'],
188
+ },
189
+ {
190
+ method: 'GET',
191
+ path: '/organisations/:orgId/forms/:formKey/submissions/:submissionId',
192
+ permissions: ['formSubmissions.read'],
193
+ },
194
+ {
195
+ method: 'GET',
196
+ path: '/organisations/:orgId/forms/data-sources',
197
+ permissions: ['formDataSources.manage'],
198
+ },
199
+ {
200
+ method: 'GET',
201
+ path: '/organisations/:orgId/forms/:formKey/data-sources/:dataSourceKey/options',
202
+ permissions: ['formSubmissions.create'],
203
+ },
204
+ {
205
+ method: 'POST',
206
+ path: '/organisations/:orgId/forms/:formKey/files',
207
+ permissions: ['formSubmissions.create'],
208
+ },
209
+ {
210
+ method: 'GET',
211
+ path: '/organisations/:orgId/forms/:formKey/files/:fileId',
212
+ permissions: ['formSubmissions.read'],
213
+ },
214
+ {
215
+ method: 'GET',
216
+ path: '/organisations/:orgId/forms/:formKey/submissions/export',
217
+ permissions: ['formSubmissions.export'],
218
+ },
219
+ // Report builder (@ftisindia/report-builder) — report design §10. Row
220
+ // actions carry an additional per-action permission check inside the engine
221
+ // (attach-time and execute-time, §6.1); the route gate below is the base.
222
+ {
223
+ method: 'GET',
224
+ path: '/organisations/:orgId/reports',
225
+ permissions: ['reports.read'],
226
+ },
227
+ {
228
+ method: 'POST',
229
+ path: '/organisations/:orgId/reports',
230
+ permissions: ['reports.create'],
231
+ },
232
+ {
233
+ method: 'GET',
234
+ path: '/organisations/:orgId/reports/exports/:jobId',
235
+ permissions: ['reports.export'],
236
+ },
237
+ {
238
+ method: 'GET',
239
+ path: '/organisations/:orgId/reports/exports/:jobId/download',
240
+ permissions: ['reports.export'],
241
+ },
242
+ {
243
+ method: 'GET',
244
+ path: '/organisations/:orgId/reports/:key',
245
+ permissions: ['reports.read'],
246
+ },
247
+ {
248
+ method: 'PATCH',
249
+ path: '/organisations/:orgId/reports/:key',
250
+ permissions: ['reports.update'],
251
+ },
252
+ {
253
+ method: 'POST',
254
+ path: '/organisations/:orgId/reports/:key/publish',
255
+ permissions: ['reports.publish'],
256
+ },
257
+ {
258
+ method: 'POST',
259
+ path: '/organisations/:orgId/reports/:key/archive',
260
+ permissions: ['reports.archive'],
261
+ },
262
+ {
263
+ method: 'GET',
264
+ path: '/organisations/:orgId/reports/:key/meta',
265
+ permissions: ['reports.read'],
266
+ },
267
+ {
268
+ method: 'POST',
269
+ path: '/organisations/:orgId/reports/:key/query',
270
+ permissions: ['reports.read'],
271
+ },
272
+ {
273
+ method: 'GET',
274
+ path: '/organisations/:orgId/reports/:key/views',
275
+ permissions: ['reports.read'],
276
+ },
277
+ {
278
+ method: 'POST',
279
+ path: '/organisations/:orgId/reports/:key/views',
280
+ permissions: ['reports.read'],
281
+ },
282
+ {
283
+ method: 'POST',
284
+ path: '/organisations/:orgId/reports/:key/views/shared',
285
+ permissions: ['reports.update'],
286
+ },
287
+ {
288
+ method: 'PATCH',
289
+ path: '/organisations/:orgId/reports/:key/views/:viewId',
290
+ permissions: ['reports.read'],
291
+ },
292
+ {
293
+ method: 'DELETE',
294
+ path: '/organisations/:orgId/reports/:key/views/:viewId',
295
+ permissions: ['reports.read'],
296
+ },
297
+ {
298
+ method: 'POST',
299
+ path: '/organisations/:orgId/reports/:key/actions/:name/prepare',
300
+ permissions: ['reports.read'],
301
+ },
302
+ {
303
+ method: 'POST',
304
+ path: '/organisations/:orgId/reports/:key/actions/:name',
305
+ permissions: ['reports.read'],
306
+ },
307
+ {
308
+ method: 'POST',
309
+ path: '/organisations/:orgId/reports/:key/export',
310
+ permissions: ['reports.export'],
311
+ },
129
312
  ];