@ftisindia/create-app 0.1.6 → 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.
- package/package.json +1 -1
- package/template/.env.example +3 -0
- package/template/_package.json +4 -1
- package/template/docs/FORMS.md +34 -15
- package/template/docs/FORMS_CHECKLIST.md +34 -26
- package/template/docs/REPORTS.md +12 -3
- package/template/docs/REPORTS_CHECKLIST.md +150 -95
- package/template/prisma/migrations/20260616000000_add_form_outbox_claimed_by/migration.sql +5 -0
- package/template/prisma/schema.prisma +5 -1
- package/template/src/app.module.ts +4 -3
- package/template/src/config/env.validation.ts +3 -0
- package/template/src/config/forms.config.ts +1 -0
- package/template/src/config/reports.config.ts +2 -0
- package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts +319 -0
- package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +64 -16
- package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +40 -18
- package/template/src/modules/forms/forms.module.ts +2 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +82 -59
- package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +81 -0
- package/template/src/modules/reports/application/services/reports-exports.service.ts +6 -2
- package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +43 -30
- package/template/test/forms-captcha.e2e-spec.ts +163 -0
- package/template/test/forms-files.e2e-spec.ts +1 -1
- package/template/test/forms-outbox.e2e-spec.ts +271 -10
- package/template/test/forms-public.e2e-spec.ts +24 -0
- package/template/test/forms-throttling.e2e-spec.ts +146 -0
- package/template/test/forms-webhooks.e2e-spec.ts +150 -8
- package/template/test/jest-e2e.json +1 -0
- package/template/test/reports-advanced.e2e-spec.ts +13 -0
- package/template/test/reports-query.e2e-spec.ts +52 -0
- package/template/test/reports-tiers.e2e-spec.ts +106 -20
|
@@ -11,6 +11,7 @@ import { OutboxDispatcherService } from '../src/modules/forms/application/servic
|
|
|
11
11
|
interface CapturedRequest {
|
|
12
12
|
url: string;
|
|
13
13
|
signature: string | undefined;
|
|
14
|
+
idempotencyKey: string | undefined;
|
|
14
15
|
body: string;
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -37,8 +38,15 @@ describe('Forms submission webhooks (e2e)', () => {
|
|
|
37
38
|
received.push({
|
|
38
39
|
url: req.url ?? '',
|
|
39
40
|
signature: req.headers['x-forms-signature'] as string | undefined,
|
|
41
|
+
idempotencyKey: req.headers['x-forms-idempotency-key'] as string | undefined,
|
|
40
42
|
body: Buffer.concat(chunks).toString('utf8'),
|
|
41
43
|
});
|
|
44
|
+
if (req.url === '/redirect-metadata') {
|
|
45
|
+
res.statusCode = 302;
|
|
46
|
+
res.setHeader('location', 'http://169.254.169.254/latest/meta-data/');
|
|
47
|
+
res.end();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
42
50
|
res.statusCode = req.url === '/always-500' ? 500 : 200;
|
|
43
51
|
res.end();
|
|
44
52
|
});
|
|
@@ -54,11 +62,23 @@ describe('Forms submission webhooks (e2e)', () => {
|
|
|
54
62
|
app = moduleRef.createNestApplication();
|
|
55
63
|
app.useGlobalFilters(new HttpExceptionFilter());
|
|
56
64
|
app.useGlobalPipes(
|
|
57
|
-
new ValidationPipe({
|
|
65
|
+
new ValidationPipe({
|
|
66
|
+
forbidNonWhitelisted: true,
|
|
67
|
+
transform: true,
|
|
68
|
+
whitelist: true,
|
|
69
|
+
}),
|
|
58
70
|
);
|
|
59
71
|
await app.init();
|
|
60
72
|
prisma = app.get(PrismaService);
|
|
61
73
|
dispatcher = app.get(OutboxDispatcherService);
|
|
74
|
+
await prisma.formOutboxJob.updateMany({
|
|
75
|
+
where: { status: { in: ['PENDING', 'PROCESSING'] } },
|
|
76
|
+
data: {
|
|
77
|
+
status: 'FAILED',
|
|
78
|
+
claimedBy: null,
|
|
79
|
+
lastError: 'parked by forms-webhooks e2e setup',
|
|
80
|
+
},
|
|
81
|
+
});
|
|
62
82
|
});
|
|
63
83
|
|
|
64
84
|
afterAll(async () => {
|
|
@@ -73,7 +93,9 @@ describe('Forms submission webhooks (e2e)', () => {
|
|
|
73
93
|
const response = await request(app.getHttpServer())
|
|
74
94
|
.post(`/organisations/${orgId}/forms`)
|
|
75
95
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
76
|
-
.send({
|
|
96
|
+
.send({
|
|
97
|
+
definition: makeDefinition([{ url: 'https://hooks.example.com/x' }]),
|
|
98
|
+
})
|
|
77
99
|
.expect(422);
|
|
78
100
|
|
|
79
101
|
const codes = issueCodes(response.body);
|
|
@@ -87,7 +109,9 @@ describe('Forms submission webhooks (e2e)', () => {
|
|
|
87
109
|
const response = await request(app.getHttpServer())
|
|
88
110
|
.post(`/organisations/${orgId}/forms`)
|
|
89
111
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
90
|
-
.send({
|
|
112
|
+
.send({
|
|
113
|
+
definition: makeDefinition([{ url: 'http://hooks.example.com/x' }]),
|
|
114
|
+
})
|
|
91
115
|
.expect(422);
|
|
92
116
|
|
|
93
117
|
expect(issueCodes(response.body)).toContain('WEBHOOK_URL_INSECURE');
|
|
@@ -105,7 +129,10 @@ describe('Forms submission webhooks (e2e)', () => {
|
|
|
105
129
|
const submit = await request(app.getHttpServer())
|
|
106
130
|
.post(`/organisations/${orgId}/forms/webhook-form/submissions`)
|
|
107
131
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
108
|
-
.send({
|
|
132
|
+
.send({
|
|
133
|
+
mode: 'submit',
|
|
134
|
+
data: { title: 'Hello', apiKey: 'super-secret-key' },
|
|
135
|
+
})
|
|
109
136
|
.expect(200);
|
|
110
137
|
const submissionId = submit.body.submissionId as string;
|
|
111
138
|
|
|
@@ -116,6 +143,9 @@ describe('Forms submission webhooks (e2e)', () => {
|
|
|
116
143
|
expect(job!.type).toBe('webhook');
|
|
117
144
|
expect(job!.status).toBe('PENDING');
|
|
118
145
|
expect(job!.orgId).toBe(orgId);
|
|
146
|
+
const payload = job!.payload as Record<string, unknown>;
|
|
147
|
+
expect(payload.secret).toBeUndefined();
|
|
148
|
+
expect(payload.signature).toEqual(expect.stringMatching(/^sha256=/));
|
|
119
149
|
|
|
120
150
|
const before = received.length;
|
|
121
151
|
await dispatcher.runOnce();
|
|
@@ -134,8 +164,11 @@ describe('Forms submission webhooks (e2e)', () => {
|
|
|
134
164
|
const expectedSignature =
|
|
135
165
|
'sha256=' + createHmac('sha256', 'test-secret').update(hit.body).digest('hex');
|
|
136
166
|
expect(hit.signature).toBe(expectedSignature);
|
|
167
|
+
expect(hit.idempotencyKey).toBe(`${submissionId}:webhook:0`);
|
|
137
168
|
|
|
138
|
-
const delivered = await prisma.formOutboxJob.findFirst({
|
|
169
|
+
const delivered = await prisma.formOutboxJob.findFirst({
|
|
170
|
+
where: { id: job!.id },
|
|
171
|
+
});
|
|
139
172
|
expect(delivered!.status).toBe('DONE');
|
|
140
173
|
|
|
141
174
|
const audit = await prisma.auditLog.findFirst({
|
|
@@ -145,6 +178,43 @@ describe('Forms submission webhooks (e2e)', () => {
|
|
|
145
178
|
expect(audit!.actorUserId).toBe(userId);
|
|
146
179
|
});
|
|
147
180
|
|
|
181
|
+
it('sanitizes legacy webhook payloads that still contain a secret', async () => {
|
|
182
|
+
const { orgId, userId } = await createUserAndOrg('wh-legacy-secret');
|
|
183
|
+
const body = { event: 'submitted', formKey: 'legacy-webhook' };
|
|
184
|
+
const job = await prisma.formOutboxJob.create({
|
|
185
|
+
data: {
|
|
186
|
+
type: 'webhook',
|
|
187
|
+
payload: {
|
|
188
|
+
url: `http://127.0.0.1:${port}/legacy`,
|
|
189
|
+
body,
|
|
190
|
+
secret: 'legacy-secret',
|
|
191
|
+
},
|
|
192
|
+
status: 'PENDING',
|
|
193
|
+
attempts: 0,
|
|
194
|
+
maxAttempts: 3,
|
|
195
|
+
orgId,
|
|
196
|
+
actorUserId: userId,
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const before = received.length;
|
|
201
|
+
await dispatcher.runOnce();
|
|
202
|
+
|
|
203
|
+
const hits = received.slice(before).filter((entry) => entry.url === '/legacy');
|
|
204
|
+
expect(hits).toHaveLength(1);
|
|
205
|
+
const expectedSignature =
|
|
206
|
+
'sha256=' + createHmac('sha256', 'legacy-secret').update(hits[0]!.body).digest('hex');
|
|
207
|
+
expect(hits[0]!.signature).toBe(expectedSignature);
|
|
208
|
+
|
|
209
|
+
const delivered = await prisma.formOutboxJob.findUnique({ where: { id: job.id } });
|
|
210
|
+
expect(delivered?.status).toBe('DONE');
|
|
211
|
+
const sanitized = delivered!.payload as Record<string, unknown>;
|
|
212
|
+
expect(sanitized.secret).toBeUndefined();
|
|
213
|
+
expect(sanitized.body).toBeUndefined();
|
|
214
|
+
expect(sanitized.rawBody).toBe(JSON.stringify(body));
|
|
215
|
+
expect(sanitized.signature).toBe(expectedSignature);
|
|
216
|
+
});
|
|
217
|
+
|
|
148
218
|
it('retries failed webhook deliveries with backoff', async () => {
|
|
149
219
|
const { accessToken, orgId } = await createUserAndOrg('wh-retry');
|
|
150
220
|
await allowHosts(accessToken, orgId, ['127.0.0.1']);
|
|
@@ -171,6 +241,69 @@ describe('Forms submission webhooks (e2e)', () => {
|
|
|
171
241
|
expect(job!.runAfter!.getTime()).toBeGreaterThan(Date.now());
|
|
172
242
|
});
|
|
173
243
|
|
|
244
|
+
it('does not follow webhook redirects during delivery', async () => {
|
|
245
|
+
const { accessToken, orgId } = await createUserAndOrg('wh-redirect');
|
|
246
|
+
await allowHosts(accessToken, orgId, ['127.0.0.1']);
|
|
247
|
+
await createAndPublish(
|
|
248
|
+
accessToken,
|
|
249
|
+
orgId,
|
|
250
|
+
makeDefinition([{ url: `http://127.0.0.1:${port}/redirect-metadata` }]),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const submit = await request(app.getHttpServer())
|
|
254
|
+
.post(`/organisations/${orgId}/forms/webhook-form/submissions`)
|
|
255
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
256
|
+
.send({ mode: 'submit', data: { title: 'Redirect me' } })
|
|
257
|
+
.expect(200);
|
|
258
|
+
|
|
259
|
+
const before = received.length;
|
|
260
|
+
await dispatcher.runOnce();
|
|
261
|
+
|
|
262
|
+
const job = await prisma.formOutboxJob.findFirst({
|
|
263
|
+
where: { idempotencyKey: `${submit.body.submissionId}:webhook:0` },
|
|
264
|
+
});
|
|
265
|
+
expect(job!.status).toBe('PENDING');
|
|
266
|
+
expect(job!.attempts).toBe(1);
|
|
267
|
+
expect(job!.lastError).toContain('redirects are not allowed');
|
|
268
|
+
const redirectHits = received
|
|
269
|
+
.slice(before)
|
|
270
|
+
.filter((entry) => entry.url === '/redirect-metadata');
|
|
271
|
+
expect(redirectHits).toHaveLength(1);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('rejects private and link-local webhook destinations at delivery in production', async () => {
|
|
275
|
+
const { orgId, userId } = await createUserAndOrg('wh-private-delivery');
|
|
276
|
+
const previousNodeEnv = process.env.NODE_ENV;
|
|
277
|
+
process.env.NODE_ENV = 'production';
|
|
278
|
+
try {
|
|
279
|
+
const job = await prisma.formOutboxJob.create({
|
|
280
|
+
data: {
|
|
281
|
+
type: 'webhook',
|
|
282
|
+
payload: {
|
|
283
|
+
url: 'https://169.254.169.254/latest/meta-data/',
|
|
284
|
+
body: { event: 'submitted' },
|
|
285
|
+
},
|
|
286
|
+
status: 'PENDING',
|
|
287
|
+
attempts: 0,
|
|
288
|
+
maxAttempts: 3,
|
|
289
|
+
orgId,
|
|
290
|
+
actorUserId: userId,
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await dispatcher.runOnce();
|
|
295
|
+
|
|
296
|
+
const retried = await prisma.formOutboxJob.findUnique({
|
|
297
|
+
where: { id: job.id },
|
|
298
|
+
});
|
|
299
|
+
expect(retried!.status).toBe('PENDING');
|
|
300
|
+
expect(retried!.attempts).toBe(1);
|
|
301
|
+
expect(retried!.lastError).toContain('private or reserved address');
|
|
302
|
+
} finally {
|
|
303
|
+
process.env.NODE_ENV = previousNodeEnv;
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
174
307
|
it('does not enqueue webhooks for draft saves', async () => {
|
|
175
308
|
const { accessToken, orgId } = await createUserAndOrg('wh-draft');
|
|
176
309
|
await allowHosts(accessToken, orgId, ['127.0.0.1']);
|
|
@@ -198,10 +331,17 @@ describe('Forms submission webhooks (e2e)', () => {
|
|
|
198
331
|
version: 1,
|
|
199
332
|
title: 'Webhook form',
|
|
200
333
|
fields: [
|
|
201
|
-
{
|
|
334
|
+
{
|
|
335
|
+
type: 'text',
|
|
336
|
+
name: 'title',
|
|
337
|
+
validators: { required: true, maxLength: 200 },
|
|
338
|
+
},
|
|
202
339
|
{ type: 'password', name: 'apiKey' },
|
|
203
340
|
],
|
|
204
|
-
actions: {
|
|
341
|
+
actions: {
|
|
342
|
+
submit: ['validateAll', 'persist'],
|
|
343
|
+
saveDraft: ['persistDraft'],
|
|
344
|
+
},
|
|
205
345
|
settings: { webhooks },
|
|
206
346
|
};
|
|
207
347
|
}
|
|
@@ -229,7 +369,9 @@ describe('Forms submission webhooks (e2e)', () => {
|
|
|
229
369
|
.send({ definition })
|
|
230
370
|
.expect(201);
|
|
231
371
|
await request(app.getHttpServer())
|
|
232
|
-
.post(
|
|
372
|
+
.post(
|
|
373
|
+
`/organisations/${orgId}/forms/${definition.key as string}/versions/${created.body.version}/publish`,
|
|
374
|
+
)
|
|
233
375
|
.set('Authorization', `Bearer ${accessToken}`)
|
|
234
376
|
.expect(200);
|
|
235
377
|
return created.body as { version: number };
|
|
@@ -167,6 +167,19 @@ describe('Reports advanced lifecycle (e2e)', () => {
|
|
|
167
167
|
expect(download.headers['content-disposition']).toContain('attachment');
|
|
168
168
|
expect(download.text).toContain('Name');
|
|
169
169
|
expect(download.text.split(/\r?\n/).filter(Boolean).length).toBe(3); // header + 2 rows
|
|
170
|
+
|
|
171
|
+
await prisma.reportExportJob.update({
|
|
172
|
+
where: { id: jobId },
|
|
173
|
+
data: { updatedAt: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000) },
|
|
174
|
+
});
|
|
175
|
+
await expect(dispatcher.cleanupExpiredFiles()).resolves.toBeGreaterThanOrEqual(1);
|
|
176
|
+
|
|
177
|
+
const expired = await prisma.reportExportJob.findUnique({ where: { id: jobId } });
|
|
178
|
+
expect(expired?.fileId).toBeNull();
|
|
179
|
+
await api()
|
|
180
|
+
.get(`/organisations/${orgId}/reports/exports/${jobId}/download`)
|
|
181
|
+
.set(auth(accessToken))
|
|
182
|
+
.expect(404);
|
|
170
183
|
});
|
|
171
184
|
|
|
172
185
|
it('streams a structurally valid XLSX synchronously', async () => {
|
|
@@ -210,6 +210,54 @@ describe('Reports query lifecycle (e2e)', () => {
|
|
|
210
210
|
expect(tagged.body.rows[0].$tags).toContain('shortlisted');
|
|
211
211
|
});
|
|
212
212
|
|
|
213
|
+
it('isolates report query, export, and byIds actions across organisations', async () => {
|
|
214
|
+
const orgA = await createUserAndOrg('rep-ten-a');
|
|
215
|
+
const orgB = await createUserAndOrg('rep-ten-b');
|
|
216
|
+
const keyA = uniqueKey('ten-a');
|
|
217
|
+
const keyB = uniqueKey('ten-b');
|
|
218
|
+
await publishOrgMembers(orgA.accessToken, orgA.orgId, keyA);
|
|
219
|
+
await publishOrgMembers(orgB.accessToken, orgB.orgId, keyB);
|
|
220
|
+
|
|
221
|
+
const orgBRows = await api()
|
|
222
|
+
.post(`/organisations/${orgB.orgId}/reports/${keyB}/query`)
|
|
223
|
+
.set(auth(orgB.accessToken))
|
|
224
|
+
.send({})
|
|
225
|
+
.expect(200);
|
|
226
|
+
const orgBRowId = orgBRows.body.rows[0].$id as string;
|
|
227
|
+
|
|
228
|
+
await api()
|
|
229
|
+
.post(`/organisations/${orgB.orgId}/reports/${keyB}/query`)
|
|
230
|
+
.set(auth(orgA.accessToken))
|
|
231
|
+
.send({})
|
|
232
|
+
.expect(403);
|
|
233
|
+
|
|
234
|
+
await api()
|
|
235
|
+
.post(`/organisations/${orgB.orgId}/reports/${keyB}/export`)
|
|
236
|
+
.set(auth(orgA.accessToken))
|
|
237
|
+
.send({ format: 'csv' })
|
|
238
|
+
.expect(403);
|
|
239
|
+
|
|
240
|
+
await api()
|
|
241
|
+
.post(`/organisations/${orgB.orgId}/reports/${keyB}/actions/manageTags`)
|
|
242
|
+
.set(auth(orgA.accessToken))
|
|
243
|
+
.send({ selection: { byIds: [orgBRowId] }, input: { add: ['cross-org-leak'] } })
|
|
244
|
+
.expect(403);
|
|
245
|
+
|
|
246
|
+
const crossOrgIdSelection = await api()
|
|
247
|
+
.post(`/organisations/${orgA.orgId}/reports/${keyA}/actions/manageTags`)
|
|
248
|
+
.set(auth(orgA.accessToken))
|
|
249
|
+
.send({ selection: { byIds: [orgBRowId] }, input: { add: ['cross-org-leak'] } })
|
|
250
|
+
.expect(200);
|
|
251
|
+
expect(crossOrgIdSelection.body.affectedRows).toBe(0);
|
|
252
|
+
|
|
253
|
+
const orgBTagged = await api()
|
|
254
|
+
.post(`/organisations/${orgB.orgId}/reports/${keyB}/query`)
|
|
255
|
+
.set(auth(orgB.accessToken))
|
|
256
|
+
.send({ filters: [{ column: '$tags', op: 'hasTag', value: 'cross-org-leak' }] })
|
|
257
|
+
.expect(200);
|
|
258
|
+
expect(orgBTagged.body.rows).toHaveLength(0);
|
|
259
|
+
});
|
|
260
|
+
|
|
213
261
|
it('streams a snapshot-consistent CSV export, gated by reports.export', async () => {
|
|
214
262
|
const { accessToken, orgId } = await createUserAndOrg('rep-export');
|
|
215
263
|
const key = uniqueKey('export');
|
|
@@ -255,6 +303,10 @@ describe('Reports query lifecycle (e2e)', () => {
|
|
|
255
303
|
return request(app.getHttpServer());
|
|
256
304
|
}
|
|
257
305
|
|
|
306
|
+
function auth(token: string) {
|
|
307
|
+
return { Authorization: `Bearer ${token}` };
|
|
308
|
+
}
|
|
309
|
+
|
|
258
310
|
function uniqueSuffix() {
|
|
259
311
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
260
312
|
}
|
|
@@ -31,12 +31,18 @@ describe('Reports performance tiers + schema check (e2e)', () => {
|
|
|
31
31
|
process.env.REPORTS_EXPORT_WORKER_ENABLED = 'false';
|
|
32
32
|
|
|
33
33
|
const { AppModule } = await import('../src/app.module');
|
|
34
|
-
const moduleRef = await Test.createTestingModule({
|
|
34
|
+
const moduleRef = await Test.createTestingModule({
|
|
35
|
+
imports: [AppModule],
|
|
36
|
+
}).compile();
|
|
35
37
|
|
|
36
38
|
app = moduleRef.createNestApplication();
|
|
37
39
|
app.useGlobalFilters(new HttpExceptionFilter());
|
|
38
40
|
app.useGlobalPipes(
|
|
39
|
-
new ValidationPipe({
|
|
41
|
+
new ValidationPipe({
|
|
42
|
+
forbidNonWhitelisted: true,
|
|
43
|
+
transform: true,
|
|
44
|
+
whitelist: true,
|
|
45
|
+
}),
|
|
40
46
|
);
|
|
41
47
|
await app.init();
|
|
42
48
|
prisma = app.get(PrismaService);
|
|
@@ -59,7 +65,11 @@ describe('Reports performance tiers + schema check (e2e)', () => {
|
|
|
59
65
|
const def = indexedDefinition(key, sourceKey);
|
|
60
66
|
|
|
61
67
|
try {
|
|
62
|
-
await api()
|
|
68
|
+
await api()
|
|
69
|
+
.post(`/organisations/${orgId}/reports`)
|
|
70
|
+
.set(auth(accessToken))
|
|
71
|
+
.send(def)
|
|
72
|
+
.expect(201);
|
|
63
73
|
|
|
64
74
|
// Publish runs the catalog lint: the declared indexes do not exist yet,
|
|
65
75
|
// so it fails CONSTRUCTIVELY with the exact CREATE INDEX SQL (§8).
|
|
@@ -71,8 +81,12 @@ describe('Reports performance tiers + schema check (e2e)', () => {
|
|
|
71
81
|
expect(JSON.stringify(failed.body)).toContain(statusIdx);
|
|
72
82
|
|
|
73
83
|
// Apply the suggested indexes (org-leading btrees on the physical columns).
|
|
74
|
-
await prisma.$executeRawUnsafe(
|
|
75
|
-
|
|
84
|
+
await prisma.$executeRawUnsafe(
|
|
85
|
+
`CREATE INDEX "${statusIdx}" ON "Membership" ("orgId", "status")`,
|
|
86
|
+
);
|
|
87
|
+
await prisma.$executeRawUnsafe(
|
|
88
|
+
`CREATE INDEX "${joinedIdx}" ON "Membership" ("orgId", "createdAt")`,
|
|
89
|
+
);
|
|
76
90
|
|
|
77
91
|
// The SAME draft now publishes: catalog + plan lint pass.
|
|
78
92
|
await api()
|
|
@@ -113,15 +127,35 @@ describe('Reports performance tiers + schema check (e2e)', () => {
|
|
|
113
127
|
title: 'Member stats (materialized)',
|
|
114
128
|
source: { kind: 'custom', key: sourceKey },
|
|
115
129
|
columns: [
|
|
116
|
-
{
|
|
117
|
-
|
|
130
|
+
{
|
|
131
|
+
id: 'status',
|
|
132
|
+
header: 'Status',
|
|
133
|
+
columnId: 'status',
|
|
134
|
+
type: 'enum',
|
|
135
|
+
filterable: true,
|
|
136
|
+
enum: ['ACTIVE', 'SUSPENDED', 'REVOKED'],
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: 'joinedAt',
|
|
140
|
+
header: 'Joined',
|
|
141
|
+
columnId: 'joinedAt',
|
|
142
|
+
type: 'datetime',
|
|
143
|
+
sortable: true,
|
|
144
|
+
},
|
|
118
145
|
],
|
|
119
146
|
defaultSort: [{ column: 'joinedAt', dir: 'desc' }],
|
|
120
147
|
performanceTier: 'materialized',
|
|
121
148
|
};
|
|
122
149
|
|
|
123
|
-
await api()
|
|
124
|
-
|
|
150
|
+
await api()
|
|
151
|
+
.post(`/organisations/${orgId}/reports`)
|
|
152
|
+
.set(auth(accessToken))
|
|
153
|
+
.send(def)
|
|
154
|
+
.expect(201);
|
|
155
|
+
await api()
|
|
156
|
+
.post(`/organisations/${orgId}/reports/${key}/publish`)
|
|
157
|
+
.set(auth(accessToken))
|
|
158
|
+
.expect(200);
|
|
125
159
|
|
|
126
160
|
const result = await api()
|
|
127
161
|
.post(`/organisations/${orgId}/reports/${key}/query`)
|
|
@@ -161,8 +195,22 @@ describe('Reports performance tiers + schema check (e2e)', () => {
|
|
|
161
195
|
title: 'Indexed members',
|
|
162
196
|
source: { kind: 'custom', key: sourceKey },
|
|
163
197
|
columns: [
|
|
164
|
-
{
|
|
165
|
-
|
|
198
|
+
{
|
|
199
|
+
id: 'status',
|
|
200
|
+
header: 'Status',
|
|
201
|
+
columnId: 'status',
|
|
202
|
+
type: 'enum',
|
|
203
|
+
filterable: true,
|
|
204
|
+
sortable: true,
|
|
205
|
+
enum: ['ACTIVE', 'SUSPENDED', 'REVOKED'],
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: 'joinedAt',
|
|
209
|
+
header: 'Joined',
|
|
210
|
+
columnId: 'joinedAt',
|
|
211
|
+
type: 'datetime',
|
|
212
|
+
sortable: true,
|
|
213
|
+
},
|
|
166
214
|
],
|
|
167
215
|
defaultSort: [{ column: 'joinedAt', dir: 'desc' }],
|
|
168
216
|
performanceTier: 'indexed',
|
|
@@ -183,7 +231,12 @@ describe('Reports performance tiers + schema check (e2e)', () => {
|
|
|
183
231
|
filterable: true,
|
|
184
232
|
sortable: true,
|
|
185
233
|
nullable: false,
|
|
186
|
-
index: {
|
|
234
|
+
index: {
|
|
235
|
+
name: statusIdx,
|
|
236
|
+
kind: 'btree',
|
|
237
|
+
table: 'Membership',
|
|
238
|
+
expr: '"orgId", "status"',
|
|
239
|
+
},
|
|
187
240
|
},
|
|
188
241
|
{
|
|
189
242
|
id: 'joinedAt',
|
|
@@ -192,11 +245,20 @@ describe('Reports performance tiers + schema check (e2e)', () => {
|
|
|
192
245
|
valueKind: 'native',
|
|
193
246
|
sortable: true,
|
|
194
247
|
nullable: false,
|
|
195
|
-
index: {
|
|
248
|
+
index: {
|
|
249
|
+
name: joinedIdx,
|
|
250
|
+
kind: 'btree',
|
|
251
|
+
table: 'Membership',
|
|
252
|
+
expr: '"orgId", "createdAt"',
|
|
253
|
+
},
|
|
196
254
|
},
|
|
197
255
|
],
|
|
198
256
|
}),
|
|
199
|
-
baseQuery: () => ({
|
|
257
|
+
baseQuery: () => ({
|
|
258
|
+
from: '"Membership" m',
|
|
259
|
+
orgColumn: 'm."orgId"',
|
|
260
|
+
primaryTable: 'Membership',
|
|
261
|
+
}),
|
|
200
262
|
};
|
|
201
263
|
}
|
|
202
264
|
|
|
@@ -207,13 +269,31 @@ describe('Reports performance tiers + schema check (e2e)', () => {
|
|
|
207
269
|
rowId: 'm."id"',
|
|
208
270
|
orgScoped: true,
|
|
209
271
|
columns: [
|
|
210
|
-
{
|
|
211
|
-
|
|
272
|
+
{
|
|
273
|
+
id: 'status',
|
|
274
|
+
sql: '"status"',
|
|
275
|
+
type: 'enum',
|
|
276
|
+
filterable: true,
|
|
277
|
+
nullable: false,
|
|
278
|
+
},
|
|
279
|
+
{
|
|
280
|
+
id: 'joinedAt',
|
|
281
|
+
sql: '"joinedAt"',
|
|
282
|
+
type: 'datetime',
|
|
283
|
+
valueKind: 'native',
|
|
284
|
+
sortable: true,
|
|
285
|
+
nullable: false,
|
|
286
|
+
},
|
|
212
287
|
],
|
|
213
|
-
materialization: {
|
|
288
|
+
materialization: {
|
|
289
|
+
relation: mv,
|
|
290
|
+
orgColumn: '"orgId"',
|
|
291
|
+
rowIdColumn: 'rowId',
|
|
292
|
+
stalenessSeconds: 300,
|
|
293
|
+
},
|
|
214
294
|
}),
|
|
215
295
|
baseQuery: () => ({ from: mv, orgColumn: '"orgId"', primaryTable: mv }),
|
|
216
|
-
freshAsOf:
|
|
296
|
+
freshAsOf: () => Promise.resolve(new Date()),
|
|
217
297
|
};
|
|
218
298
|
}
|
|
219
299
|
|
|
@@ -233,7 +313,9 @@ describe('Reports performance tiers + schema check (e2e)', () => {
|
|
|
233
313
|
|
|
234
314
|
/** A lowercase alphanumeric tag safe for SQL identifiers and report keys. */
|
|
235
315
|
function uniqueTag() {
|
|
236
|
-
return uniqueSuffix()
|
|
316
|
+
return uniqueSuffix()
|
|
317
|
+
.replace(/[^a-z0-9]/gi, '')
|
|
318
|
+
.toLowerCase();
|
|
237
319
|
}
|
|
238
320
|
|
|
239
321
|
function uniqueKey(label: string) {
|
|
@@ -244,7 +326,11 @@ describe('Reports performance tiers + schema check (e2e)', () => {
|
|
|
244
326
|
const suffix = uniqueSuffix();
|
|
245
327
|
const signup = await api()
|
|
246
328
|
.post('/auth/signup')
|
|
247
|
-
.send({
|
|
329
|
+
.send({
|
|
330
|
+
email: `${label}-${suffix}@example.com`,
|
|
331
|
+
password: 'test-password-123',
|
|
332
|
+
displayName: label,
|
|
333
|
+
})
|
|
248
334
|
.expect(201);
|
|
249
335
|
const accessToken = signup.body.accessToken as string;
|
|
250
336
|
const org = await api()
|