@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,350 @@
1
+ import { INestApplication, ValidationPipe } from '@nestjs/common';
2
+ import { Test } from '@nestjs/testing';
3
+ import request, { type Response } from 'supertest';
4
+ import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
5
+ import { PrismaService } from '../src/database/prisma/prisma.service';
6
+
7
+ /**
8
+ * Reports end-to-end (@ftisindia/report-builder) over the `org-members`
9
+ * CUSTOM source — the Phase-1 standalone milestone (report design §14): a
10
+ * report that filters/sorts/searches/paginates at constant cost with ZERO
11
+ * form-builder involvement. Creating an org seeds the owner's membership, so
12
+ * the source has real rows to query.
13
+ */
14
+ // Cold ts-jest compile of the full app graph + boot (DI scan, schema checks,
15
+ // Prisma connect) exceeds jest's 5s default; each test also runs many
16
+ // sequential HTTP + DB calls.
17
+ jest.setTimeout(60_000);
18
+
19
+ describe('Reports query lifecycle (e2e)', () => {
20
+ let app: INestApplication;
21
+ let prisma: PrismaService;
22
+
23
+ beforeAll(async () => {
24
+ process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
25
+ process.env.JWT_SECRET ||= 'test-jwt-secret-at-least-32-characters-long';
26
+ process.env.AUTH_GOOGLE_ENABLED ||= 'false';
27
+ process.env.AUTH_EMAIL_PASSWORD_ENABLED ||= 'true';
28
+ process.env.NODE_ENV = 'test';
29
+ process.env.FORMS_OUTBOX_ENABLED = 'false';
30
+ process.env.REPORTS_SCHEMA_CHECK = 'on';
31
+
32
+ const { AppModule } = await import('../src/app.module');
33
+ const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
34
+
35
+ app = moduleRef.createNestApplication();
36
+ app.useGlobalFilters(new HttpExceptionFilter());
37
+ app.useGlobalPipes(
38
+ new ValidationPipe({ forbidNonWhitelisted: true, transform: true, whitelist: true }),
39
+ );
40
+ await app.init();
41
+ prisma = app.get(PrismaService);
42
+ });
43
+
44
+ afterAll(async () => {
45
+ await app.close();
46
+ });
47
+
48
+ it('publishes a custom-source report and queries member rows (the standalone milestone)', async () => {
49
+ const { accessToken, orgId } = await createUserAndOrg('rep-pub');
50
+ const key = uniqueKey('members');
51
+
52
+ const draft = await api()
53
+ .post(`/organisations/${orgId}/reports`)
54
+ .set('Authorization', `Bearer ${accessToken}`)
55
+ .send(orgMembersDefinition(key))
56
+ .expect(201);
57
+ expect(draft.body.status).toBe('DRAFT');
58
+ expect(draft.body.version).toBe(1);
59
+
60
+ await api()
61
+ .post(`/organisations/${orgId}/reports/${key}/publish`)
62
+ .set('Authorization', `Bearer ${accessToken}`)
63
+ .expect(200)
64
+ .expect((res) => expect(res.body.status).toBe('PUBLISHED'));
65
+
66
+ const result = await api()
67
+ .post(`/organisations/${orgId}/reports/${key}/query`)
68
+ .set('Authorization', `Bearer ${accessToken}`)
69
+ .send({ pageSize: 50 })
70
+ .expect(200);
71
+
72
+ expect(result.body.rows).toHaveLength(1); // the owner
73
+ const row = result.body.rows[0];
74
+ expect(typeof row.$id).toBe('string');
75
+ expect(Array.isArray(row.$tags)).toBe(true);
76
+ expect(row.role).toBe('Owner');
77
+ expect(row.status).toBe('ACTIVE');
78
+ expect(typeof row.displayName).toBe('string');
79
+ expect(result.body.meta.reportVersion).toBe(1);
80
+ expect(result.body.pageInfo.hasMore).toBe(false);
81
+ });
82
+
83
+ it('paginates members at constant cost with keyset cursors', async () => {
84
+ const { accessToken, orgId } = await createUserAndOrg('rep-page');
85
+ await createMemberInOrg(orgId, 'rep-page-b', ['reports.read']);
86
+ await createMemberInOrg(orgId, 'rep-page-c', ['reports.read']);
87
+ const key = uniqueKey('page');
88
+ await publishOrgMembers(accessToken, orgId, key);
89
+
90
+ // Three members; walk one row per page via the signed cursor.
91
+ const seen = new Set<string>();
92
+ let cursor: string | null = null;
93
+ for (let page = 0; page < 3; page += 1) {
94
+ const res: Response = await api()
95
+ .post(`/organisations/${orgId}/reports/${key}/query`)
96
+ .set('Authorization', `Bearer ${accessToken}`)
97
+ .send({ pageSize: 1, sort: [{ column: 'displayName', dir: 'asc' }], cursor })
98
+ .expect(200);
99
+ expect(res.body.rows).toHaveLength(1);
100
+ seen.add(res.body.rows[0].$id);
101
+ cursor = res.body.pageInfo.nextCursor;
102
+ expect(res.body.pageInfo.hasMore).toBe(page < 2);
103
+ }
104
+ expect(seen.size).toBe(3); // every page distinct — no OFFSET skips/dupes
105
+ expect(cursor).toBeNull();
106
+ });
107
+
108
+ it('filters and sorts through the typed compiler', async () => {
109
+ const { accessToken, orgId } = await createUserAndOrg('rep-filter');
110
+ const key = uniqueKey('filter');
111
+ await publishOrgMembers(accessToken, orgId, key);
112
+
113
+ await api()
114
+ .post(`/organisations/${orgId}/reports/${key}/query`)
115
+ .set('Authorization', `Bearer ${accessToken}`)
116
+ .send({ filters: [{ column: 'status', op: 'eq', value: 'ACTIVE' }] })
117
+ .expect(200)
118
+ .expect((res) => expect(res.body.rows.length).toBeGreaterThanOrEqual(1));
119
+
120
+ // A typed-operator violation is a 400 BEFORE any SQL runs (§4).
121
+ await api()
122
+ .post(`/organisations/${orgId}/reports/${key}/query`)
123
+ .set('Authorization', `Bearer ${accessToken}`)
124
+ .send({ filters: [{ column: 'joinedAt', op: 'contains', value: 'x' }] })
125
+ .expect(400);
126
+ });
127
+
128
+ it('describes itself through the meta endpoint', async () => {
129
+ const { accessToken, orgId } = await createUserAndOrg('rep-meta');
130
+ const key = uniqueKey('meta');
131
+ await publishOrgMembers(accessToken, orgId, key);
132
+
133
+ const meta = await api()
134
+ .get(`/organisations/${orgId}/reports/${key}/meta`)
135
+ .set('Authorization', `Bearer ${accessToken}`)
136
+ .expect(200);
137
+
138
+ expect(meta.body.reportVersion).toBe(1);
139
+ expect(meta.body.searchEnabled).toBe(true);
140
+ expect(meta.body.rowActions.map((a: { name: string }) => a.name)).toContain('manageTags');
141
+ const status = meta.body.columns.find((c: { id: string }) => c.id === 'status');
142
+ expect(status.operators).toEqual(expect.arrayContaining(['eq', 'in']));
143
+ expect(status.operators).not.toContain('contains');
144
+ });
145
+
146
+ it('gates queries on reports.read', async () => {
147
+ const { accessToken, orgId } = await createUserAndOrg('rep-perm');
148
+ const key = uniqueKey('perm');
149
+ await publishOrgMembers(accessToken, orgId, key);
150
+
151
+ const outsider = await createMemberInOrg(orgId, 'rep-perm-no', ['reportTags.manage']);
152
+ await api()
153
+ .post(`/organisations/${orgId}/reports/${key}/query`)
154
+ .set('Authorization', `Bearer ${outsider.accessToken}`)
155
+ .send({})
156
+ .expect(403);
157
+
158
+ const reader = await createMemberInOrg(orgId, 'rep-perm-yes', ['reports.read']);
159
+ await api()
160
+ .post(`/organisations/${orgId}/reports/${key}/query`)
161
+ .set('Authorization', `Bearer ${reader.accessToken}`)
162
+ .send({})
163
+ .expect(200);
164
+ });
165
+
166
+ it('saves a personal view and reports its compatibility', async () => {
167
+ const { accessToken, orgId } = await createUserAndOrg('rep-view');
168
+ const key = uniqueKey('view');
169
+ await publishOrgMembers(accessToken, orgId, key);
170
+
171
+ await api()
172
+ .post(`/organisations/${orgId}/reports/${key}/views`)
173
+ .set('Authorization', `Bearer ${accessToken}`)
174
+ .send({ name: 'Active only', spec: { filters: [{ column: 'status', op: 'eq', value: 'ACTIVE' }] } })
175
+ .expect(201);
176
+
177
+ const views = await api()
178
+ .get(`/organisations/${orgId}/reports/${key}/views`)
179
+ .set('Authorization', `Bearer ${accessToken}`)
180
+ .expect(200);
181
+ const list = Array.isArray(views.body) ? views.body : views.body.items;
182
+ expect(list).toHaveLength(1);
183
+ expect(list[0].compatibility.status).toBe('ok');
184
+ });
185
+
186
+ it('tags rows through the manageTags row action and filters on $tags', async () => {
187
+ const { accessToken, orgId } = await createUserAndOrg('rep-tag');
188
+ const key = uniqueKey('tag');
189
+ await publishOrgMembers(accessToken, orgId, key);
190
+
191
+ const first = await api()
192
+ .post(`/organisations/${orgId}/reports/${key}/query`)
193
+ .set('Authorization', `Bearer ${accessToken}`)
194
+ .send({})
195
+ .expect(200);
196
+ const rowId = first.body.rows[0].$id;
197
+
198
+ await api()
199
+ .post(`/organisations/${orgId}/reports/${key}/actions/manageTags`)
200
+ .set('Authorization', `Bearer ${accessToken}`)
201
+ .send({ selection: { byIds: [rowId] }, input: { add: ['shortlisted'] } })
202
+ .expect(200);
203
+
204
+ const tagged = await api()
205
+ .post(`/organisations/${orgId}/reports/${key}/query`)
206
+ .set('Authorization', `Bearer ${accessToken}`)
207
+ .send({ filters: [{ column: '$tags', op: 'hasTag', value: 'shortlisted' }] })
208
+ .expect(200);
209
+ expect(tagged.body.rows).toHaveLength(1);
210
+ expect(tagged.body.rows[0].$tags).toContain('shortlisted');
211
+ });
212
+
213
+ it('streams a snapshot-consistent CSV export, gated by reports.export', async () => {
214
+ const { accessToken, orgId } = await createUserAndOrg('rep-export');
215
+ const key = uniqueKey('export');
216
+ await publishOrgMembers(accessToken, orgId, key);
217
+
218
+ const csv = await api()
219
+ .post(`/organisations/${orgId}/reports/${key}/export`)
220
+ .set('Authorization', `Bearer ${accessToken}`)
221
+ .send({ format: 'csv' })
222
+ .expect(200);
223
+ expect(csv.headers['content-disposition']).toContain('attachment');
224
+ expect(csv.text).toContain('Name'); // the displayName header
225
+ expect(csv.text.split(/\r?\n/).filter(Boolean).length).toBeGreaterThanOrEqual(2);
226
+
227
+ // A member without reports.export cannot egress (never implied by read).
228
+ const reader = await createMemberInOrg(orgId, 'rep-export-no', ['reports.read']);
229
+ await api()
230
+ .post(`/organisations/${orgId}/reports/${key}/export`)
231
+ .set('Authorization', `Bearer ${reader.accessToken}`)
232
+ .send({ format: 'csv' })
233
+ .expect(403);
234
+ });
235
+
236
+ it('rejects a definition that smuggles SQL into a column path (the §2.1 boundary)', async () => {
237
+ const { accessToken, orgId } = await createUserAndOrg('rep-sql');
238
+ const def = orgMembersDefinition(uniqueKey('sql'));
239
+ (def.columns as Array<Record<string, unknown>>).push({
240
+ id: 'evil',
241
+ header: 'Evil',
242
+ path: "name'); DROP TABLE \"User\"; --",
243
+ type: 'text',
244
+ });
245
+ await api()
246
+ .post(`/organisations/${orgId}/reports`)
247
+ .set('Authorization', `Bearer ${accessToken}`)
248
+ .send(def)
249
+ .expect(422);
250
+ });
251
+
252
+ // ── helpers (mirrors the forms e2e harness) ─────────────────────────────────
253
+
254
+ function api() {
255
+ return request(app.getHttpServer());
256
+ }
257
+
258
+ function uniqueSuffix() {
259
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
260
+ }
261
+
262
+ function uniqueKey(label: string) {
263
+ return `${label}-${uniqueSuffix()}`.toLowerCase();
264
+ }
265
+
266
+ function orgMembersDefinition(key: string): Record<string, unknown> {
267
+ return {
268
+ key,
269
+ title: 'Organisation members',
270
+ source: { kind: 'custom', key: 'org-members' },
271
+ columns: [
272
+ { id: 'displayName', header: 'Name', columnId: 'displayName', type: 'text', sortable: true, filterable: true, searchable: true },
273
+ { id: 'email', header: 'Email', columnId: 'email', type: 'text', sortable: true, filterable: true },
274
+ { id: 'role', header: 'Role', columnId: 'role', type: 'text', sortable: true, filterable: true },
275
+ { id: 'status', header: 'Status', columnId: 'status', type: 'enum', filterable: true, enum: ['ACTIVE', 'SUSPENDED', 'REVOKED'] },
276
+ { id: 'joinedAt', header: 'Joined', columnId: 'joinedAt', type: 'datetime', sortable: true },
277
+ ],
278
+ defaultSort: [{ column: 'joinedAt', dir: 'desc' }],
279
+ search: { columns: ['displayName'] },
280
+ rowActions: ['manageTags'],
281
+ export: { formats: ['csv', 'xlsx'] },
282
+ performanceTier: 'live',
283
+ };
284
+ }
285
+
286
+ async function publishOrgMembers(accessToken: string, orgId: string, key: string) {
287
+ await api()
288
+ .post(`/organisations/${orgId}/reports`)
289
+ .set('Authorization', `Bearer ${accessToken}`)
290
+ .send(orgMembersDefinition(key))
291
+ .expect(201);
292
+ await api()
293
+ .post(`/organisations/${orgId}/reports/${key}/publish`)
294
+ .set('Authorization', `Bearer ${accessToken}`)
295
+ .expect(200);
296
+ }
297
+
298
+ async function createUserAndOrg(label: string) {
299
+ const user = await createUser(label);
300
+ const org = await createOrganisation(user.accessToken, label);
301
+ return { ...user, orgId: org.orgId, membershipId: org.membershipId };
302
+ }
303
+
304
+ async function createUser(label: string) {
305
+ const suffix = uniqueSuffix();
306
+ const signup = await api()
307
+ .post('/auth/signup')
308
+ .send({ email: `${label}-${suffix}@example.com`, password: 'test-password-123', displayName: label })
309
+ .expect(201);
310
+ return {
311
+ accessToken: signup.body.accessToken as string,
312
+ userId: signup.body.user.id as string,
313
+ };
314
+ }
315
+
316
+ async function createOrganisation(accessToken: string, label: string) {
317
+ const suffix = uniqueSuffix();
318
+ const org = await api()
319
+ .post('/organisations')
320
+ .set('Authorization', `Bearer ${accessToken}`)
321
+ .send({ name: `${label} ${suffix}`, slug: `${label}-${suffix}` })
322
+ .expect(201);
323
+ return {
324
+ orgId: org.body.organisation.id as string,
325
+ membershipId: org.body.membership.id as string,
326
+ };
327
+ }
328
+
329
+ async function createMemberInOrg(orgId: string, label: string, permissionKeys: string[]) {
330
+ const user = await createUser(label);
331
+ const permissions = await prisma.permission.findMany({
332
+ where: { key: { in: permissionKeys } },
333
+ select: { id: true },
334
+ });
335
+ expect(permissions).toHaveLength(permissionKeys.length);
336
+ const role = await prisma.role.create({
337
+ data: {
338
+ orgId,
339
+ name: `${label}-${uniqueSuffix()}`,
340
+ permissions: { create: permissions.map((permission) => ({ permissionId: permission.id })) },
341
+ },
342
+ select: { id: true },
343
+ });
344
+ const membership = await prisma.membership.create({
345
+ data: { userId: user.userId, orgId, roleId: role.id },
346
+ select: { id: true, roleId: true },
347
+ });
348
+ return { ...user, membershipId: membership.id, roleId: membership.roleId };
349
+ }
350
+ });
@@ -0,0 +1,257 @@
1
+ import { INestApplication, ValidationPipe } from '@nestjs/common';
2
+ import { Test } from '@nestjs/testing';
3
+ import request from 'supertest';
4
+ import { ReportSourceRegistry } from '@ftisindia/report-builder';
5
+ import type { ReportSourceDef } from '@ftisindia/report-builder';
6
+ import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
7
+ import { PrismaService } from '../src/database/prisma/prisma.service';
8
+ import { ReportsSchemaCheckService } from '../src/modules/reports/infrastructure/schema-check/reports-schema-check.service';
9
+
10
+ /**
11
+ * Reports e2e for the production-critical surfaces the basic specs do not
12
+ * reach: the indexed-tier publish → apply-migration → republish ROUND TRIP,
13
+ * the materialized tier over a real materialized view, and the boot
14
+ * schema-check FAILURE mode. All against real Postgres; test-only sources are
15
+ * registered into the engine's source registry at runtime (no shipped
16
+ * production code needed to exercise them).
17
+ */
18
+ jest.setTimeout(60_000);
19
+
20
+ describe('Reports performance tiers + schema check (e2e)', () => {
21
+ let app: INestApplication;
22
+ let prisma: PrismaService;
23
+ let sources: ReportSourceRegistry;
24
+
25
+ beforeAll(async () => {
26
+ process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
27
+ process.env.JWT_SECRET ||= 'test-jwt-secret-at-least-32-characters-long';
28
+ process.env.AUTH_GOOGLE_ENABLED ||= 'false';
29
+ process.env.NODE_ENV = 'test';
30
+ process.env.FORMS_OUTBOX_ENABLED = 'false';
31
+ process.env.REPORTS_EXPORT_WORKER_ENABLED = 'false';
32
+
33
+ const { AppModule } = await import('../src/app.module');
34
+ const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
35
+
36
+ app = moduleRef.createNestApplication();
37
+ app.useGlobalFilters(new HttpExceptionFilter());
38
+ app.useGlobalPipes(
39
+ new ValidationPipe({ forbidNonWhitelisted: true, transform: true, whitelist: true }),
40
+ );
41
+ await app.init();
42
+ prisma = app.get(PrismaService);
43
+ sources = app.get(ReportSourceRegistry);
44
+ });
45
+
46
+ afterAll(async () => {
47
+ await app.close();
48
+ });
49
+
50
+ it('indexed tier: publish fails with the migration snippet, then SUCCEEDS after applying it', async () => {
51
+ const { accessToken, orgId } = await createUserAndOrg('rep-idxrt');
52
+ const tag = uniqueTag();
53
+ const sourceKey = `idx-test-${tag}`;
54
+ const statusIdx = `idx_rb_test_status_${tag}`;
55
+ const joinedIdx = `idx_rb_test_joined_${tag}`;
56
+ sources.register(indexedTestSource(sourceKey, statusIdx, joinedIdx));
57
+
58
+ const key = uniqueKey('idxrt');
59
+ const def = indexedDefinition(key, sourceKey);
60
+
61
+ try {
62
+ await api().post(`/organisations/${orgId}/reports`).set(auth(accessToken)).send(def).expect(201);
63
+
64
+ // Publish runs the catalog lint: the declared indexes do not exist yet,
65
+ // so it fails CONSTRUCTIVELY with the exact CREATE INDEX SQL (§8).
66
+ const failed = await api()
67
+ .post(`/organisations/${orgId}/reports/${key}/publish`)
68
+ .set(auth(accessToken))
69
+ .expect(422);
70
+ expect(JSON.stringify(failed.body)).toContain('CREATE INDEX');
71
+ expect(JSON.stringify(failed.body)).toContain(statusIdx);
72
+
73
+ // Apply the suggested indexes (org-leading btrees on the physical columns).
74
+ await prisma.$executeRawUnsafe(`CREATE INDEX "${statusIdx}" ON "Membership" ("orgId", "status")`);
75
+ await prisma.$executeRawUnsafe(`CREATE INDEX "${joinedIdx}" ON "Membership" ("orgId", "createdAt")`);
76
+
77
+ // The SAME draft now publishes: catalog + plan lint pass.
78
+ await api()
79
+ .post(`/organisations/${orgId}/reports/${key}/publish`)
80
+ .set(auth(accessToken))
81
+ .expect(200);
82
+
83
+ const result = await api()
84
+ .post(`/organisations/${orgId}/reports/${key}/query`)
85
+ .set(auth(accessToken))
86
+ .send({ sort: [{ column: 'joinedAt', dir: 'desc' }] })
87
+ .expect(200);
88
+ expect(result.body.rows.length).toBeGreaterThanOrEqual(1);
89
+ } finally {
90
+ await prisma.$executeRawUnsafe(`DROP INDEX IF EXISTS "${statusIdx}"`);
91
+ await prisma.$executeRawUnsafe(`DROP INDEX IF EXISTS "${joinedIdx}"`);
92
+ }
93
+ });
94
+
95
+ it('materialized tier: publishes against a materialized view and reports freshAsOf', async () => {
96
+ const { accessToken, orgId } = await createUserAndOrg('rep-matrt');
97
+ const tag = uniqueTag();
98
+ const mv = `rb_test_mv_${tag}`;
99
+
100
+ // Create the matview AFTER the org exists so it captures the owner row.
101
+ await prisma.$executeRawUnsafe(
102
+ `CREATE MATERIALIZED VIEW ${mv} AS ` +
103
+ `SELECT m."id" AS "rowId", m."orgId" AS "orgId", m."status"::text AS "status", ` +
104
+ `m."createdAt" AS "joinedAt" FROM "Membership" m`,
105
+ );
106
+ try {
107
+ const sourceKey = `mat-test-${tag}`;
108
+ sources.register(materializedTestSource(sourceKey, mv));
109
+
110
+ const key = uniqueKey('matrt');
111
+ const def = {
112
+ key,
113
+ title: 'Member stats (materialized)',
114
+ source: { kind: 'custom', key: sourceKey },
115
+ columns: [
116
+ { id: 'status', header: 'Status', columnId: 'status', type: 'enum', filterable: true, enum: ['ACTIVE', 'SUSPENDED', 'REVOKED'] },
117
+ { id: 'joinedAt', header: 'Joined', columnId: 'joinedAt', type: 'datetime', sortable: true },
118
+ ],
119
+ defaultSort: [{ column: 'joinedAt', dir: 'desc' }],
120
+ performanceTier: 'materialized',
121
+ };
122
+
123
+ await api().post(`/organisations/${orgId}/reports`).set(auth(accessToken)).send(def).expect(201);
124
+ await api().post(`/organisations/${orgId}/reports/${key}/publish`).set(auth(accessToken)).expect(200);
125
+
126
+ const result = await api()
127
+ .post(`/organisations/${orgId}/reports/${key}/query`)
128
+ .set(auth(accessToken))
129
+ .send({})
130
+ .expect(200);
131
+ expect(result.body.rows.length).toBeGreaterThanOrEqual(1);
132
+ expect(result.body.meta.freshAsOf).not.toBeNull();
133
+ } finally {
134
+ await prisma.$executeRawUnsafe(`DROP MATERIALIZED VIEW IF EXISTS ${mv}`);
135
+ }
136
+ });
137
+
138
+ it('boot schema check fails loudly when a required partial unique index is missing', async () => {
139
+ const check = app.get(ReportsSchemaCheckService);
140
+ // A freshly migrated DB passes.
141
+ await expect(check.check()).resolves.toBeUndefined();
142
+
143
+ await prisma.$executeRawUnsafe('DROP INDEX "report_view_shared_uq"');
144
+ try {
145
+ await expect(check.check()).rejects.toThrow(/report_view_shared_uq|CREATE UNIQUE INDEX/);
146
+ } finally {
147
+ // Restore so the rest of the suite (and the DB) stays intact.
148
+ await prisma.$executeRawUnsafe(
149
+ 'CREATE UNIQUE INDEX "report_view_shared_uq" ON "ReportSavedView" ("orgId", "reportKey", "name") WHERE "ownerId" IS NULL',
150
+ );
151
+ }
152
+ // Green again after restore.
153
+ await expect(check.check()).resolves.toBeUndefined();
154
+ });
155
+
156
+ // ── test-only sources ───────────────────────────────────────────────────────
157
+
158
+ function indexedDefinition(key: string, sourceKey: string): Record<string, unknown> {
159
+ return {
160
+ key,
161
+ title: 'Indexed members',
162
+ source: { kind: 'custom', key: sourceKey },
163
+ columns: [
164
+ { id: 'status', header: 'Status', columnId: 'status', type: 'enum', filterable: true, sortable: true, enum: ['ACTIVE', 'SUSPENDED', 'REVOKED'] },
165
+ { id: 'joinedAt', header: 'Joined', columnId: 'joinedAt', type: 'datetime', sortable: true },
166
+ ],
167
+ defaultSort: [{ column: 'joinedAt', dir: 'desc' }],
168
+ performanceTier: 'indexed',
169
+ };
170
+ }
171
+
172
+ function indexedTestSource(key: string, statusIdx: string, joinedIdx: string): ReportSourceDef {
173
+ return {
174
+ key,
175
+ manifest: () => ({
176
+ rowId: 'm."id"',
177
+ orgScoped: true,
178
+ columns: [
179
+ {
180
+ id: 'status',
181
+ sql: 'm."status"::text',
182
+ type: 'enum',
183
+ filterable: true,
184
+ sortable: true,
185
+ nullable: false,
186
+ index: { name: statusIdx, kind: 'btree', table: 'Membership', expr: '"orgId", "status"' },
187
+ },
188
+ {
189
+ id: 'joinedAt',
190
+ sql: 'm."createdAt"',
191
+ type: 'datetime',
192
+ valueKind: 'native',
193
+ sortable: true,
194
+ nullable: false,
195
+ index: { name: joinedIdx, kind: 'btree', table: 'Membership', expr: '"orgId", "createdAt"' },
196
+ },
197
+ ],
198
+ }),
199
+ baseQuery: () => ({ from: '"Membership" m', orgColumn: 'm."orgId"', primaryTable: 'Membership' }),
200
+ };
201
+ }
202
+
203
+ function materializedTestSource(key: string, mv: string): ReportSourceDef {
204
+ return {
205
+ key,
206
+ manifest: () => ({
207
+ rowId: 'm."id"',
208
+ orgScoped: true,
209
+ columns: [
210
+ { id: 'status', sql: '"status"', type: 'enum', filterable: true, nullable: false },
211
+ { id: 'joinedAt', sql: '"joinedAt"', type: 'datetime', valueKind: 'native', sortable: true, nullable: false },
212
+ ],
213
+ materialization: { relation: mv, orgColumn: '"orgId"', rowIdColumn: 'rowId', stalenessSeconds: 300 },
214
+ }),
215
+ baseQuery: () => ({ from: mv, orgColumn: '"orgId"', primaryTable: mv }),
216
+ freshAsOf: async () => new Date(),
217
+ };
218
+ }
219
+
220
+ // ── shared harness ──────────────────────────────────────────────────────────
221
+
222
+ function api() {
223
+ return request(app.getHttpServer());
224
+ }
225
+
226
+ function auth(token: string) {
227
+ return { Authorization: `Bearer ${token}` };
228
+ }
229
+
230
+ function uniqueSuffix() {
231
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
232
+ }
233
+
234
+ /** A lowercase alphanumeric tag safe for SQL identifiers and report keys. */
235
+ function uniqueTag() {
236
+ return uniqueSuffix().replace(/[^a-z0-9]/gi, '').toLowerCase();
237
+ }
238
+
239
+ function uniqueKey(label: string) {
240
+ return `${label}-${uniqueTag()}`;
241
+ }
242
+
243
+ async function createUserAndOrg(label: string) {
244
+ const suffix = uniqueSuffix();
245
+ const signup = await api()
246
+ .post('/auth/signup')
247
+ .send({ email: `${label}-${suffix}@example.com`, password: 'test-password-123', displayName: label })
248
+ .expect(201);
249
+ const accessToken = signup.body.accessToken as string;
250
+ const org = await api()
251
+ .post('/organisations')
252
+ .set(auth(accessToken))
253
+ .send({ name: `${label} ${suffix}`, slug: `${label}-${suffix}` })
254
+ .expect(201);
255
+ return { accessToken, orgId: org.body.organisation.id as string };
256
+ }
257
+ });
@@ -1,24 +1,49 @@
1
1
  import { Controller, Get } from '@nestjs/common';
2
2
  import { MetadataScanner, Reflector } from '@nestjs/core';
3
3
  import { AccessControlController } from '../src/modules/access-control/presentation/access-control.controller';
4
+ import { CurrentAccessControlController } from '../src/modules/access-control/presentation/current-access-control.controller';
4
5
  import { RequirePermissions } from '../src/modules/access-control/presentation/permissions.decorator';
5
6
  import { RouteRegistryValidator } from '../src/modules/access-control/application/route-registry.validator';
6
7
  import { PermissionKey } from '../src/modules/access-control/types/permission-key';
8
+ import { routePermissionRegistry } from '../src/modules/access-control/types/route-permission-registry';
7
9
  import { AuditController } from '../src/modules/audit/presentation/audit.controller';
8
10
  import { AuthController } from '../src/modules/auth/presentation/auth.controller';
11
+ import { FormsDataSourcesController } from '../src/modules/forms/presentation/forms-data-sources.controller';
12
+ import { FormsDefinitionsController } from '../src/modules/forms/presentation/forms-definitions.controller';
13
+ import { FormsFilesController } from '../src/modules/forms/presentation/forms-files.controller';
14
+ import { FormsSubmissionsController } from '../src/modules/forms/presentation/forms-submissions.controller';
15
+ import { PublicFormsController } from '../src/modules/forms/presentation/public-forms.controller';
9
16
  import { InvitationsController } from '../src/modules/invitations/presentation/invitations.controller';
10
17
  import { MembershipsController } from '../src/modules/memberships/presentation/memberships.controller';
11
18
  import { OrganisationsController } from '../src/modules/organisations/presentation/organisations.controller';
19
+ import { ReportsActionsController } from '../src/modules/reports/presentation/reports-actions.controller';
20
+ import { ReportsDefinitionsController } from '../src/modules/reports/presentation/reports-definitions.controller';
21
+ import { ReportsExportController } from '../src/modules/reports/presentation/reports-export.controller';
22
+ import { ReportsExportJobsController } from '../src/modules/reports/presentation/reports-export-jobs.controller';
23
+ import { ReportsQueryController } from '../src/modules/reports/presentation/reports-query.controller';
24
+ import { ReportsViewsController } from '../src/modules/reports/presentation/reports-views.controller';
12
25
  import { SampleController } from '../src/modules/sample/presentation/sample.controller';
13
26
  import { SettingsController } from '../src/modules/settings/presentation/settings.controller';
14
27
 
15
28
  const controllers = [
16
29
  AccessControlController,
30
+ CurrentAccessControlController,
17
31
  AuditController,
18
32
  AuthController,
33
+ FormsDataSourcesController,
34
+ FormsDefinitionsController,
35
+ FormsFilesController,
36
+ FormsSubmissionsController,
37
+ PublicFormsController,
19
38
  InvitationsController,
20
39
  MembershipsController,
21
40
  OrganisationsController,
41
+ ReportsActionsController,
42
+ ReportsDefinitionsController,
43
+ ReportsExportController,
44
+ ReportsExportJobsController,
45
+ ReportsQueryController,
46
+ ReportsViewsController,
22
47
  SampleController,
23
48
  SettingsController,
24
49
  ];
@@ -28,6 +53,15 @@ describe('RouteRegistryValidator', () => {
28
53
  expect(() => createValidator(controllers).onApplicationBootstrap()).not.toThrow();
29
54
  });
30
55
 
56
+ it('does not register the current access-control bootstrap route as permission-gated', () => {
57
+ expect(routePermissionRegistry).not.toContainEqual(
58
+ expect.objectContaining({
59
+ method: 'GET',
60
+ path: '/organisations/:orgId/access-control/me',
61
+ }),
62
+ );
63
+ });
64
+
31
65
  it('fails when a permission-gated route is missing from the registry', () => {
32
66
  class DriftController {
33
67
  probe() {