@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,163 @@
1
+ import { INestApplication, ValidationPipe } from '@nestjs/common';
2
+ import { Test } from '@nestjs/testing';
3
+ import type { CaptchaVerifier } from '@ftisindia/form-builder';
4
+ import request from 'supertest';
5
+ import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
6
+ import { FORMS_CAPTCHA_VERIFIER } from '../src/modules/forms/forms.tokens';
7
+
8
+ describe('Forms captcha seam (e2e)', () => {
9
+ let app: INestApplication;
10
+ let owner: { accessToken: string; orgId: string };
11
+ let formKey: string;
12
+ const seenTokens: string[] = [];
13
+
14
+ beforeAll(async () => {
15
+ process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
16
+ process.env.JWT_SECRET ||= 'test-jwt-secret-at-least-16-chars';
17
+ process.env.AUTH_GOOGLE_ENABLED ||= 'false';
18
+ process.env.AUTH_EMAIL_PASSWORD_ENABLED ||= 'true';
19
+ process.env.NODE_ENV = 'test';
20
+ process.env.FORMS_OUTBOX_ENABLED = 'false';
21
+
22
+ const captcha: CaptchaVerifier = {
23
+ verify: (token) => {
24
+ seenTokens.push(token);
25
+ return Promise.resolve(token === 'valid-token');
26
+ },
27
+ };
28
+
29
+ const { AppModule } = await import('../src/app.module');
30
+ const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
31
+ .overrideProvider(FORMS_CAPTCHA_VERIFIER)
32
+ .useValue(captcha)
33
+ .compile();
34
+
35
+ app = moduleRef.createNestApplication();
36
+ app.useGlobalFilters(new HttpExceptionFilter());
37
+ app.useGlobalPipes(
38
+ new ValidationPipe({
39
+ forbidNonWhitelisted: true,
40
+ transform: true,
41
+ whitelist: true,
42
+ }),
43
+ );
44
+ await app.init();
45
+
46
+ owner = await createUserAndOrg('captcha-owner');
47
+ formKey = await publishDefinition(publicCaptchaDefinition(uniqueFormKey('captcha')));
48
+ });
49
+
50
+ afterAll(async () => {
51
+ await app.close();
52
+ });
53
+
54
+ it('rejects missing or invalid tokens and accepts a valid captcha token', async () => {
55
+ const payload = {
56
+ data: {
57
+ fullName: 'Captcha User',
58
+ email: 'captcha@example.com',
59
+ ticketType: 'standard',
60
+ },
61
+ };
62
+
63
+ await request(app.getHttpServer())
64
+ .post(`/public/organisations/${owner.orgId}/forms/${formKey}/submissions`)
65
+ .send(payload)
66
+ .expect(422);
67
+
68
+ await request(app.getHttpServer())
69
+ .post(`/public/organisations/${owner.orgId}/forms/${formKey}/submissions`)
70
+ .send({ ...payload, captchaToken: 'invalid-token' })
71
+ .expect(422);
72
+
73
+ const submitted = await request(app.getHttpServer())
74
+ .post(`/public/organisations/${owner.orgId}/forms/${formKey}/submissions`)
75
+ .send({ ...payload, captchaToken: 'valid-token' })
76
+ .expect(200);
77
+
78
+ expect(submitted.body.submissionId).toBeTruthy();
79
+ expect(seenTokens).toEqual(['invalid-token', 'valid-token']);
80
+ });
81
+
82
+ function publicCaptchaDefinition(key: string): Record<string, unknown> {
83
+ return {
84
+ key,
85
+ version: 1,
86
+ title: 'Captcha public form',
87
+ settings: { access: 'public', captcha: true },
88
+ fields: [
89
+ {
90
+ type: 'text',
91
+ name: 'fullName',
92
+ validators: { required: true, maxLength: 120 },
93
+ },
94
+ {
95
+ type: 'email',
96
+ name: 'email',
97
+ validators: { required: true },
98
+ },
99
+ {
100
+ type: 'select',
101
+ name: 'ticketType',
102
+ validators: { required: true, options: ['standard', 'student', 'vip'] },
103
+ },
104
+ ],
105
+ actions: {
106
+ submit: ['validateAll', 'persist'],
107
+ saveDraft: ['persistDraft'],
108
+ },
109
+ };
110
+ }
111
+
112
+ async function publishDefinition(definition: Record<string, unknown>): Promise<string> {
113
+ const created = await request(app.getHttpServer())
114
+ .post(`/organisations/${owner.orgId}/forms`)
115
+ .set('Authorization', `Bearer ${owner.accessToken}`)
116
+ .send({ definition })
117
+ .expect(201);
118
+
119
+ await request(app.getHttpServer())
120
+ .post(
121
+ `/organisations/${owner.orgId}/forms/${definition.key as string}/versions/${created.body.version as number}/publish`,
122
+ )
123
+ .set('Authorization', `Bearer ${owner.accessToken}`)
124
+ .expect(200);
125
+
126
+ return definition.key as string;
127
+ }
128
+
129
+ async function createUserAndOrg(label: string) {
130
+ const suffix = uniqueSuffix();
131
+ const signup = await request(app.getHttpServer())
132
+ .post('/auth/signup')
133
+ .send({
134
+ email: `${label}-${suffix}@example.com`,
135
+ password: 'test-password-123',
136
+ displayName: label,
137
+ })
138
+ .expect(201);
139
+
140
+ const accessToken = signup.body.accessToken as string;
141
+ const org = await request(app.getHttpServer())
142
+ .post('/organisations')
143
+ .set('Authorization', `Bearer ${accessToken}`)
144
+ .send({
145
+ name: `${label} ${suffix}`,
146
+ slug: `${label}-${suffix}`,
147
+ })
148
+ .expect(201);
149
+
150
+ return {
151
+ accessToken,
152
+ orgId: org.body.organisation.id as string,
153
+ };
154
+ }
155
+
156
+ function uniqueSuffix() {
157
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
158
+ }
159
+
160
+ function uniqueFormKey(label: string) {
161
+ return `${label}-${uniqueSuffix()}`.toLowerCase();
162
+ }
163
+ });
@@ -0,0 +1,394 @@
1
+ import { INestApplication, ValidationPipe } from '@nestjs/common';
2
+ import { Test } from '@nestjs/testing';
3
+ import request from 'supertest';
4
+ import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
5
+ import { PrismaService } from '../src/database/prisma/prisma.service';
6
+
7
+ describe('Forms definitions lifecycle (e2e)', () => {
8
+ let app: INestApplication;
9
+ let prisma: PrismaService;
10
+
11
+ beforeAll(async () => {
12
+ process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
13
+ process.env.JWT_SECRET ||= 'test-jwt-secret-at-least-16-chars';
14
+ process.env.AUTH_GOOGLE_ENABLED ||= 'false';
15
+ process.env.AUTH_EMAIL_PASSWORD_ENABLED ||= 'true';
16
+ process.env.NODE_ENV = 'test';
17
+ process.env.FORMS_OUTBOX_ENABLED = 'false';
18
+ process.env.FORMS_FILE_STORAGE_DIR = './var/test-uploads';
19
+
20
+ const { AppModule } = await import('../src/app.module');
21
+ const moduleRef = await Test.createTestingModule({
22
+ imports: [AppModule],
23
+ }).compile();
24
+
25
+ app = moduleRef.createNestApplication();
26
+ app.useGlobalFilters(new HttpExceptionFilter());
27
+ app.useGlobalPipes(
28
+ new ValidationPipe({
29
+ forbidNonWhitelisted: true,
30
+ transform: true,
31
+ whitelist: true,
32
+ }),
33
+ );
34
+ await app.init();
35
+ prisma = app.get(PrismaService);
36
+ });
37
+
38
+ afterAll(async () => {
39
+ await app.close();
40
+ });
41
+
42
+ it('creates drafts with incrementing versions for the same key', async () => {
43
+ const { accessToken, orgId } = await createUserAndOrg('def-draft');
44
+ const key = uniqueFormKey('draft');
45
+
46
+ const first = await request(app.getHttpServer())
47
+ .post(`/organisations/${orgId}/forms`)
48
+ .set('Authorization', `Bearer ${accessToken}`)
49
+ .send({ definition: registrationDefinition(key) })
50
+ .expect(201);
51
+
52
+ expect(first.body.status).toBe('DRAFT');
53
+ expect(first.body.version).toBe(1);
54
+ expect(first.body.key).toBe(key);
55
+
56
+ const second = await request(app.getHttpServer())
57
+ .post(`/organisations/${orgId}/forms`)
58
+ .set('Authorization', `Bearer ${accessToken}`)
59
+ .send({ definition: registrationDefinition(key) })
60
+ .expect(201);
61
+
62
+ expect(second.body.status).toBe('DRAFT');
63
+ expect(second.body.version).toBe(2);
64
+ });
65
+
66
+ it('rejects definitions with an unknown field type at save time', async () => {
67
+ const { accessToken, orgId } = await createUserAndOrg('def-lint');
68
+ const definition = registrationDefinition(uniqueFormKey('lint'));
69
+ (definition.fields as Array<Record<string, unknown>>).push({
70
+ type: 'no-such-type',
71
+ name: 'mysteryField',
72
+ });
73
+
74
+ const response = await request(app.getHttpServer())
75
+ .post(`/organisations/${orgId}/forms`)
76
+ .set('Authorization', `Bearer ${accessToken}`)
77
+ .send({ definition })
78
+ .expect(422);
79
+
80
+ const issues = response.body.error.details.issues as Array<{ code: string }>;
81
+ expect(issues.map((issue) => issue.code)).toContain('UNKNOWN_FIELD_TYPE');
82
+ });
83
+
84
+ it('publishes, freezes published versions, auto-archives priors, and audits', async () => {
85
+ const { accessToken, orgId } = await createUserAndOrg('def-publish');
86
+ const key = uniqueFormKey('publish');
87
+
88
+ await request(app.getHttpServer())
89
+ .post(`/organisations/${orgId}/forms`)
90
+ .set('Authorization', `Bearer ${accessToken}`)
91
+ .send({ definition: registrationDefinition(key) })
92
+ .expect(201);
93
+
94
+ const published = await request(app.getHttpServer())
95
+ .post(`/organisations/${orgId}/forms/${key}/versions/1/publish`)
96
+ .set('Authorization', `Bearer ${accessToken}`)
97
+ .expect(200);
98
+ expect(published.body.status).toBe('PUBLISHED');
99
+
100
+ // Published versions are immutable.
101
+ await request(app.getHttpServer())
102
+ .patch(`/organisations/${orgId}/forms/${key}/versions/1`)
103
+ .set('Authorization', `Bearer ${accessToken}`)
104
+ .send({ definition: registrationDefinition(key) })
105
+ .expect(409);
106
+
107
+ // Publish version 2: version 1 must be auto-archived.
108
+ await request(app.getHttpServer())
109
+ .post(`/organisations/${orgId}/forms`)
110
+ .set('Authorization', `Bearer ${accessToken}`)
111
+ .send({ definition: registrationDefinition(key) })
112
+ .expect(201);
113
+ await request(app.getHttpServer())
114
+ .post(`/organisations/${orgId}/forms/${key}/versions/2/publish`)
115
+ .set('Authorization', `Bearer ${accessToken}`)
116
+ .expect(200);
117
+
118
+ const versionOne = await request(app.getHttpServer())
119
+ .get(`/organisations/${orgId}/forms/${key}/versions/1`)
120
+ .set('Authorization', `Bearer ${accessToken}`)
121
+ .expect(200);
122
+ expect(versionOne.body.status).toBe('ARCHIVED');
123
+
124
+ const auditRows = await prisma.auditLog.findMany({
125
+ where: { orgId, action: { in: ['forms.create', 'forms.publish'] } },
126
+ });
127
+ expect(auditRows.some((row) => row.action === 'forms.create')).toBe(true);
128
+ expect(auditRows.some((row) => row.action === 'forms.publish')).toBe(true);
129
+ });
130
+
131
+ it('gates dangerous-action wiring at attach time (permission and allowlist)', async () => {
132
+ const owner = await createUserAndOrg('def-dangerous');
133
+ const member = await createMemberInOrg(owner.orgId, 'def-dangerous-member', [
134
+ 'forms.read',
135
+ 'forms.create',
136
+ 'forms.update',
137
+ 'forms.publish',
138
+ ]);
139
+
140
+ // Member lacks forms.wireDangerous.
141
+ const memberAttempt = await request(app.getHttpServer())
142
+ .post(`/organisations/${owner.orgId}/forms`)
143
+ .set('Authorization', `Bearer ${member.accessToken}`)
144
+ .send({ definition: authenticateDefinition(uniqueFormKey('login-member')) })
145
+ .expect(422);
146
+ const memberIssues = memberAttempt.body.error.details.issues as Array<{ code: string }>;
147
+ expect(memberIssues.map((issue) => issue.code)).toContain('DANGEROUS_NOT_ALLOWED');
148
+
149
+ // Owner passes wireDangerous (manage all) but the org allowlist is empty.
150
+ const ownerAttempt = await request(app.getHttpServer())
151
+ .post(`/organisations/${owner.orgId}/forms`)
152
+ .set('Authorization', `Bearer ${owner.accessToken}`)
153
+ .send({ definition: authenticateDefinition(uniqueFormKey('login-owner')) })
154
+ .expect(422);
155
+ const ownerIssues = ownerAttempt.body.error.details.issues as Array<{ code: string }>;
156
+ expect(ownerIssues.map((issue) => issue.code)).toContain('DANGEROUS_NOT_IN_ALLOWLIST');
157
+ expect(ownerIssues.map((issue) => issue.code)).not.toContain('DANGEROUS_NOT_ALLOWED');
158
+
159
+ // With the org allowlist set, owner create + publish succeeds.
160
+ await setOrgSetting(owner.accessToken, owner.orgId, 'forms.allowedDangerousActions', [
161
+ 'authenticate',
162
+ ]);
163
+ const key = uniqueFormKey('login-allowed');
164
+ await request(app.getHttpServer())
165
+ .post(`/organisations/${owner.orgId}/forms`)
166
+ .set('Authorization', `Bearer ${owner.accessToken}`)
167
+ .send({ definition: authenticateDefinition(key) })
168
+ .expect(201);
169
+ const published = await request(app.getHttpServer())
170
+ .post(`/organisations/${owner.orgId}/forms/${key}/versions/1/publish`)
171
+ .set('Authorization', `Bearer ${owner.accessToken}`)
172
+ .expect(200);
173
+ expect(published.body.status).toBe('PUBLISHED');
174
+ });
175
+
176
+ it('gates public-access flips by forms.managePublicAccess and audits them', async () => {
177
+ const owner = await createUserAndOrg('def-public-access');
178
+ const member = await createMemberInOrg(owner.orgId, 'def-public-member', ['forms.read']);
179
+ const key = uniqueFormKey('flip');
180
+
181
+ await request(app.getHttpServer())
182
+ .post(`/organisations/${owner.orgId}/forms`)
183
+ .set('Authorization', `Bearer ${owner.accessToken}`)
184
+ .send({ definition: registrationDefinition(key) })
185
+ .expect(201);
186
+ await request(app.getHttpServer())
187
+ .post(`/organisations/${owner.orgId}/forms/${key}/versions/1/publish`)
188
+ .set('Authorization', `Bearer ${owner.accessToken}`)
189
+ .expect(200);
190
+
191
+ await request(app.getHttpServer())
192
+ .post(`/organisations/${owner.orgId}/forms/${key}/versions/1/public-access`)
193
+ .set('Authorization', `Bearer ${member.accessToken}`)
194
+ .send({ access: 'public' })
195
+ .expect(403);
196
+
197
+ await request(app.getHttpServer())
198
+ .post(`/organisations/${owner.orgId}/forms/${key}/versions/1/public-access`)
199
+ .set('Authorization', `Bearer ${owner.accessToken}`)
200
+ .send({ access: 'public' })
201
+ .expect(200);
202
+
203
+ const auditRow = await prisma.auditLog.findFirst({
204
+ where: { orgId: owner.orgId, action: 'forms.publicAccess.update' },
205
+ orderBy: { createdAt: 'desc' },
206
+ });
207
+ expect(auditRow).not.toBeNull();
208
+ const metadata = auditRow?.metadata as { previous?: string; next?: string };
209
+ expect(metadata.previous).toBe('private');
210
+ expect(metadata.next).toBe('public');
211
+ });
212
+
213
+ it('denies cross-org definition access (403 cross-tenant, 404 foreign key)', async () => {
214
+ const orgA = await createUserAndOrg('def-cross-a');
215
+ const orgB = await createUserAndOrg('def-cross-b');
216
+ const keyA = uniqueFormKey('cross');
217
+
218
+ await request(app.getHttpServer())
219
+ .post(`/organisations/${orgA.orgId}/forms`)
220
+ .set('Authorization', `Bearer ${orgA.accessToken}`)
221
+ .send({ definition: registrationDefinition(keyA) })
222
+ .expect(201);
223
+
224
+ // Org B's owner against org A's path: not a member, denied outright.
225
+ await request(app.getHttpServer())
226
+ .get(`/organisations/${orgA.orgId}/forms/${keyA}`)
227
+ .set('Authorization', `Bearer ${orgB.accessToken}`)
228
+ .expect(403);
229
+
230
+ // Org A's key under org B's own path: scoped lookup finds nothing.
231
+ await request(app.getHttpServer())
232
+ .get(`/organisations/${orgB.orgId}/forms/${keyA}`)
233
+ .set('Authorization', `Bearer ${orgB.accessToken}`)
234
+ .expect(404);
235
+ });
236
+
237
+ // ── Helpers ────────────────────────────────────────────────────────────────
238
+
239
+ function uniqueSuffix() {
240
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
241
+ }
242
+
243
+ function uniqueFormKey(label: string) {
244
+ return `${label}-${uniqueSuffix()}`.toLowerCase();
245
+ }
246
+
247
+ function registrationDefinition(key: string): Record<string, unknown> {
248
+ return {
249
+ key,
250
+ version: 1,
251
+ title: 'Event registration',
252
+ fields: [
253
+ {
254
+ type: 'text',
255
+ name: 'fullName',
256
+ label: 'Full name',
257
+ validators: { required: true, maxLength: 120 },
258
+ reportable: true,
259
+ },
260
+ {
261
+ type: 'email',
262
+ name: 'email',
263
+ label: 'Email address',
264
+ validators: { required: true },
265
+ reportable: true,
266
+ },
267
+ {
268
+ type: 'select',
269
+ name: 'ticketType',
270
+ label: 'Ticket type',
271
+ validators: { required: true, options: ['standard', 'student', 'vip'] },
272
+ reportable: true,
273
+ },
274
+ {
275
+ type: 'hidden',
276
+ name: 'registeredBy',
277
+ source: 'context',
278
+ path: 'user.id',
279
+ },
280
+ ],
281
+ actions: {
282
+ submit: ['validateAll', 'persist'],
283
+ saveDraft: ['persistDraft'],
284
+ },
285
+ };
286
+ }
287
+
288
+ function authenticateDefinition(key: string): Record<string, unknown> {
289
+ return {
290
+ key,
291
+ version: 1,
292
+ title: 'Sign in',
293
+ fields: [
294
+ { type: 'email', name: 'email', label: 'Email address', validators: { required: true } },
295
+ {
296
+ type: 'password',
297
+ name: 'password',
298
+ label: 'Password',
299
+ validators: { required: true, minLength: 8 },
300
+ },
301
+ ],
302
+ actions: {
303
+ submit: ['authenticate'],
304
+ },
305
+ };
306
+ }
307
+
308
+ async function setOrgSetting(accessToken: string, orgId: string, key: string, value: unknown) {
309
+ await request(app.getHttpServer())
310
+ .patch(`/organisations/${orgId}/settings`)
311
+ .set('Authorization', `Bearer ${accessToken}`)
312
+ .send({ key, value })
313
+ .expect(200);
314
+ }
315
+
316
+ async function createUserAndOrg(label: string) {
317
+ const user = await createUser(label);
318
+ const org = await createOrganisation(user.accessToken, label);
319
+
320
+ return {
321
+ ...user,
322
+ orgId: org.orgId,
323
+ membershipId: org.membershipId,
324
+ };
325
+ }
326
+
327
+ async function createUser(label: string) {
328
+ const suffix = uniqueSuffix();
329
+ const signup = await request(app.getHttpServer())
330
+ .post('/auth/signup')
331
+ .send({
332
+ email: `${label}-${suffix}@example.com`,
333
+ password: 'test-password-123',
334
+ displayName: label,
335
+ })
336
+ .expect(201);
337
+
338
+ return {
339
+ accessToken: signup.body.accessToken as string,
340
+ userId: signup.body.user.id as string,
341
+ };
342
+ }
343
+
344
+ async function createOrganisation(accessToken: string, label: string) {
345
+ const suffix = uniqueSuffix();
346
+ const org = await request(app.getHttpServer())
347
+ .post('/organisations')
348
+ .set('Authorization', `Bearer ${accessToken}`)
349
+ .send({
350
+ name: `${label} ${suffix}`,
351
+ slug: `${label}-${suffix}`,
352
+ })
353
+ .expect(201);
354
+
355
+ return {
356
+ orgId: org.body.organisation.id as string,
357
+ membershipId: org.body.membership.id as string,
358
+ };
359
+ }
360
+
361
+ async function createMemberInOrg(orgId: string, label: string, permissionKeys: string[]) {
362
+ const user = await createUser(label);
363
+ const permissions = await prisma.permission.findMany({
364
+ where: { key: { in: permissionKeys } },
365
+ select: { id: true },
366
+ });
367
+ expect(permissions).toHaveLength(permissionKeys.length);
368
+
369
+ const role = await prisma.role.create({
370
+ data: {
371
+ orgId,
372
+ name: `${label}-${uniqueSuffix()}`,
373
+ permissions: {
374
+ create: permissions.map((permission) => ({ permissionId: permission.id })),
375
+ },
376
+ },
377
+ select: { id: true },
378
+ });
379
+ const membership = await prisma.membership.create({
380
+ data: {
381
+ userId: user.userId,
382
+ orgId,
383
+ roleId: role.id,
384
+ },
385
+ select: { id: true, roleId: true },
386
+ });
387
+
388
+ return {
389
+ ...user,
390
+ membershipId: membership.id,
391
+ roleId: membership.roleId,
392
+ };
393
+ }
394
+ });