@ftisindia/create-app 0.1.4 → 0.1.6

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 (169) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +31 -0
  3. package/template/README.md +61 -0
  4. package/template/_gitignore +6 -0
  5. package/template/_package.json +6 -0
  6. package/template/docs/FORMS.md +169 -0
  7. package/template/docs/FORMS_CHECKLIST.md +61 -0
  8. package/template/docs/REPORTS.md +246 -0
  9. package/template/docs/REPORTS_CHECKLIST.md +97 -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/schema.prisma +285 -0
  13. package/template/scripts/export-openapi.ts +85 -0
  14. package/template/scripts/gen-form.mjs +149 -0
  15. package/template/scripts/push-form.ts +124 -0
  16. package/template/src/app.module.ts +29 -8
  17. package/template/src/common/dto/membership-response.dto.ts +1 -0
  18. package/template/src/common/dto/role-summary.dto.ts +3 -3
  19. package/template/src/common/dto/user-summary.dto.ts +3 -3
  20. package/template/src/config/app.config.ts +6 -1
  21. package/template/src/config/env.validation.ts +45 -0
  22. package/template/src/config/forms.config.ts +12 -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 +16 -0
  27. package/template/src/main.ts +16 -12
  28. package/template/src/modules/access-control/access-control.module.ts +2 -1
  29. package/template/src/modules/access-control/dto/access-control-response.dto.ts +3 -0
  30. package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +35 -0
  31. package/template/src/modules/access-control/presentation/current-access-control.controller.ts +40 -0
  32. package/template/src/modules/access-control/types/permission-key.ts +27 -0
  33. package/template/src/modules/access-control/types/route-permission-registry.ts +183 -0
  34. package/template/src/modules/audit/dto/audit-response.dto.ts +7 -3
  35. package/template/src/modules/auth/auth.module.ts +3 -1
  36. package/template/src/modules/auth/dto/auth-response.dto.ts +1 -1
  37. package/template/src/modules/forms/application/services/file-gc.service.ts +85 -0
  38. package/template/src/modules/forms/application/services/forms-definitions.service.ts +137 -0
  39. package/template/src/modules/forms/application/services/forms-error.mapper.ts +64 -0
  40. package/template/src/modules/forms/application/services/forms-export.service.ts +210 -0
  41. package/template/src/modules/forms/application/services/forms-files.service.ts +164 -0
  42. package/template/src/modules/forms/application/services/forms-public.service.ts +49 -0
  43. package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +53 -0
  44. package/template/src/modules/forms/application/services/forms-submissions.service.ts +103 -0
  45. package/template/src/modules/forms/application/services/handlers/authenticate.action.ts +37 -0
  46. package/template/src/modules/forms/application/services/handlers/logging-email.handler.ts +22 -0
  47. package/template/src/modules/forms/application/services/handlers/send-confirmation-email.action.ts +40 -0
  48. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +41 -0
  49. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +109 -0
  50. package/template/src/modules/forms/dto/create-form-definition.dto.ts +12 -0
  51. package/template/src/modules/forms/dto/data-source-response.dto.ts +19 -0
  52. package/template/src/modules/forms/dto/export-submissions-query.dto.ts +33 -0
  53. package/template/src/modules/forms/dto/file-upload-response.dto.ts +24 -0
  54. package/template/src/modules/forms/dto/form-definition-response.dto.ts +50 -0
  55. package/template/src/modules/forms/dto/form-render-response.dto.ts +17 -0
  56. package/template/src/modules/forms/dto/list-form-definitions-query.dto.ts +10 -0
  57. package/template/src/modules/forms/dto/list-submissions-query.dto.ts +10 -0
  58. package/template/src/modules/forms/dto/public-submit-form.dto.ts +24 -0
  59. package/template/src/modules/forms/dto/set-public-access.dto.ts +8 -0
  60. package/template/src/modules/forms/dto/submission-response.dto.ts +99 -0
  61. package/template/src/modules/forms/dto/submit-form.dto.ts +50 -0
  62. package/template/src/modules/forms/dto/update-form-definition.dto.ts +12 -0
  63. package/template/src/modules/forms/dto/upload-file-query.dto.ts +33 -0
  64. package/template/src/modules/forms/dto/validate-submission.dto.ts +22 -0
  65. package/template/src/modules/forms/examples/abstract-submission.form.json +80 -0
  66. package/template/src/modules/forms/examples/login.form.json +24 -0
  67. package/template/src/modules/forms/examples/registration.form.json +44 -0
  68. package/template/src/modules/forms/forms.module.ts +226 -0
  69. package/template/src/modules/forms/forms.tokens.ts +6 -0
  70. package/template/src/modules/forms/infrastructure/audit-sink.adapter.ts +30 -0
  71. package/template/src/modules/forms/infrastructure/casl-forms-authorization.ts +31 -0
  72. package/template/src/modules/forms/infrastructure/prisma-tx-runner.ts +17 -0
  73. package/template/src/modules/forms/infrastructure/registry/form-extension.decorators.ts +17 -0
  74. package/template/src/modules/forms/infrastructure/registry/registry-bootstrap.service.ts +82 -0
  75. package/template/src/modules/forms/infrastructure/request-forms-context.ts +60 -0
  76. package/template/src/modules/forms/infrastructure/schema-check/forms-schema-check.service.ts +76 -0
  77. package/template/src/modules/forms/infrastructure/storage/local-disk-storage.adapter.ts +43 -0
  78. package/template/src/modules/forms/infrastructure/stores/index.ts +5 -0
  79. package/template/src/modules/forms/infrastructure/stores/prisma-action-log.store.ts +37 -0
  80. package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +108 -0
  81. package/template/src/modules/forms/infrastructure/stores/prisma-form-definition.store.ts +147 -0
  82. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +133 -0
  83. package/template/src/modules/forms/infrastructure/stores/prisma-submission.store.ts +164 -0
  84. package/template/src/modules/forms/presentation/forms-data-sources.controller.ts +58 -0
  85. package/template/src/modules/forms/presentation/forms-definitions.controller.ts +191 -0
  86. package/template/src/modules/forms/presentation/forms-files.controller.ts +79 -0
  87. package/template/src/modules/forms/presentation/forms-submissions.controller.ts +154 -0
  88. package/template/src/modules/forms/presentation/forms-upload.interceptor.ts +33 -0
  89. package/template/src/modules/forms/presentation/public-forms.controller.ts +51 -0
  90. package/template/src/modules/invitations/dto/invitation-response.dto.ts +4 -0
  91. package/template/src/modules/organisations/application/services/organisations.service.ts +67 -1
  92. package/template/src/modules/organisations/dto/organisation-response.dto.ts +52 -0
  93. package/template/src/modules/organisations/presentation/organisations.controller.ts +25 -3
  94. package/template/src/modules/reports/application/services/reports-actions.service.ts +54 -0
  95. package/template/src/modules/reports/application/services/reports-definitions.service.ts +66 -0
  96. package/template/src/modules/reports/application/services/reports-error.mapper.ts +97 -0
  97. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +124 -0
  98. package/template/src/modules/reports/application/services/reports-exports.service.ts +74 -0
  99. package/template/src/modules/reports/application/services/reports-queries.service.ts +35 -0
  100. package/template/src/modules/reports/application/services/reports-settings-reader.service.ts +49 -0
  101. package/template/src/modules/reports/application/services/reports-views.service.ts +79 -0
  102. package/template/src/modules/reports/dto/action-result-response.dto.ts +21 -0
  103. package/template/src/modules/reports/dto/create-report-definition.dto.ts +86 -0
  104. package/template/src/modules/reports/dto/create-saved-view.dto.ts +26 -0
  105. package/template/src/modules/reports/dto/execute-action.dto.ts +71 -0
  106. package/template/src/modules/reports/dto/export-job-response.dto.ts +60 -0
  107. package/template/src/modules/reports/dto/export-request.dto.ts +34 -0
  108. package/template/src/modules/reports/dto/list-reports-query.dto.ts +10 -0
  109. package/template/src/modules/reports/dto/list-views-query.dto.ts +17 -0
  110. package/template/src/modules/reports/dto/prepare-action-response.dto.ts +14 -0
  111. package/template/src/modules/reports/dto/prepare-action.dto.ts +27 -0
  112. package/template/src/modules/reports/dto/query-response.dto.ts +64 -0
  113. package/template/src/modules/reports/dto/query-spec.dto.ts +120 -0
  114. package/template/src/modules/reports/dto/report-definition-response.dto.ts +64 -0
  115. package/template/src/modules/reports/dto/report-meta-query.dto.ts +16 -0
  116. package/template/src/modules/reports/dto/report-meta-response.dto.ts +113 -0
  117. package/template/src/modules/reports/dto/saved-view-response.dto.ts +66 -0
  118. package/template/src/modules/reports/dto/update-report-definition.dto.ts +9 -0
  119. package/template/src/modules/reports/dto/update-saved-view.dto.ts +27 -0
  120. package/template/src/modules/reports/examples/abstract-review-board.report.json +54 -0
  121. package/template/src/modules/reports/examples/org-members.report.json +55 -0
  122. package/template/src/modules/reports/infrastructure/audit-sink.adapter.ts +31 -0
  123. package/template/src/modules/reports/infrastructure/casl-reports-authorization.ts +39 -0
  124. package/template/src/modules/reports/infrastructure/forms-adapter/form-report-source.adapter.ts +292 -0
  125. package/template/src/modules/reports/infrastructure/forms-adapter/form-row-actions.ts +171 -0
  126. package/template/src/modules/reports/infrastructure/forms-adapter/forms-bridge-bootstrap.service.ts +32 -0
  127. package/template/src/modules/reports/infrastructure/prisma-catalog.adapter.ts +95 -0
  128. package/template/src/modules/reports/infrastructure/prisma-query-executor.ts +103 -0
  129. package/template/src/modules/reports/infrastructure/prisma-snapshot-runner.ts +47 -0
  130. package/template/src/modules/reports/infrastructure/prisma-tx-runner.ts +18 -0
  131. package/template/src/modules/reports/infrastructure/registry/registry-bootstrap.service.ts +61 -0
  132. package/template/src/modules/reports/infrastructure/registry/report-extension.decorators.ts +14 -0
  133. package/template/src/modules/reports/infrastructure/reports-job-queue.adapter.ts +28 -0
  134. package/template/src/modules/reports/infrastructure/request-reports-context.ts +42 -0
  135. package/template/src/modules/reports/infrastructure/schema-check/reports-schema-check.service.ts +116 -0
  136. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +79 -0
  137. package/template/src/modules/reports/infrastructure/stores/index.ts +5 -0
  138. package/template/src/modules/reports/infrastructure/stores/prisma-bulk-action-run.store.ts +89 -0
  139. package/template/src/modules/reports/infrastructure/stores/prisma-export-job.store.ts +93 -0
  140. package/template/src/modules/reports/infrastructure/stores/prisma-report-definition.store.ts +171 -0
  141. package/template/src/modules/reports/infrastructure/stores/prisma-row-tag.store.ts +110 -0
  142. package/template/src/modules/reports/infrastructure/stores/prisma-saved-view.store.ts +144 -0
  143. package/template/src/modules/reports/presentation/reports-actions.controller.ts +83 -0
  144. package/template/src/modules/reports/presentation/reports-definitions.controller.ts +156 -0
  145. package/template/src/modules/reports/presentation/reports-export-jobs.controller.ts +61 -0
  146. package/template/src/modules/reports/presentation/reports-export.controller.ts +76 -0
  147. package/template/src/modules/reports/presentation/reports-query.controller.ts +52 -0
  148. package/template/src/modules/reports/presentation/reports-views.controller.ts +140 -0
  149. package/template/src/modules/reports/reports-forms.module.ts +33 -0
  150. package/template/src/modules/reports/reports.module.ts +335 -0
  151. package/template/src/modules/reports/reports.tokens.ts +11 -0
  152. package/template/src/modules/reports/sources/org-members.source.ts +112 -0
  153. package/template/src/modules/settings/types/setting-definitions.ts +94 -0
  154. package/template/test/forms-definitions.e2e-spec.ts +394 -0
  155. package/template/test/forms-export.e2e-spec.ts +390 -0
  156. package/template/test/forms-files.e2e-spec.ts +345 -0
  157. package/template/test/forms-outbox.e2e-spec.ts +309 -0
  158. package/template/test/forms-permission-sync.spec.ts +27 -0
  159. package/template/test/forms-public.e2e-spec.ts +269 -0
  160. package/template/test/forms-schema-check.e2e-spec.ts +65 -0
  161. package/template/test/forms-submissions.e2e-spec.ts +500 -0
  162. package/template/test/forms-webhooks.e2e-spec.ts +261 -0
  163. package/template/test/frontend-bootstrap.spec.ts +181 -0
  164. package/template/test/reports-advanced.e2e-spec.ts +368 -0
  165. package/template/test/reports-permission-sync.spec.ts +30 -0
  166. package/template/test/reports-query.e2e-spec.ts +350 -0
  167. package/template/test/reports-tiers.e2e-spec.ts +257 -0
  168. package/template/test/route-registry.validator.spec.ts +34 -0
  169. package/template/test/security.e2e-spec.ts +134 -2
@@ -0,0 +1,85 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { writeFile } from 'fs/promises';
3
+ import { resolve } from 'path';
4
+
5
+ loadEnvFile();
6
+ ensurePlaceholderEnv();
7
+
8
+ main().catch((error: unknown) => {
9
+ console.error(error);
10
+ process.exit(1);
11
+ });
12
+
13
+ async function main() {
14
+ // Imported after env preparation so config validation sees the placeholders.
15
+ const { NestFactory } = await import('@nestjs/core');
16
+ const { SwaggerModule } = await import('@nestjs/swagger');
17
+ const { AppModule } = await import('../src/app.module');
18
+ const { buildOpenApiConfig } = await import('../src/config/openapi');
19
+
20
+ const app = await NestFactory.create(AppModule, {
21
+ logger: false,
22
+ abortOnError: false,
23
+ });
24
+
25
+ try {
26
+ const document = SwaggerModule.createDocument(app, buildOpenApiConfig());
27
+ const outputPath = resolve(process.cwd(), 'docs-json.json');
28
+ await writeFile(outputPath, `${JSON.stringify(document, null, 2)}\n`);
29
+ console.log(`OpenAPI document written to ${outputPath}`);
30
+ } finally {
31
+ await app.close().catch(() => undefined);
32
+ }
33
+ }
34
+
35
+ // The document is built from route metadata only; no server starts and no
36
+ // database connection is made, so missing secrets get safe placeholders.
37
+ function ensurePlaceholderEnv() {
38
+ if (!process.env.DATABASE_URL) {
39
+ process.env.DATABASE_URL =
40
+ 'postgresql://placeholder:placeholder@localhost:5432/placeholder';
41
+ }
42
+
43
+ if (!process.env.JWT_SECRET) {
44
+ process.env.JWT_SECRET = 'openapi-export-placeholder-secret-0123456789';
45
+ }
46
+ }
47
+
48
+ function loadEnvFile() {
49
+ const envPath = resolve(process.cwd(), '.env');
50
+ if (!existsSync(envPath)) {
51
+ return;
52
+ }
53
+
54
+ const lines = readFileSync(envPath, 'utf8').split(/\r?\n/);
55
+ for (const line of lines) {
56
+ const trimmed = line.trim();
57
+ if (!trimmed || trimmed.startsWith('#')) {
58
+ continue;
59
+ }
60
+
61
+ const equalsIndex = trimmed.indexOf('=');
62
+ if (equalsIndex === -1) {
63
+ continue;
64
+ }
65
+
66
+ const key = trimmed.slice(0, equalsIndex).trim();
67
+ const rawValue = trimmed.slice(equalsIndex + 1).trim();
68
+ if (!key || process.env[key] !== undefined) {
69
+ continue;
70
+ }
71
+
72
+ process.env[key] = stripQuotes(rawValue);
73
+ }
74
+ }
75
+
76
+ function stripQuotes(value: string) {
77
+ if (
78
+ (value.startsWith('"') && value.endsWith('"')) ||
79
+ (value.startsWith("'") && value.endsWith("'"))
80
+ ) {
81
+ return value.slice(1, -1);
82
+ }
83
+
84
+ return value;
85
+ }
@@ -0,0 +1,149 @@
1
+ // Scaffold a form definition + a DB-free lint spec (definitions-as-code).
2
+ //
3
+ // npm run gen:form -- customer-feedback
4
+ // npm run gen:form -- customer-feedback --title "Customer feedback"
5
+ //
6
+ // Generates:
7
+ // src/modules/forms/definitions/<key>.form.json (starter definition)
8
+ // test/<key>.form.spec.ts (meta-schema + publish lint, runs in `npm test`)
9
+ //
10
+ // Load it into an organisation with:
11
+ // npm run forms:push -- --file src/modules/forms/definitions/<key>.form.json --org <org> --user <email> --publish
12
+
13
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
14
+ import { dirname, join } from 'path';
15
+
16
+ const args = process.argv.slice(2);
17
+ const key = args[0];
18
+ if (!key || key.startsWith('--')) {
19
+ throw new Error('Usage: npm run gen:form -- <form-key> [--title "Display title"]');
20
+ }
21
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(key) || key.length > 100) {
22
+ throw new Error(
23
+ `Form key "${key}" is invalid. Use lowercase letters, digits, and hyphens (must start with a letter or digit).`,
24
+ );
25
+ }
26
+
27
+ const titleFlag = args.indexOf('--title');
28
+ const title =
29
+ titleFlag !== -1 && args[titleFlag + 1]
30
+ ? args[titleFlag + 1]
31
+ : key
32
+ .split('-')
33
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
34
+ .join(' ');
35
+
36
+ const definitionPath = join('src', 'modules', 'forms', 'definitions', `${key}.form.json`);
37
+ const specPath = join('test', `${key}.form.spec.ts`);
38
+
39
+ for (const path of [definitionPath, specPath]) {
40
+ if (existsSync(path)) {
41
+ throw new Error(`Refusing to overwrite existing file: ${path}`);
42
+ }
43
+ }
44
+
45
+ const definition = {
46
+ key,
47
+ version: 1,
48
+ title,
49
+ fields: [
50
+ {
51
+ type: 'text',
52
+ name: 'title',
53
+ label: 'Title',
54
+ validators: { required: true, maxLength: 200 },
55
+ reportable: true,
56
+ },
57
+ {
58
+ type: 'text',
59
+ name: 'notes',
60
+ label: 'Notes',
61
+ validators: { maxLength: 2000 },
62
+ },
63
+ ],
64
+ actions: {
65
+ submit: ['validateAll', 'persist'],
66
+ saveDraft: ['persistDraft'],
67
+ },
68
+ };
69
+
70
+ const spec = `import { readFileSync } from 'node:fs';
71
+ import { join } from 'node:path';
72
+ import {
73
+ ActionRegistry,
74
+ DEFAULT_ORG_FORMS_POLICY,
75
+ DEFAULT_RULE_LIMITS,
76
+ DataSourceRegistry,
77
+ FieldTypeRegistry,
78
+ lintDefinition,
79
+ lintRules,
80
+ registerBuiltinActions,
81
+ registerCoreFieldTypes,
82
+ validateDefinitionShape,
83
+ } from '@ftisindia/form-builder';
84
+ import type { FormDefinition, SubmissionStore } from '@ftisindia/form-builder';
85
+
86
+ /**
87
+ * DB-free definition lint for ${key}.form.json — fails the unit-test run the
88
+ * moment the definition would be rejected at save/publish time. If your
89
+ * definition references custom actions or data sources, register matching
90
+ * stubs below (same names/kinds as the real providers).
91
+ */
92
+ const definition = JSON.parse(
93
+ readFileSync(
94
+ join(__dirname, '..', 'src', 'modules', 'forms', 'definitions', '${key}.form.json'),
95
+ 'utf8',
96
+ ),
97
+ ) as FormDefinition;
98
+
99
+ describe('${key} form definition', () => {
100
+ const fieldTypes = new FieldTypeRegistry();
101
+ registerCoreFieldTypes(fieldTypes);
102
+
103
+ const actions = new ActionRegistry();
104
+ registerBuiltinActions(actions, {
105
+ validate: () => ({ valid: true, errors: [] }),
106
+ submissions: {} as SubmissionStore,
107
+ });
108
+ actions.register({ name: 'sendConfirmationEmail', kind: 'post-commit', execute: async () => ({}) });
109
+ actions.register({
110
+ name: 'authenticate',
111
+ kind: 'transactional',
112
+ dangerous: true,
113
+ execute: async () => ({}),
114
+ });
115
+
116
+ it('passes the engine meta-schema', () => {
117
+ expect(validateDefinitionShape(definition)).toEqual([]);
118
+ });
119
+
120
+ it('passes publish-stage linting', () => {
121
+ expect(
122
+ lintDefinition(definition, {
123
+ fieldTypes,
124
+ actions,
125
+ dataSources: new DataSourceRegistry(),
126
+ policy: { ...DEFAULT_ORG_FORMS_POLICY, allowedDangerousActions: ['authenticate'] },
127
+ ruleLimits: DEFAULT_RULE_LIMITS,
128
+ lintRules,
129
+ stage: 'publish',
130
+ canWireDangerous: true,
131
+ }),
132
+ ).toEqual([]);
133
+ });
134
+ });
135
+ `;
136
+
137
+ mkdirSync(dirname(definitionPath), { recursive: true });
138
+ writeFileSync(definitionPath, `${JSON.stringify(definition, null, 2)}\n`);
139
+ writeFileSync(specPath, spec);
140
+
141
+ console.log(`Created ${definitionPath}`);
142
+ console.log(`Created ${specPath}`);
143
+ console.log('');
144
+ console.log('Next steps:');
145
+ console.log(' 1. Edit the definition (fields, rules, actions, settings).');
146
+ console.log(' 2. npm test # lint spec runs without a database');
147
+ console.log(
148
+ ` 3. npm run forms:push -- --file ${definitionPath.replaceAll('\\', '/')} --org <org-id-or-slug> --user <owner-email> --publish`,
149
+ );
@@ -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,20 @@ 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
+ // e2e suites create many users/submissions in seconds; rate limiting is
46
+ // not what they test. Production/dev behavior is unchanged.
47
+ skipIf: () => process.env.NODE_ENV === 'test',
48
+ }),
33
49
  PrismaModule,
34
50
  HealthModule,
35
51
  AuthModule,
@@ -40,6 +56,11 @@ import { SettingsModule } from './modules/settings/settings.module';
40
56
  MembershipsModule,
41
57
  InvitationsModule,
42
58
  SettingsModule,
59
+ FormsModule,
60
+ ReportsModule,
61
+ // Optional bridge: adds form-backed reports + delegated grid verbs. Remove
62
+ // this line and ReportsModule keeps working over custom sources alone.
63
+ ReportsFormsModule,
43
64
  SampleModule,
44
65
  ],
45
66
  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
 
@@ -1,7 +1,12 @@
1
1
  import { registerAs } from '@nestjs/config';
2
- import { getEnv } from './env.validation';
2
+ import { getEnv, parseCorsOrigins } from './env.validation';
3
3
 
4
4
  export default registerAs('app', () => ({
5
5
  nodeEnv: getEnv().NODE_ENV,
6
6
  port: getEnv().PORT,
7
+ cors: {
8
+ enabled: getEnv().CORS_ENABLED,
9
+ origins: parseCorsOrigins(getEnv().CORS_ORIGINS),
10
+ credentials: getEnv().CORS_CREDENTIALS,
11
+ },
7
12
  }));
@@ -61,10 +61,48 @@ export const envSchema = z
61
61
  AUTH_FACEBOOK_ENABLED: booleanFromEnv.default(false),
62
62
  AUTH_MOBILE_OTP_ENABLED: booleanFromEnv.default(false),
63
63
  AUTH_MAGIC_LINK_ENABLED: booleanFromEnv.default(false),
64
+ CORS_ENABLED: booleanFromEnv.default(false),
65
+ CORS_ORIGINS: z.string().trim().optional().default(''),
66
+ CORS_CREDENTIALS: booleanFromEnv.default(false),
64
67
  RBAC_CACHE_TTL_SECONDS: z.coerce.number().int().positive().default(60),
65
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_FILE_GC_INTERVAL_MS: z.coerce.number().int().positive().default(3_600_000),
72
+ FORMS_FILE_TEMP_TTL_HOURS: z.coerce.number().int().positive().default(24),
73
+ FORMS_MAX_UPLOAD_MB: z.coerce.number().int().positive().default(25),
74
+ FORMS_FILE_STORAGE_DIR: z.string().trim().min(1).default('./var/uploads'),
75
+ FORMS_SCHEMA_CHECK: z.enum(['on', 'off']).default('on'),
76
+ REPORTS_SCHEMA_CHECK: z.enum(['on', 'off']).default('on'),
77
+ // HMAC secret for report cursors and bulk-action tokens (report design
78
+ // §5.3/§6.3). Optional: when empty, a key is derived from JWT_SECRET via
79
+ // HKDF so existing apps need no new mandatory env. Rotating it invalidates
80
+ // outstanding cursors/tokens — clients restart from page one by design.
81
+ REPORTS_TOKEN_SECRET: z.string().trim().optional().default(''),
82
+ // Reports-owned async-export worker + file storage (report design §9).
83
+ REPORTS_EXPORT_WORKER_ENABLED: booleanFromEnv.default(true),
84
+ REPORTS_EXPORT_POLL_MS: z.coerce.number().int().positive().default(5000),
85
+ REPORTS_EXPORT_STORAGE_DIR: z.string().trim().min(1).default('./var/report-exports'),
66
86
  })
67
87
  .superRefine((env, ctx) => {
88
+ if (env.REPORTS_TOKEN_SECRET.length > 0 && env.REPORTS_TOKEN_SECRET.length < 32) {
89
+ ctx.addIssue({
90
+ code: 'custom',
91
+ path: ['REPORTS_TOKEN_SECRET'],
92
+ message: 'REPORTS_TOKEN_SECRET must be at least 32 characters when set',
93
+ });
94
+ }
95
+
96
+ const corsOrigins = parseCorsOrigins(env.CORS_ORIGINS);
97
+
98
+ if (env.CORS_CREDENTIALS && corsOrigins.includes('*')) {
99
+ ctx.addIssue({
100
+ code: 'custom',
101
+ path: ['CORS_ORIGINS'],
102
+ message: 'CORS_ORIGINS cannot include * when CORS_CREDENTIALS=true',
103
+ });
104
+ }
105
+
68
106
  if (!env.AUTH_GOOGLE_ENABLED) {
69
107
  return;
70
108
  }
@@ -136,6 +174,13 @@ export function getEnv(): Env {
136
174
  return validatedEnv;
137
175
  }
138
176
 
177
+ export function parseCorsOrigins(value: string) {
178
+ return value
179
+ .split(',')
180
+ .map((origin) => origin.trim())
181
+ .filter(Boolean);
182
+ }
183
+
139
184
  function requireHttpsInProduction(ctx: z.RefinementCtx, key: string, value: string) {
140
185
  if (new URL(value).protocol === 'https:') {
141
186
  return;
@@ -0,0 +1,12 @@
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
+ fileGcIntervalMs: getEnv().FORMS_FILE_GC_INTERVAL_MS,
8
+ fileTempTtlHours: getEnv().FORMS_FILE_TEMP_TTL_HOURS,
9
+ maxUploadMb: getEnv().FORMS_MAX_UPLOAD_MB,
10
+ storageDir: getEnv().FORMS_FILE_STORAGE_DIR,
11
+ schemaCheck: getEnv().FORMS_SCHEMA_CHECK,
12
+ }));
@@ -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,16 @@
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
+ }));
@@ -1,13 +1,27 @@
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);
10
11
  const config = app.get(ConfigService);
12
+ const cors = config.get<{
13
+ enabled: boolean;
14
+ origins: string[];
15
+ credentials: boolean;
16
+ }>('app.cors');
17
+
18
+ if (cors?.enabled) {
19
+ app.enableCors({
20
+ origin: cors.origins,
21
+ credentials: cors.credentials,
22
+ });
23
+ }
24
+
11
25
  app.enableShutdownHooks();
12
26
  app.useGlobalFilters(new HttpExceptionFilter());
13
27
 
@@ -19,17 +33,7 @@ async function bootstrap() {
19
33
  }),
20
34
  );
21
35
 
22
- const document = SwaggerModule.createDocument(
23
- app,
24
- new DocumentBuilder()
25
- .setTitle('Foundation Starter API')
26
- .setDescription(
27
- 'Modular-monolith starter API with auth, organisations, RBAC, audit, and settings.',
28
- )
29
- .setVersion('0.1.0')
30
- .addBearerAuth()
31
- .build(),
32
- );
36
+ const document = SwaggerModule.createDocument(app, buildOpenApiConfig());
33
37
  SwaggerModule.setup('docs', app, document, {
34
38
  swaggerOptions: {
35
39
  persistAuthorization: true,
@@ -7,11 +7,12 @@ import { AccessControlService } from './application/services/access-control.serv
7
7
  import { PermissionGuard } from './application/services/permission.guard';
8
8
  import { RbacCacheService } from './application/services/rbac-cache.service';
9
9
  import { AccessControlController } from './presentation/access-control.controller';
10
+ import { CurrentAccessControlController } from './presentation/current-access-control.controller';
10
11
 
11
12
  @Global()
12
13
  @Module({
13
14
  imports: [AuthModule, DiscoveryModule],
14
- controllers: [AccessControlController],
15
+ controllers: [AccessControlController, CurrentAccessControlController],
15
16
  providers: [
16
17
  AbilityFactory,
17
18
  AccessControlService,