@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.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +3 -0
  3. package/template/_package.json +4 -1
  4. package/template/docs/FORMS.md +34 -15
  5. package/template/docs/FORMS_CHECKLIST.md +34 -26
  6. package/template/docs/REPORTS.md +12 -3
  7. package/template/docs/REPORTS_CHECKLIST.md +150 -95
  8. package/template/prisma/migrations/20260616000000_add_form_outbox_claimed_by/migration.sql +5 -0
  9. package/template/prisma/schema.prisma +5 -1
  10. package/template/src/app.module.ts +4 -3
  11. package/template/src/config/env.validation.ts +3 -0
  12. package/template/src/config/forms.config.ts +1 -0
  13. package/template/src/config/reports.config.ts +2 -0
  14. package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts +319 -0
  15. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +64 -16
  16. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +40 -18
  17. package/template/src/modules/forms/forms.module.ts +2 -0
  18. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +82 -59
  19. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +81 -0
  20. package/template/src/modules/reports/application/services/reports-exports.service.ts +6 -2
  21. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +43 -30
  22. package/template/test/forms-captcha.e2e-spec.ts +163 -0
  23. package/template/test/forms-files.e2e-spec.ts +1 -1
  24. package/template/test/forms-outbox.e2e-spec.ts +271 -10
  25. package/template/test/forms-public.e2e-spec.ts +24 -0
  26. package/template/test/forms-throttling.e2e-spec.ts +146 -0
  27. package/template/test/forms-webhooks.e2e-spec.ts +150 -8
  28. package/template/test/jest-e2e.json +1 -0
  29. package/template/test/reports-advanced.e2e-spec.ts +13 -0
  30. package/template/test/reports-query.e2e-spec.ts +52 -0
  31. 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({ forbidNonWhitelisted: true, transform: true, whitelist: true }),
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({ definition: makeDefinition([{ url: 'https://hooks.example.com/x' }]) })
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({ definition: makeDefinition([{ url: 'http://hooks.example.com/x' }]) })
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({ mode: 'submit', data: { title: 'Hello', apiKey: 'super-secret-key' } })
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({ where: { id: job!.id } });
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
- { type: 'text', name: 'title', validators: { required: true, maxLength: 200 } },
334
+ {
335
+ type: 'text',
336
+ name: 'title',
337
+ validators: { required: true, maxLength: 200 },
338
+ },
202
339
  { type: 'password', name: 'apiKey' },
203
340
  ],
204
- actions: { submit: ['validateAll', 'persist'], saveDraft: ['persistDraft'] },
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(`/organisations/${orgId}/forms/${definition.key as string}/versions/${created.body.version}/publish`)
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 };
@@ -3,6 +3,7 @@
3
3
  "rootDir": "..",
4
4
  "testEnvironment": "node",
5
5
  "testRegex": ".e2e-spec.ts$",
6
+ "testTimeout": 30000,
6
7
  "transform": {
7
8
  "^.+\\.(t|j)s$": "ts-jest"
8
9
  }
@@ -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({ imports: [AppModule] }).compile();
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({ forbidNonWhitelisted: true, transform: true, whitelist: true }),
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().post(`/organisations/${orgId}/reports`).set(auth(accessToken)).send(def).expect(201);
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(`CREATE INDEX "${statusIdx}" ON "Membership" ("orgId", "status")`);
75
- await prisma.$executeRawUnsafe(`CREATE INDEX "${joinedIdx}" ON "Membership" ("orgId", "createdAt")`);
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
- { 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 },
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().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);
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
- { 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 },
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: { name: statusIdx, kind: 'btree', table: 'Membership', expr: '"orgId", "status"' },
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: { name: joinedIdx, kind: 'btree', table: 'Membership', expr: '"orgId", "createdAt"' },
248
+ index: {
249
+ name: joinedIdx,
250
+ kind: 'btree',
251
+ table: 'Membership',
252
+ expr: '"orgId", "createdAt"',
253
+ },
196
254
  },
197
255
  ],
198
256
  }),
199
- baseQuery: () => ({ from: '"Membership" m', orgColumn: 'm."orgId"', primaryTable: 'Membership' }),
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
- { id: 'status', sql: '"status"', type: 'enum', filterable: true, nullable: false },
211
- { id: 'joinedAt', sql: '"joinedAt"', type: 'datetime', valueKind: 'native', sortable: true, nullable: false },
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: { relation: mv, orgColumn: '"orgId"', rowIdColumn: 'rowId', stalenessSeconds: 300 },
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: async () => new Date(),
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().replace(/[^a-z0-9]/gi, '').toLowerCase();
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({ email: `${label}-${suffix}@example.com`, password: 'test-password-123', displayName: label })
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()