@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,89 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { Prisma, ReportBulkActionRun as ReportBulkActionRunRow } from '@prisma/client';
3
+ import type {
4
+ ActionRunStatus,
5
+ BulkActionRunRecord,
6
+ BulkActionRunStore,
7
+ NewBulkActionRunRecord,
8
+ } from '@ftisindia/report-builder';
9
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
10
+
11
+ /**
12
+ * The bulk-action idempotency ledger (report design §6.3, finding #7): the
13
+ * (orgId, idempotencyKey) unique makes `begin` an atomic claim — exactly one
14
+ * caller creates the RUNNING row; a retry gets the existing row back and
15
+ * returns the recorded outcome instead of re-running the verb.
16
+ */
17
+ @Injectable()
18
+ export class PrismaBulkActionRunStore implements BulkActionRunStore {
19
+ constructor(private readonly prisma: PrismaService) {}
20
+
21
+ async begin(
22
+ record: NewBulkActionRunRecord,
23
+ ): Promise<{ created: boolean; run: BulkActionRunRecord }> {
24
+ try {
25
+ const row = await this.prisma.reportBulkActionRun.create({
26
+ data: {
27
+ orgId: record.orgId,
28
+ reportKey: record.reportKey,
29
+ reportVersion: record.reportVersion,
30
+ action: record.action,
31
+ idempotencyKey: record.idempotencyKey,
32
+ status: 'RUNNING',
33
+ },
34
+ });
35
+
36
+ return { created: true, run: this.toRecord(row) };
37
+ } catch (error) {
38
+ if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2002') {
39
+ throw error;
40
+ }
41
+
42
+ const existing = await this.prisma.reportBulkActionRun.findUnique({
43
+ where: {
44
+ orgId_idempotencyKey: { orgId: record.orgId, idempotencyKey: record.idempotencyKey },
45
+ },
46
+ });
47
+
48
+ if (!existing) {
49
+ // The claiming row vanished between the conflict and the lookup —
50
+ // surface the original conflict rather than inventing state.
51
+ throw error;
52
+ }
53
+
54
+ return { created: false, run: this.toRecord(existing) };
55
+ }
56
+ }
57
+
58
+ async complete(
59
+ orgId: string,
60
+ id: string,
61
+ status: 'DONE' | 'FAILED',
62
+ result?: unknown,
63
+ ): Promise<void> {
64
+ await this.prisma.reportBulkActionRun.updateMany({
65
+ where: { id, orgId },
66
+ data: {
67
+ status,
68
+ ...(result !== undefined
69
+ ? { result: result === null ? Prisma.DbNull : (result as Prisma.InputJsonValue) }
70
+ : {}),
71
+ },
72
+ });
73
+ }
74
+
75
+ private toRecord(row: ReportBulkActionRunRow): BulkActionRunRecord {
76
+ return {
77
+ id: row.id,
78
+ orgId: row.orgId,
79
+ reportKey: row.reportKey,
80
+ reportVersion: row.reportVersion,
81
+ action: row.action,
82
+ idempotencyKey: row.idempotencyKey,
83
+ status: row.status as ActionRunStatus,
84
+ result: row.result,
85
+ createdAt: row.createdAt,
86
+ updatedAt: row.updatedAt,
87
+ };
88
+ }
89
+ }
@@ -0,0 +1,93 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { Prisma, ReportExportJob as ReportExportJobRow } from '@prisma/client';
3
+ import type {
4
+ EngineTx,
5
+ ExportJobRecord,
6
+ ExportJobSpec,
7
+ ExportJobStatus,
8
+ ExportJobStore,
9
+ NewExportJobRecord,
10
+ } from '@ftisindia/report-builder';
11
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
12
+
13
+ @Injectable()
14
+ export class PrismaExportJobStore implements ExportJobStore {
15
+ constructor(private readonly prisma: PrismaService) {}
16
+
17
+ private client(tx?: EngineTx) {
18
+ return tx ? (tx as unknown as Prisma.TransactionClient) : this.prisma;
19
+ }
20
+
21
+ async create(record: NewExportJobRecord, tx?: EngineTx): Promise<ExportJobRecord> {
22
+ const row = await this.client(tx).reportExportJob.create({
23
+ data: {
24
+ orgId: record.orgId,
25
+ reportKey: record.reportKey,
26
+ reportVersion: record.reportVersion,
27
+ // The FULL spec {spec, format, columns} — replayable and auditable,
28
+ // never a hash (report design §9, finding #1/#8).
29
+ spec: record.spec as unknown as Prisma.InputJsonValue,
30
+ status: 'PENDING',
31
+ requestedBy: record.requestedBy,
32
+ },
33
+ });
34
+
35
+ return this.toRecord(row);
36
+ }
37
+
38
+ async findById(orgId: string, id: string): Promise<ExportJobRecord | null> {
39
+ const row = await this.client().reportExportJob.findFirst({
40
+ where: { id, orgId },
41
+ });
42
+
43
+ return row ? this.toRecord(row) : null;
44
+ }
45
+
46
+ async markRunning(orgId: string, id: string): Promise<void> {
47
+ await this.client().reportExportJob.updateMany({
48
+ where: { id, orgId },
49
+ data: { status: 'RUNNING' },
50
+ });
51
+ }
52
+
53
+ async markDone(
54
+ orgId: string,
55
+ id: string,
56
+ outcome: { fileId: string; rowCount: number; asOf: Date },
57
+ ): Promise<void> {
58
+ await this.client().reportExportJob.updateMany({
59
+ where: { id, orgId },
60
+ data: {
61
+ status: 'DONE',
62
+ fileId: outcome.fileId,
63
+ rowCount: outcome.rowCount,
64
+ asOf: outcome.asOf,
65
+ },
66
+ });
67
+ }
68
+
69
+ async markFailed(orgId: string, id: string, error: string): Promise<void> {
70
+ await this.client().reportExportJob.updateMany({
71
+ where: { id, orgId },
72
+ data: { status: 'FAILED', error },
73
+ });
74
+ }
75
+
76
+ private toRecord(row: ReportExportJobRow): ExportJobRecord {
77
+ return {
78
+ id: row.id,
79
+ orgId: row.orgId,
80
+ reportKey: row.reportKey,
81
+ reportVersion: row.reportVersion,
82
+ spec: row.spec as unknown as ExportJobSpec,
83
+ asOf: row.asOf,
84
+ status: row.status as ExportJobStatus,
85
+ fileId: row.fileId,
86
+ rowCount: row.rowCount,
87
+ error: row.error,
88
+ requestedBy: row.requestedBy,
89
+ createdAt: row.createdAt,
90
+ updatedAt: row.updatedAt,
91
+ };
92
+ }
93
+ }
@@ -0,0 +1,171 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { Prisma, ReportDefinition as ReportDefinitionRow } from '@prisma/client';
3
+ import {
4
+ ReportConflictError,
5
+ ReportNotFoundError,
6
+ type CompiledDefinitionMeta,
7
+ type EngineTx,
8
+ type NewReportDefinitionRecord,
9
+ type ReportDefinition,
10
+ type ReportDefinitionPatch,
11
+ type ReportDefinitionRecord,
12
+ type ReportDefinitionStatus,
13
+ type ReportDefinitionStore,
14
+ } from '@ftisindia/report-builder';
15
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
16
+
17
+ @Injectable()
18
+ export class PrismaReportDefinitionStore implements ReportDefinitionStore {
19
+ constructor(private readonly prisma: PrismaService) {}
20
+
21
+ private client(tx?: EngineTx) {
22
+ return tx ? (tx as unknown as Prisma.TransactionClient) : this.prisma;
23
+ }
24
+
25
+ async create(record: NewReportDefinitionRecord, tx?: EngineTx): Promise<ReportDefinitionRecord> {
26
+ try {
27
+ const row = await this.client(tx).reportDefinition.create({
28
+ data: {
29
+ orgId: record.orgId,
30
+ key: record.key,
31
+ version: record.version,
32
+ status: record.status,
33
+ schema: record.schema as unknown as Prisma.InputJsonValue,
34
+ },
35
+ });
36
+
37
+ return this.toRecord(row);
38
+ } catch (error) {
39
+ // The (orgId, key, version) unique — a concurrent draft/publish race.
40
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
41
+ throw new ReportConflictError(
42
+ `Report definition "${record.key}" v${record.version} already exists in this organisation.`,
43
+ );
44
+ }
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ async update(
50
+ orgId: string,
51
+ key: string,
52
+ version: number,
53
+ patch: ReportDefinitionPatch,
54
+ tx?: EngineTx,
55
+ ): Promise<ReportDefinitionRecord> {
56
+ const client = this.client(tx);
57
+
58
+ const existing = await client.reportDefinition.findUnique({
59
+ where: { orgId_key_version: { orgId, key, version } },
60
+ select: { id: true },
61
+ });
62
+
63
+ if (!existing) {
64
+ throw new ReportNotFoundError(
65
+ `Report definition "${key}" v${version} was not found in this organisation.`,
66
+ );
67
+ }
68
+
69
+ const row = await client.reportDefinition.update({
70
+ where: { id: existing.id },
71
+ data: {
72
+ ...(patch.status !== undefined ? { status: patch.status } : {}),
73
+ ...(patch.schema !== undefined
74
+ ? { schema: patch.schema as unknown as Prisma.InputJsonValue }
75
+ : {}),
76
+ ...(patch.compiled !== undefined
77
+ ? {
78
+ compiled:
79
+ patch.compiled === null
80
+ ? Prisma.DbNull
81
+ : (patch.compiled as unknown as Prisma.InputJsonValue),
82
+ }
83
+ : {}),
84
+ },
85
+ });
86
+
87
+ return this.toRecord(row);
88
+ }
89
+
90
+ async findByKeyVersion(
91
+ orgId: string,
92
+ key: string,
93
+ version: number,
94
+ tx?: EngineTx,
95
+ ): Promise<ReportDefinitionRecord | null> {
96
+ const row = await this.client(tx).reportDefinition.findUnique({
97
+ where: { orgId_key_version: { orgId, key, version } },
98
+ });
99
+
100
+ return row ? this.toRecord(row) : null;
101
+ }
102
+
103
+ async findLatest(
104
+ orgId: string,
105
+ key: string,
106
+ status?: ReportDefinitionStatus,
107
+ ): Promise<ReportDefinitionRecord | null> {
108
+ const row = await this.client().reportDefinition.findFirst({
109
+ where: { orgId, key, ...(status ? { status } : {}) },
110
+ orderBy: { version: 'desc' },
111
+ });
112
+
113
+ return row ? this.toRecord(row) : null;
114
+ }
115
+
116
+ async findAllByKeyStatus(
117
+ orgId: string,
118
+ key: string,
119
+ status: ReportDefinitionStatus,
120
+ tx?: EngineTx,
121
+ ): Promise<ReportDefinitionRecord[]> {
122
+ const rows = await this.client(tx).reportDefinition.findMany({
123
+ where: { orgId, key, status },
124
+ orderBy: { version: 'desc' },
125
+ });
126
+
127
+ return rows.map((row) => this.toRecord(row));
128
+ }
129
+
130
+ async list(
131
+ orgId: string,
132
+ options: { status?: ReportDefinitionStatus; cursor?: string; limit: number },
133
+ ): Promise<ReportDefinitionRecord[]> {
134
+ const rows = await this.client().reportDefinition.findMany({
135
+ where: { orgId, ...(options.status ? { status: options.status } : {}) },
136
+ orderBy: [{ key: 'asc' }, { version: 'desc' }],
137
+ take: options.limit,
138
+ ...(options.cursor
139
+ ? {
140
+ cursor: { id: options.cursor },
141
+ skip: 1,
142
+ }
143
+ : {}),
144
+ });
145
+
146
+ return rows.map((row) => this.toRecord(row));
147
+ }
148
+
149
+ async maxVersion(orgId: string, key: string, tx?: EngineTx): Promise<number> {
150
+ const result = await this.client(tx).reportDefinition.aggregate({
151
+ _max: { version: true },
152
+ where: { orgId, key },
153
+ });
154
+
155
+ return result._max.version ?? 0;
156
+ }
157
+
158
+ private toRecord(row: ReportDefinitionRow): ReportDefinitionRecord {
159
+ return {
160
+ id: row.id,
161
+ orgId: row.orgId,
162
+ key: row.key,
163
+ version: row.version,
164
+ status: row.status as ReportDefinitionStatus,
165
+ schema: row.schema as unknown as ReportDefinition,
166
+ compiled: row.compiled === null ? null : (row.compiled as unknown as CompiledDefinitionMeta),
167
+ createdAt: row.createdAt,
168
+ updatedAt: row.updatedAt,
169
+ };
170
+ }
171
+ }
@@ -0,0 +1,110 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { Prisma } from '@prisma/client';
3
+ import type { EngineTx, RowTagStore } from '@ftisindia/report-builder';
4
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
5
+
6
+ /**
7
+ * Org-scoped row tags & labels (report design §7): one table, one mechanism.
8
+ * The (orgId, sourceKind, sourceKey, rowId, tag) unique + createMany
9
+ * skipDuplicates make addTags idempotent per link; the engine normalizes tag
10
+ * text before it reaches this store.
11
+ */
12
+ @Injectable()
13
+ export class PrismaRowTagStore implements RowTagStore {
14
+ constructor(private readonly prisma: PrismaService) {}
15
+
16
+ private client(tx?: EngineTx) {
17
+ return tx ? (tx as unknown as Prisma.TransactionClient) : this.prisma;
18
+ }
19
+
20
+ async addTags(
21
+ orgId: string,
22
+ sourceKind: string,
23
+ sourceKey: string,
24
+ rowIds: readonly string[],
25
+ tags: readonly string[],
26
+ createdBy: string,
27
+ tx?: EngineTx,
28
+ ): Promise<number> {
29
+ if (rowIds.length === 0 || tags.length === 0) {
30
+ return 0;
31
+ }
32
+
33
+ const result = await this.client(tx).reportRowTag.createMany({
34
+ data: rowIds.flatMap((rowId) =>
35
+ tags.map((tag) => ({ orgId, sourceKind, sourceKey, rowId, tag, createdBy })),
36
+ ),
37
+ // Existing (org, source, row, tag) links are silently kept — the count
38
+ // returned is the number of NEW links, per the port contract.
39
+ skipDuplicates: true,
40
+ });
41
+
42
+ return result.count;
43
+ }
44
+
45
+ async removeTags(
46
+ orgId: string,
47
+ sourceKind: string,
48
+ sourceKey: string,
49
+ rowIds: readonly string[],
50
+ tags: readonly string[],
51
+ tx?: EngineTx,
52
+ ): Promise<number> {
53
+ if (rowIds.length === 0 || tags.length === 0) {
54
+ return 0;
55
+ }
56
+
57
+ const result = await this.client(tx).reportRowTag.deleteMany({
58
+ where: {
59
+ orgId,
60
+ sourceKind,
61
+ sourceKey,
62
+ rowId: { in: [...rowIds] },
63
+ tag: { in: [...tags] },
64
+ },
65
+ });
66
+
67
+ return result.count;
68
+ }
69
+
70
+ async tagsForRows(
71
+ orgId: string,
72
+ sourceKind: string,
73
+ sourceKey: string,
74
+ rowIds: readonly string[],
75
+ ): Promise<Map<string, string[]>> {
76
+ if (rowIds.length === 0) {
77
+ return new Map();
78
+ }
79
+
80
+ // One indexed lookup per page — (orgId, sourceKind, sourceKey, rowId).
81
+ const rows = await this.client().reportRowTag.findMany({
82
+ where: { orgId, sourceKind, sourceKey, rowId: { in: [...rowIds] } },
83
+ select: { rowId: true, tag: true },
84
+ orderBy: { tag: 'asc' },
85
+ });
86
+
87
+ const tagsByRow = new Map<string, string[]>();
88
+ for (const row of rows) {
89
+ const tags = tagsByRow.get(row.rowId);
90
+ if (tags) {
91
+ tags.push(row.tag);
92
+ } else {
93
+ tagsByRow.set(row.rowId, [row.tag]);
94
+ }
95
+ }
96
+
97
+ return tagsByRow;
98
+ }
99
+
100
+ async listTags(orgId: string, sourceKind: string, sourceKey: string): Promise<string[]> {
101
+ const rows = await this.client().reportRowTag.findMany({
102
+ where: { orgId, sourceKind, sourceKey },
103
+ select: { tag: true },
104
+ distinct: ['tag'],
105
+ orderBy: { tag: 'asc' },
106
+ });
107
+
108
+ return rows.map((row) => row.tag);
109
+ }
110
+ }
@@ -0,0 +1,144 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { Prisma, ReportSavedView as ReportSavedViewRow } from '@prisma/client';
3
+ import {
4
+ ReportConflictError,
5
+ ReportNotFoundError,
6
+ type EngineTx,
7
+ type NewSavedViewRecord,
8
+ type QuerySpec,
9
+ type SavedViewRecord,
10
+ type SavedViewStore,
11
+ } from '@ftisindia/report-builder';
12
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
13
+
14
+ @Injectable()
15
+ export class PrismaSavedViewStore implements SavedViewStore {
16
+ constructor(private readonly prisma: PrismaService) {}
17
+
18
+ private client(tx?: EngineTx) {
19
+ return tx ? (tx as unknown as Prisma.TransactionClient) : this.prisma;
20
+ }
21
+
22
+ async create(record: NewSavedViewRecord, tx?: EngineTx): Promise<SavedViewRecord> {
23
+ try {
24
+ const row = await this.client(tx).reportSavedView.create({
25
+ data: {
26
+ orgId: record.orgId,
27
+ reportKey: record.reportKey,
28
+ reportVersion: record.reportVersion,
29
+ name: record.name,
30
+ spec: record.spec as unknown as Prisma.InputJsonValue,
31
+ ownerId: record.ownerId,
32
+ },
33
+ });
34
+
35
+ return this.toRecord(row);
36
+ } catch (error) {
37
+ if (this.isDuplicateViewName(error)) {
38
+ throw new ReportConflictError(
39
+ `A view named "${record.name}" already exists for this report.`,
40
+ );
41
+ }
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ async update(
47
+ orgId: string,
48
+ id: string,
49
+ patch: { name?: string; spec?: QuerySpec; reportVersion?: number },
50
+ tx?: EngineTx,
51
+ ): Promise<SavedViewRecord> {
52
+ const client = this.client(tx);
53
+
54
+ const existing = await client.reportSavedView.findFirst({
55
+ where: { id, orgId },
56
+ select: { id: true },
57
+ });
58
+
59
+ if (!existing) {
60
+ throw new ReportNotFoundError(`Saved view ${id} was not found in this organisation.`);
61
+ }
62
+
63
+ try {
64
+ const row = await client.reportSavedView.update({
65
+ where: { id: existing.id },
66
+ data: {
67
+ ...(patch.name !== undefined ? { name: patch.name } : {}),
68
+ ...(patch.spec !== undefined
69
+ ? { spec: patch.spec as unknown as Prisma.InputJsonValue }
70
+ : {}),
71
+ ...(patch.reportVersion !== undefined ? { reportVersion: patch.reportVersion } : {}),
72
+ },
73
+ });
74
+
75
+ return this.toRecord(row);
76
+ } catch (error) {
77
+ if (this.isDuplicateViewName(error)) {
78
+ throw new ReportConflictError(
79
+ patch.name === undefined
80
+ ? 'A view with this name already exists for this report.'
81
+ : `A view named "${patch.name}" already exists for this report.`,
82
+ );
83
+ }
84
+ throw error;
85
+ }
86
+ }
87
+
88
+ async delete(orgId: string, id: string, tx?: EngineTx): Promise<void> {
89
+ await this.client(tx).reportSavedView.deleteMany({
90
+ where: { id, orgId },
91
+ });
92
+ }
93
+
94
+ async findById(orgId: string, id: string): Promise<SavedViewRecord | null> {
95
+ const row = await this.client().reportSavedView.findFirst({
96
+ where: { id, orgId },
97
+ });
98
+
99
+ return row ? this.toRecord(row) : null;
100
+ }
101
+
102
+ async listByReport(
103
+ orgId: string,
104
+ reportKey: string,
105
+ userId: string,
106
+ ): Promise<SavedViewRecord[]> {
107
+ const rows = await this.client().reportSavedView.findMany({
108
+ // The caller's PERSONAL views plus SHARED views (ownerId null) — never
109
+ // another user's personal views (report design §4/§12).
110
+ where: { orgId, reportKey, OR: [{ ownerId: userId }, { ownerId: null }] },
111
+ orderBy: [{ name: 'asc' }, { createdAt: 'asc' }],
112
+ });
113
+
114
+ return rows.map((row) => this.toRecord(row));
115
+ }
116
+
117
+ /**
118
+ * View-name uniqueness lives in PARTIAL UNIQUE INDEXES shipped as raw SQL
119
+ * (report design §12, finding #5 — Postgres treats NULL ownerIds as
120
+ * distinct, so a Prisma @@unique cannot express it). Prisma maps the
121
+ * underlying 23505 to P2002 when it can; a raw partial-unique violation may
122
+ * also surface as an unknown request error, so match the index-name prefix
123
+ * ("report_view_shared_uq" / "report_view_personal_uq") as the fallback.
124
+ */
125
+ private isDuplicateViewName(error: unknown): boolean {
126
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
127
+ return true;
128
+ }
129
+ return error instanceof Error && error.message.includes('report_view_');
130
+ }
131
+
132
+ private toRecord(row: ReportSavedViewRow): SavedViewRecord {
133
+ return {
134
+ id: row.id,
135
+ orgId: row.orgId,
136
+ reportKey: row.reportKey,
137
+ reportVersion: row.reportVersion,
138
+ name: row.name,
139
+ spec: row.spec as unknown as QuerySpec,
140
+ ownerId: row.ownerId,
141
+ createdAt: row.createdAt,
142
+ };
143
+ }
144
+ }
@@ -0,0 +1,83 @@
1
+ import { Body, Controller, HttpCode, Param, Post, UseGuards } from '@nestjs/common';
2
+ import {
3
+ ApiBearerAuth,
4
+ ApiOkResponse,
5
+ ApiOperation,
6
+ ApiParam,
7
+ ApiTags,
8
+ ApiUnprocessableEntityResponse,
9
+ } from '@nestjs/swagger';
10
+ import { ErrorResponseDto } from '../../../common/dto/error-response.dto';
11
+ import { ApiProtectedErrorResponses } from '../../../common/swagger/api-error-responses';
12
+ import { PermissionGuard } from '../../access-control/application/services/permission.guard';
13
+ import { RequirePermissions } from '../../access-control/presentation/permissions.decorator';
14
+ import { JwtAuthGuard } from '../../auth/infrastructure/passport/jwt-auth.guard';
15
+ import { OrgScopeGuard } from '../../request-context/presentation/org-scope.guard';
16
+ import { ReportsActionsService } from '../application/services/reports-actions.service';
17
+ import { ActionResultResponseDto } from '../dto/action-result-response.dto';
18
+ import { ExecuteActionDto } from '../dto/execute-action.dto';
19
+ import { PrepareActionDto } from '../dto/prepare-action.dto';
20
+ import { PrepareActionResponseDto } from '../dto/prepare-action-response.dto';
21
+
22
+ /**
23
+ * Row actions (design §6) — verbs that DELEGATE, never a second write path.
24
+ * The route-level key is reports.read (the grid entry point); each action's
25
+ * own permission keys (action.requiredPermissions, e.g.
26
+ * 'formSubmissions.update') are enforced INSIDE the engine at execute-time —
27
+ * the second of §6.1's two layers (attach-time ran at definition save).
28
+ */
29
+ @ApiTags('Reports')
30
+ @ApiBearerAuth()
31
+ @ApiParam({ name: 'orgId', description: 'Organisation ID.', format: 'uuid' })
32
+ @ApiParam({ name: 'key', description: 'Report definition key.' })
33
+ @ApiParam({ name: 'name', description: 'Registered row-action name.' })
34
+ @ApiProtectedErrorResponses(404, 409, 410)
35
+ @ApiUnprocessableEntityResponse({
36
+ description: 'The action input failed validation.',
37
+ type: ErrorResponseDto,
38
+ })
39
+ @Controller('organisations/:orgId/reports')
40
+ @UseGuards(JwtAuthGuard, OrgScopeGuard, PermissionGuard)
41
+ export class ReportsActionsController {
42
+ constructor(private readonly reportsActionsService: ReportsActionsService) {}
43
+
44
+ @Post(':key/actions/:name/prepare')
45
+ @HttpCode(200)
46
+ @RequirePermissions('reports.read')
47
+ @ApiOperation({
48
+ summary:
49
+ 'Prepare a byFilter bulk action: resolve the selection NOW and sign what was seen (§6.3 step 1).',
50
+ })
51
+ @ApiOkResponse({
52
+ description: 'Expected count plus the signed, short-TTL action token.',
53
+ type: PrepareActionResponseDto,
54
+ })
55
+ prepare(
56
+ @Param('orgId') orgId: string,
57
+ @Param('key') key: string,
58
+ @Param('name') name: string,
59
+ @Body() dto: PrepareActionDto,
60
+ ) {
61
+ return this.reportsActionsService.prepare(orgId, key, name, dto);
62
+ }
63
+
64
+ @Post(':key/actions/:name')
65
+ @HttpCode(200)
66
+ @RequirePermissions('reports.read')
67
+ @ApiOperation({
68
+ summary:
69
+ 'Execute a row/bulk action: byIds directly, byFilter via the prepared token with drift detection and the idempotency ledger (§6.3).',
70
+ })
71
+ @ApiOkResponse({
72
+ description: 'Execution outcome.',
73
+ type: ActionResultResponseDto,
74
+ })
75
+ execute(
76
+ @Param('orgId') orgId: string,
77
+ @Param('key') key: string,
78
+ @Param('name') name: string,
79
+ @Body() dto: ExecuteActionDto,
80
+ ) {
81
+ return this.reportsActionsService.execute(orgId, key, name, dto);
82
+ }
83
+ }