@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,95 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import type { CatalogColumn, CatalogIndex, CatalogPort } from '@ftisindia/report-builder';
3
+ import { PrismaService } from '../../../database/prisma/prisma.service';
4
+
5
+ /**
6
+ * Catalog introspection for the publish-time lints (report design §5.2/§8):
7
+ * declared indexes are verified against pg_indexes, generated columns against
8
+ * information_schema, tier caps against the planner's pg_class estimates.
9
+ * Every lookup is scoped to current_schema() so other schemas (extensions,
10
+ * shadow databases) cannot shadow or leak into lint verdicts.
11
+ */
12
+ @Injectable()
13
+ export class PrismaReportsCatalog implements CatalogPort {
14
+ constructor(private readonly prisma: PrismaService) {}
15
+
16
+ async indexesFor(table: string): Promise<CatalogIndex[]> {
17
+ const rows = await this.prisma.$queryRawUnsafe<
18
+ Array<{ indexname: string; tablename: string; indexdef: string }>
19
+ >(
20
+ `SELECT indexname, tablename, indexdef
21
+ FROM pg_indexes
22
+ WHERE schemaname = current_schema()
23
+ AND tablename = $1`,
24
+ table,
25
+ );
26
+
27
+ return rows.map((row) => ({
28
+ name: row.indexname,
29
+ table: row.tablename,
30
+ indexDef: row.indexdef,
31
+ isUnique: row.indexdef.includes('UNIQUE'),
32
+ }));
33
+ }
34
+
35
+ async columnsFor(table: string): Promise<CatalogColumn[]> {
36
+ const rows = await this.prisma.$queryRawUnsafe<
37
+ Array<{ column_name: string; data_type: string; is_generated: string }>
38
+ >(
39
+ `SELECT column_name, data_type, is_generated
40
+ FROM information_schema.columns
41
+ WHERE table_schema = current_schema()
42
+ AND table_name = $1`,
43
+ table,
44
+ );
45
+
46
+ return rows.map((row) => ({
47
+ name: row.column_name,
48
+ dataType: row.data_type,
49
+ isGenerated: row.is_generated === 'ALWAYS',
50
+ }));
51
+ }
52
+
53
+ async hasExtension(name: string): Promise<boolean> {
54
+ const rows = await this.prisma.$queryRawUnsafe<Array<{ found: number }>>(
55
+ `SELECT 1 AS found FROM pg_extension WHERE extname = $1`,
56
+ name,
57
+ );
58
+
59
+ return rows.length > 0;
60
+ }
61
+
62
+ async estimatedRowCount(table: string): Promise<number> {
63
+ const rows = await this.prisma.$queryRawUnsafe<Array<{ reltuples: unknown }>>(
64
+ `SELECT c.reltuples
65
+ FROM pg_class c
66
+ JOIN pg_namespace n ON n.oid = c.relnamespace
67
+ WHERE n.nspname = current_schema()
68
+ AND c.relname = $1
69
+ AND c.relkind IN ('r', 'p')`,
70
+ table,
71
+ );
72
+
73
+ if (rows.length === 0) {
74
+ return 0;
75
+ }
76
+ const estimate = Number(rows[0].reltuples);
77
+ return Number.isFinite(estimate) ? Math.max(0, estimate) : 0;
78
+ }
79
+
80
+ async relationExists(name: string): Promise<boolean> {
81
+ // Tables, views, materialized views, and partitioned tables all count —
82
+ // the materialized tier reads from any of them (report design §8).
83
+ const rows = await this.prisma.$queryRawUnsafe<Array<{ found: number }>>(
84
+ `SELECT 1 AS found
85
+ FROM pg_class c
86
+ JOIN pg_namespace n ON n.oid = c.relnamespace
87
+ WHERE n.nspname = current_schema()
88
+ AND c.relname = $1
89
+ AND c.relkind IN ('r', 'v', 'm', 'p')`,
90
+ name,
91
+ );
92
+
93
+ return rows.length > 0;
94
+ }
95
+ }
@@ -0,0 +1,103 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { Prisma } from '@prisma/client';
3
+ import { ReportQueryBudgetError, type QueryExecutor, type SqlRow } from '@ftisindia/report-builder';
4
+ import { PrismaService } from '../../../database/prisma/prisma.service';
5
+
6
+ /** Hard ceiling on the per-statement budget — matches Prisma's own tx limits. */
7
+ const MAX_STATEMENT_TIMEOUT_MS = 600_000;
8
+
9
+ function assertValidStatementTimeout(ms: number): void {
10
+ if (!Number.isInteger(ms) || ms < 1 || ms > MAX_STATEMENT_TIMEOUT_MS) {
11
+ throw new Error(
12
+ `Invalid report statement timeout: ${ms} (expected an integer between 1 and ${MAX_STATEMENT_TIMEOUT_MS} ms).`,
13
+ );
14
+ }
15
+ }
16
+
17
+ /** Postgres 57014 — 'canceling statement due to statement timeout'. */
18
+ function isStatementTimeout(error: unknown): boolean {
19
+ if (error instanceof Prisma.PrismaClientKnownRequestError) {
20
+ const meta = error.meta as { code?: unknown } | undefined;
21
+ if (meta?.code === '57014') {
22
+ return true;
23
+ }
24
+ }
25
+ const message = error instanceof Error ? error.message : '';
26
+ return message.includes('57014') || message.includes('canceling statement due to statement timeout');
27
+ }
28
+
29
+ /**
30
+ * Runs one compiler-built statement under a SET LOCAL statement_timeout on an
31
+ * open transaction client. SQL identifiers come only from code-owned manifests
32
+ * and every VALUE travels as a positional $1..$n bind; SET LOCAL cannot take
33
+ * binds, so the timeout — a VALIDATED INTEGER — is the one sanctioned
34
+ * interpolation exception (report design §2.1/§5.2).
35
+ *
36
+ * Shared between PrismaQueryExecutor (one-off transactions) and
37
+ * PrismaSnapshotRunner (the export's REPEATABLE READ transaction).
38
+ */
39
+ export async function runBoundedQuery(
40
+ client: Prisma.TransactionClient,
41
+ sql: string,
42
+ params: readonly unknown[],
43
+ opts: { statementTimeoutMs: number },
44
+ ): Promise<SqlRow[]> {
45
+ assertValidStatementTimeout(opts.statementTimeoutMs);
46
+ try {
47
+ await client.$executeRawUnsafe(`SET LOCAL statement_timeout = ${opts.statementTimeoutMs}`);
48
+ return await client.$queryRawUnsafe<SqlRow[]>(sql, ...params);
49
+ } catch (error) {
50
+ if (isStatementTimeout(error)) {
51
+ // The runtime backstop (§5.2): a pathological plan fails fast, typed.
52
+ throw new ReportQueryBudgetError();
53
+ }
54
+ throw error;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * EXPLAIN (FORMAT JSON) for the plan lint and estimated counts (§5.2/§5.4).
60
+ * Postgres returns one row shaped [{ "QUERY PLAN": <document> }]; drivers may
61
+ * surface the json column already parsed or as a string — the engine expects
62
+ * the parsed array-root document either way.
63
+ */
64
+ export async function runExplain(
65
+ client: Prisma.TransactionClient,
66
+ sql: string,
67
+ params: readonly unknown[],
68
+ ): Promise<unknown> {
69
+ const rows = await client.$queryRawUnsafe<Array<Record<string, unknown>>>(
70
+ `EXPLAIN (FORMAT JSON) ${sql}`,
71
+ ...params,
72
+ );
73
+ const plan = rows[0]?.['QUERY PLAN'];
74
+ return typeof plan === 'string' ? (JSON.parse(plan) as unknown) : plan;
75
+ }
76
+
77
+ /**
78
+ * The engine's SQL seam (report design §5) over Prisma. Each call runs inside
79
+ * its own short transaction because SET LOCAL is transaction-scoped — outside
80
+ * one it would be a silent no-op and the statement budget would not exist.
81
+ */
82
+ @Injectable()
83
+ export class PrismaQueryExecutor implements QueryExecutor {
84
+ constructor(private readonly prisma: PrismaService) {}
85
+
86
+ query(
87
+ sql: string,
88
+ params: readonly unknown[],
89
+ opts: { statementTimeoutMs: number },
90
+ ): Promise<SqlRow[]> {
91
+ assertValidStatementTimeout(opts.statementTimeoutMs);
92
+ return this.prisma.$transaction((tx) => runBoundedQuery(tx, sql, params, opts), {
93
+ // Keep Prisma's transaction window above the statement budget so the
94
+ // budget — not Prisma's 5s interactive-tx default — is what fires.
95
+ timeout: opts.statementTimeoutMs + 1000,
96
+ maxWait: 5000,
97
+ });
98
+ }
99
+
100
+ explain(sql: string, params: readonly unknown[]): Promise<unknown> {
101
+ return this.prisma.$transaction((tx) => runExplain(tx, sql, params));
102
+ }
103
+ }
@@ -0,0 +1,47 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { Prisma } from '@prisma/client';
3
+ import type { QueryExecutor, SnapshotRunner } from '@ftisindia/report-builder';
4
+ import { PrismaService } from '../../../database/prisma/prisma.service';
5
+ import { runBoundedQuery, runExplain } from './prisma-query-executor';
6
+
7
+ /**
8
+ * One REPEATABLE READ transaction per export (report design §9, finding #8):
9
+ * every keyset batch reads the same MVCC snapshot, so rows moving under the
10
+ * export can neither be skipped nor duplicated. The transaction is
11
+ * duration-bounded — an export that cannot finish inside the bound aborts
12
+ * (and the engine maps that to guidance: tighten filters or move the report
13
+ * to the materialized tier).
14
+ */
15
+ @Injectable()
16
+ export class PrismaSnapshotRunner implements SnapshotRunner {
17
+ constructor(private readonly prisma: PrismaService) {}
18
+
19
+ withSnapshot<T>(
20
+ opts: { maxDurationMs: number },
21
+ fn: (exec: QueryExecutor, asOf: Date) => Promise<T>,
22
+ ): Promise<T> {
23
+ return this.prisma.$transaction(
24
+ async (tx) => {
25
+ // Under REPEATABLE READ now() is the transaction (= snapshot) start
26
+ // time — the export's `asOf`, stamped into the job and file metadata.
27
+ const rows = await tx.$queryRawUnsafe<Array<{ now: unknown }>>('SELECT now() AS now');
28
+ const now = rows[0]?.now;
29
+ const asOf = now instanceof Date ? now : new Date();
30
+
31
+ // Executor bound to THIS transaction: same SET LOCAL budget + EXPLAIN
32
+ // logic as PrismaQueryExecutor, valid only while `fn` runs.
33
+ const exec: QueryExecutor = {
34
+ query: (sql, params, queryOpts) => runBoundedQuery(tx, sql, params, queryOpts),
35
+ explain: (sql, params) => runExplain(tx, sql, params),
36
+ };
37
+
38
+ return fn(exec, asOf);
39
+ },
40
+ {
41
+ isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,
42
+ timeout: opts.maxDurationMs,
43
+ maxWait: 5000,
44
+ },
45
+ );
46
+ }
47
+ }
@@ -0,0 +1,18 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import type { EngineTx, TxRunner } from '@ftisindia/report-builder';
3
+ import { PrismaService } from '../../../database/prisma/prisma.service';
4
+
5
+ /**
6
+ * The engine's transaction seam over PrismaService.$transaction. The opaque
7
+ * EngineTx brand is a Prisma.TransactionClient underneath; the stores cast it
8
+ * back. One TxRunner.run call is the engine's atomicity boundary (export job
9
+ * row + outbox enqueue, bulk-action ledger + audit — report design §6.3/§9).
10
+ */
11
+ @Injectable()
12
+ export class ReportsPrismaTxRunner implements TxRunner {
13
+ constructor(private readonly prisma: PrismaService) {}
14
+
15
+ run<T>(fn: (tx: EngineTx) => Promise<T>): Promise<T> {
16
+ return this.prisma.$transaction((tx) => fn(tx as unknown as EngineTx));
17
+ }
18
+ }
@@ -0,0 +1,61 @@
1
+ import { Injectable, OnModuleInit } from '@nestjs/common';
2
+ import { DiscoveryService, Reflector } from '@nestjs/core';
3
+ import {
4
+ ReportRowActionRegistry,
5
+ ReportSourceRegistry,
6
+ ReportTagService,
7
+ SourceProviderRegistry,
8
+ createCustomSourceProvider,
9
+ createManageTagsAction,
10
+ } from '@ftisindia/report-builder';
11
+ import type { ReportRowActionDef, ReportSourceDef } from '@ftisindia/report-builder';
12
+ import {
13
+ REPORT_ROW_ACTION_METADATA,
14
+ REPORT_SOURCE_METADATA,
15
+ } from './report-extension.decorators';
16
+
17
+ /**
18
+ * Populates the engine registries at startup — WITHOUT any form-builder
19
+ * dependency (the standalone guarantee extends to the glue, report design
20
+ * §3.2): the built-in manageTags action (§7), the 'custom' source provider
21
+ * over the code-owned source registry (the one provider the core ships), and
22
+ * a discovery scan for every app provider decorated with @ReportSource /
23
+ * @ReportRowAction (e.g. the org-members example source).
24
+ *
25
+ * Form-backed sources and the delegated editSubmission/updateStatus verbs are
26
+ * added by the OPTIONAL ReportsFormsModule, which registers the 'form'
27
+ * provider + actions into these same registries only when the app ships forms.
28
+ */
29
+ @Injectable()
30
+ export class ReportsRegistryBootstrapService implements OnModuleInit {
31
+ constructor(
32
+ private readonly discovery: DiscoveryService,
33
+ private readonly reflector: Reflector,
34
+ private readonly sourceRegistry: ReportSourceRegistry,
35
+ private readonly sourceProviders: SourceProviderRegistry,
36
+ private readonly actionRegistry: ReportRowActionRegistry,
37
+ private readonly tagService: ReportTagService,
38
+ ) {}
39
+
40
+ onModuleInit(): void {
41
+ this.actionRegistry.register(createManageTagsAction(this.tagService));
42
+ this.sourceProviders.register(createCustomSourceProvider(this.sourceRegistry));
43
+
44
+ for (const wrapper of this.discovery.getProviders()) {
45
+ const instance: unknown = wrapper.instance;
46
+ if (!instance || typeof instance !== 'object') {
47
+ continue;
48
+ }
49
+ const ctor = instance.constructor as object | undefined;
50
+ if (!ctor || ctor === Object) {
51
+ continue;
52
+ }
53
+ if (this.reflector.get<boolean>(REPORT_SOURCE_METADATA, ctor as never)) {
54
+ this.sourceRegistry.register(instance as ReportSourceDef);
55
+ }
56
+ if (this.reflector.get<boolean>(REPORT_ROW_ACTION_METADATA, ctor as never)) {
57
+ this.actionRegistry.register(instance as ReportRowActionDef);
58
+ }
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,14 @@
1
+ import { SetMetadata } from '@nestjs/common';
2
+
3
+ export const REPORT_SOURCE_METADATA = 'ftis:reports:source';
4
+ export const REPORT_ROW_ACTION_METADATA = 'ftis:reports:row-action';
5
+
6
+ /**
7
+ * The standardized extension story (report design §3.2/§6): implement the
8
+ * engine interface (ReportSourceDef / ReportRowActionDef), decorate the
9
+ * @Injectable class, list it as a provider — ReportsRegistryBootstrapService
10
+ * discovers and registers it at startup. Same three steps as the form
11
+ * registries.
12
+ */
13
+ export const ReportSource = () => SetMetadata(REPORT_SOURCE_METADATA, true);
14
+ export const ReportRowAction = () => SetMetadata(REPORT_ROW_ACTION_METADATA, true);
@@ -0,0 +1,28 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import type { EngineTx, ReportsJobQueue } from '@ftisindia/report-builder';
3
+
4
+ /**
5
+ * The async-export job queue (report design §9). The durable signal IS the
6
+ * `ReportExportJob` row the engine creates in the same transaction — the
7
+ * reports-owned ReportsExportDispatcherService polls that table for PENDING
8
+ * jobs. So `enqueue` is intentionally a no-op: there is no separate queue to
9
+ * write, and reports stays independent of the forms outbox.
10
+ *
11
+ * (An app that prefers an external queue can bind a different ReportsJobQueue
12
+ * — e.g. one that publishes to SQS — without changing the engine.)
13
+ */
14
+ @Injectable()
15
+ export class ReportsJobQueueNoop implements ReportsJobQueue {
16
+ async enqueue(
17
+ _job: {
18
+ type: string;
19
+ payload: Record<string, unknown>;
20
+ orgId: string;
21
+ actorUserId?: string | null;
22
+ idempotencyKey?: string;
23
+ },
24
+ _tx: EngineTx,
25
+ ): Promise<void> {
26
+ // The ReportExportJob row (committed with this tx) is the queue entry.
27
+ }
28
+ }
@@ -0,0 +1,42 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import type { ReportsContext } from '@ftisindia/report-builder';
3
+ import { RequestContextService } from '../../request-context/application/services/request-context.service';
4
+
5
+ /**
6
+ * Binds the engine's ReportsContext seam to the app's AsyncLocalStorage
7
+ * request context (report design §13). Worker contexts (async export jobs)
8
+ * are restored by the forms outbox dispatcher before the engine runs, so the
9
+ * same adapter serves HTTP and worker callers (ecosystem guide §3).
10
+ */
11
+ @Injectable()
12
+ export class RequestReportsContext implements ReportsContext {
13
+ constructor(private readonly ctx: RequestContextService) {}
14
+
15
+ requestId() {
16
+ return this.ctx.getRequestId();
17
+ }
18
+
19
+ source(): 'http' | 'worker' {
20
+ return this.ctx.get()?.source ?? 'worker';
21
+ }
22
+
23
+ userId() {
24
+ return this.ctx.getUserId();
25
+ }
26
+
27
+ orgId() {
28
+ return this.ctx.getOrgId();
29
+ }
30
+
31
+ permissionKeys() {
32
+ return this.ctx.getPermissions();
33
+ }
34
+
35
+ isOwner() {
36
+ return this.ctx.getRbacContext()?.isOwner ?? false;
37
+ }
38
+
39
+ assertOrgScope(orgId: string) {
40
+ this.ctx.assertOrgScope(orgId);
41
+ }
42
+ }
@@ -0,0 +1,116 @@
1
+ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
2
+ import { ConfigService } from '@nestjs/config';
3
+ import { Prisma } from '@prisma/client';
4
+ import {
5
+ EXPECTED_REPORTS_SCHEMA,
6
+ REPORTS_ENGINE_SCHEMA_VERSION,
7
+ REPORTS_PARTIAL_UNIQUE_SQL,
8
+ REPORTS_PRISMA_SNIPPET_PATH,
9
+ REQUIRED_REPORTS_INDEXES,
10
+ } from '@ftisindia/report-builder';
11
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
12
+
13
+ /**
14
+ * Boot-time schema compatibility check (ecosystem guide §10.1): the app owns
15
+ * schema.prisma and migrations; the engine ships the canonical snippet and a
16
+ * tables/columns manifest. On mismatch the app fails fast at startup with an
17
+ * actionable message instead of crashing mid-request with a cryptic Prisma
18
+ * error. Beyond tables/columns, the saved-view name uniques are PARTIAL
19
+ * unique indexes Prisma cannot represent (report design §12, finding #5) —
20
+ * they are verified via pg_indexes so a `migrate dev` regression cannot ship
21
+ * silently. Escape hatch: REPORTS_SCHEMA_CHECK=off.
22
+ */
23
+ @Injectable()
24
+ export class ReportsSchemaCheckService implements OnApplicationBootstrap {
25
+ private readonly logger = new Logger('ReportsSchemaCheck');
26
+
27
+ constructor(
28
+ private readonly prisma: PrismaService,
29
+ private readonly config: ConfigService,
30
+ ) {}
31
+
32
+ async onApplicationBootstrap(): Promise<void> {
33
+ if (this.config.get<string>('reports.schemaCheck') === 'off') {
34
+ this.logger.warn('Reports schema check is disabled (REPORTS_SCHEMA_CHECK=off).');
35
+ return;
36
+ }
37
+ await this.check();
38
+ this.logger.log(
39
+ `Reports schema check passed (engine schema version ${REPORTS_ENGINE_SCHEMA_VERSION}).`,
40
+ );
41
+ }
42
+
43
+ async check(): Promise<void> {
44
+ const tableProblems = await this.missingTablesOrColumns();
45
+ const indexProblems = await this.missingPartialUniques();
46
+ const problems = [...tableProblems, ...indexProblems];
47
+
48
+ if (problems.length === 0) {
49
+ return;
50
+ }
51
+
52
+ const lines = [
53
+ `The database does not match @ftisindia/report-builder engine schema version ${REPORTS_ENGINE_SCHEMA_VERSION}:`,
54
+ ...problems.map((problem) => ` - ${problem}`),
55
+ `Fix: copy the canonical models from "${REPORTS_PRISMA_SNIPPET_PATH}" (in node_modules) into prisma/schema.prisma and run "npx prisma migrate dev".`,
56
+ ];
57
+ if (indexProblems.length > 0) {
58
+ lines.push(
59
+ 'The saved-view name uniques are partial indexes Prisma cannot represent — re-apply them as raw SQL inside a migration:',
60
+ REPORTS_PARTIAL_UNIQUE_SQL,
61
+ );
62
+ }
63
+ lines.push('To bypass temporarily (NOT recommended), set REPORTS_SCHEMA_CHECK=off.');
64
+
65
+ throw new Error(lines.join('\n'));
66
+ }
67
+
68
+ private async missingTablesOrColumns(): Promise<string[]> {
69
+ const tables = EXPECTED_REPORTS_SCHEMA.map((expected) => expected.table);
70
+ const rows = await this.prisma.$queryRaw<Array<{ table_name: string; column_name: string }>>`
71
+ SELECT table_name, column_name
72
+ FROM information_schema.columns
73
+ WHERE table_schema = current_schema()
74
+ AND table_name IN (${Prisma.join(tables)})
75
+ `;
76
+
77
+ const present = new Map<string, Set<string>>();
78
+ for (const row of rows) {
79
+ const columns = present.get(row.table_name) ?? new Set<string>();
80
+ columns.add(row.column_name);
81
+ present.set(row.table_name, columns);
82
+ }
83
+
84
+ const problems: string[] = [];
85
+ for (const expected of EXPECTED_REPORTS_SCHEMA) {
86
+ const columns = present.get(expected.table);
87
+ if (!columns) {
88
+ problems.push(`missing table "${expected.table}"`);
89
+ continue;
90
+ }
91
+ const missingColumns = expected.columns.filter((column) => !columns.has(column));
92
+ if (missingColumns.length > 0) {
93
+ problems.push(
94
+ `table "${expected.table}" is missing column(s): ${missingColumns.join(', ')}`,
95
+ );
96
+ }
97
+ }
98
+
99
+ return problems;
100
+ }
101
+
102
+ private async missingPartialUniques(): Promise<string[]> {
103
+ const names = REQUIRED_REPORTS_INDEXES.map((index) => index.name);
104
+ const rows = await this.prisma.$queryRaw<Array<{ indexname: string }>>`
105
+ SELECT indexname
106
+ FROM pg_indexes
107
+ WHERE schemaname = current_schema()
108
+ AND indexname IN (${Prisma.join(names)})
109
+ `;
110
+
111
+ const present = new Set(rows.map((row) => row.indexname));
112
+ return REQUIRED_REPORTS_INDEXES.filter((index) => !present.has(index.name)).map(
113
+ (index) => `missing index "${index.name}" on "${index.table}" — ${index.description}`,
114
+ );
115
+ }
116
+ }
@@ -0,0 +1,92 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createReadStream, createWriteStream } from 'node:fs';
3
+ import { mkdir, rm } from 'node:fs/promises';
4
+ import { dirname, join, resolve, sep } from 'node:path';
5
+ import { Readable } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { Injectable } from '@nestjs/common';
8
+ import { ConfigService } from '@nestjs/config';
9
+ import type { ExportFileSink } from '@ftisindia/report-builder';
10
+
11
+ /**
12
+ * Reports-owned export file storage. Files land under
13
+ * `<exportStorageDir>/<orgId>/<uuid>.<ext>`; the returned fileId is that
14
+ * storage key, stored on the ReportExportJob row and used by the download
15
+ * route. The key is server-generated, and reads/deletes are guarded against
16
+ * escaping the configured directory.
17
+ *
18
+ * Operational boundary: this local adapter is correct for a single app
19
+ * instance or for multiple instances sharing the same mounted volume. A
20
+ * horizontally scaled deployment without shared storage must rebind this
21
+ * provider to an object-storage adapter so any node can download a file
22
+ * written by any worker.
23
+ */
24
+ @Injectable()
25
+ export class LocalDiskReportExportStorage implements ExportFileSink {
26
+ private readonly baseDir: string;
27
+
28
+ constructor(config: ConfigService) {
29
+ this.baseDir = resolve(config.get<string>('reports.exportStorageDir') ?? './var/report-exports');
30
+ }
31
+
32
+ async putStream(
33
+ opts: { orgId: string; ownerId: string; fileName: string; mimeType: string },
34
+ chunks: AsyncIterable<Uint8Array>,
35
+ ): Promise<{ fileId: string; size: number }> {
36
+ const safeOrg = sanitizeSegment(opts.orgId);
37
+ const storageKey = `${safeOrg}/${randomUUID()}${extensionOf(opts.fileName)}`;
38
+ const absolute = this.resolveKey(storageKey);
39
+ await mkdir(dirname(absolute), { recursive: true });
40
+
41
+ let size = 0;
42
+ try {
43
+ await pipeline(
44
+ Readable.from(countingChunks(chunks, (byteLength) => {
45
+ size += byteLength;
46
+ })),
47
+ createWriteStream(absolute),
48
+ );
49
+ } catch (error) {
50
+ await rm(absolute, { force: true });
51
+ throw error;
52
+ }
53
+
54
+ return { fileId: storageKey, size };
55
+ }
56
+
57
+ read(storageKey: string): Readable {
58
+ return createReadStream(this.resolveKey(storageKey));
59
+ }
60
+
61
+ async delete(storageKey: string): Promise<void> {
62
+ await rm(this.resolveKey(storageKey), { force: true });
63
+ }
64
+
65
+ private resolveKey(storageKey: string): string {
66
+ const absolute = resolve(join(this.baseDir, storageKey));
67
+ if (absolute !== this.baseDir && !absolute.startsWith(this.baseDir + sep)) {
68
+ throw new Error('Export storage key resolves outside the configured directory.');
69
+ }
70
+ return absolute;
71
+ }
72
+ }
73
+
74
+ function sanitizeSegment(segment: string): string {
75
+ return segment.replace(/[^A-Za-z0-9_-]/g, '_');
76
+ }
77
+
78
+ function extensionOf(fileName: string): string {
79
+ const dot = fileName.lastIndexOf('.');
80
+ const extension = dot >= 0 ? fileName.slice(dot) : '';
81
+ return /^\.[A-Za-z0-9]+$/.test(extension) ? extension.toLowerCase() : '';
82
+ }
83
+
84
+ async function* countingChunks(
85
+ chunks: AsyncIterable<Uint8Array>,
86
+ onChunk: (byteLength: number) => void,
87
+ ): AsyncGenerator<Uint8Array> {
88
+ for await (const chunk of chunks) {
89
+ onChunk(chunk.byteLength);
90
+ yield chunk;
91
+ }
92
+ }
@@ -0,0 +1,5 @@
1
+ export * from './prisma-bulk-action-run.store';
2
+ export * from './prisma-export-job.store';
3
+ export * from './prisma-report-definition.store';
4
+ export * from './prisma-row-tag.store';
5
+ export * from './prisma-saved-view.store';