@ftisindia/create-app 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +31 -0
  3. package/template/README.md +61 -0
  4. package/template/_gitignore +6 -0
  5. package/template/_package.json +6 -0
  6. package/template/docs/FORMS.md +169 -0
  7. package/template/docs/FORMS_CHECKLIST.md +61 -0
  8. package/template/docs/REPORTS.md +246 -0
  9. package/template/docs/REPORTS_CHECKLIST.md +97 -0
  10. package/template/prisma/migrations/20260612000000_add_form_builder/migration.sql +147 -0
  11. package/template/prisma/migrations/20260613000000_add_report_builder/migration.sql +129 -0
  12. package/template/prisma/schema.prisma +285 -0
  13. package/template/scripts/export-openapi.ts +85 -0
  14. package/template/scripts/gen-form.mjs +149 -0
  15. package/template/scripts/push-form.ts +124 -0
  16. package/template/src/app.module.ts +29 -8
  17. package/template/src/common/dto/membership-response.dto.ts +1 -0
  18. package/template/src/common/dto/role-summary.dto.ts +3 -3
  19. package/template/src/common/dto/user-summary.dto.ts +3 -3
  20. package/template/src/config/app.config.ts +6 -1
  21. package/template/src/config/env.validation.ts +45 -0
  22. package/template/src/config/forms.config.ts +12 -0
  23. package/template/src/config/index.ts +2 -0
  24. package/template/src/config/openapi.ts +12 -0
  25. package/template/src/config/reports-secret.ts +15 -0
  26. package/template/src/config/reports.config.ts +16 -0
  27. package/template/src/main.ts +16 -12
  28. package/template/src/modules/access-control/access-control.module.ts +2 -1
  29. package/template/src/modules/access-control/dto/access-control-response.dto.ts +3 -0
  30. package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +35 -0
  31. package/template/src/modules/access-control/presentation/current-access-control.controller.ts +40 -0
  32. package/template/src/modules/access-control/types/permission-key.ts +27 -0
  33. package/template/src/modules/access-control/types/route-permission-registry.ts +183 -0
  34. package/template/src/modules/audit/dto/audit-response.dto.ts +7 -3
  35. package/template/src/modules/auth/auth.module.ts +3 -1
  36. package/template/src/modules/auth/dto/auth-response.dto.ts +1 -1
  37. package/template/src/modules/forms/application/services/file-gc.service.ts +85 -0
  38. package/template/src/modules/forms/application/services/forms-definitions.service.ts +137 -0
  39. package/template/src/modules/forms/application/services/forms-error.mapper.ts +64 -0
  40. package/template/src/modules/forms/application/services/forms-export.service.ts +210 -0
  41. package/template/src/modules/forms/application/services/forms-files.service.ts +164 -0
  42. package/template/src/modules/forms/application/services/forms-public.service.ts +49 -0
  43. package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +53 -0
  44. package/template/src/modules/forms/application/services/forms-submissions.service.ts +103 -0
  45. package/template/src/modules/forms/application/services/handlers/authenticate.action.ts +37 -0
  46. package/template/src/modules/forms/application/services/handlers/logging-email.handler.ts +22 -0
  47. package/template/src/modules/forms/application/services/handlers/send-confirmation-email.action.ts +40 -0
  48. package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +41 -0
  49. package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +109 -0
  50. package/template/src/modules/forms/dto/create-form-definition.dto.ts +12 -0
  51. package/template/src/modules/forms/dto/data-source-response.dto.ts +19 -0
  52. package/template/src/modules/forms/dto/export-submissions-query.dto.ts +33 -0
  53. package/template/src/modules/forms/dto/file-upload-response.dto.ts +24 -0
  54. package/template/src/modules/forms/dto/form-definition-response.dto.ts +50 -0
  55. package/template/src/modules/forms/dto/form-render-response.dto.ts +17 -0
  56. package/template/src/modules/forms/dto/list-form-definitions-query.dto.ts +10 -0
  57. package/template/src/modules/forms/dto/list-submissions-query.dto.ts +10 -0
  58. package/template/src/modules/forms/dto/public-submit-form.dto.ts +24 -0
  59. package/template/src/modules/forms/dto/set-public-access.dto.ts +8 -0
  60. package/template/src/modules/forms/dto/submission-response.dto.ts +99 -0
  61. package/template/src/modules/forms/dto/submit-form.dto.ts +50 -0
  62. package/template/src/modules/forms/dto/update-form-definition.dto.ts +12 -0
  63. package/template/src/modules/forms/dto/upload-file-query.dto.ts +33 -0
  64. package/template/src/modules/forms/dto/validate-submission.dto.ts +22 -0
  65. package/template/src/modules/forms/examples/abstract-submission.form.json +80 -0
  66. package/template/src/modules/forms/examples/login.form.json +24 -0
  67. package/template/src/modules/forms/examples/registration.form.json +44 -0
  68. package/template/src/modules/forms/forms.module.ts +226 -0
  69. package/template/src/modules/forms/forms.tokens.ts +6 -0
  70. package/template/src/modules/forms/infrastructure/audit-sink.adapter.ts +30 -0
  71. package/template/src/modules/forms/infrastructure/casl-forms-authorization.ts +31 -0
  72. package/template/src/modules/forms/infrastructure/prisma-tx-runner.ts +17 -0
  73. package/template/src/modules/forms/infrastructure/registry/form-extension.decorators.ts +17 -0
  74. package/template/src/modules/forms/infrastructure/registry/registry-bootstrap.service.ts +82 -0
  75. package/template/src/modules/forms/infrastructure/request-forms-context.ts +60 -0
  76. package/template/src/modules/forms/infrastructure/schema-check/forms-schema-check.service.ts +76 -0
  77. package/template/src/modules/forms/infrastructure/storage/local-disk-storage.adapter.ts +43 -0
  78. package/template/src/modules/forms/infrastructure/stores/index.ts +5 -0
  79. package/template/src/modules/forms/infrastructure/stores/prisma-action-log.store.ts +37 -0
  80. package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +108 -0
  81. package/template/src/modules/forms/infrastructure/stores/prisma-form-definition.store.ts +147 -0
  82. package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +133 -0
  83. package/template/src/modules/forms/infrastructure/stores/prisma-submission.store.ts +164 -0
  84. package/template/src/modules/forms/presentation/forms-data-sources.controller.ts +58 -0
  85. package/template/src/modules/forms/presentation/forms-definitions.controller.ts +191 -0
  86. package/template/src/modules/forms/presentation/forms-files.controller.ts +79 -0
  87. package/template/src/modules/forms/presentation/forms-submissions.controller.ts +154 -0
  88. package/template/src/modules/forms/presentation/forms-upload.interceptor.ts +33 -0
  89. package/template/src/modules/forms/presentation/public-forms.controller.ts +51 -0
  90. package/template/src/modules/invitations/dto/invitation-response.dto.ts +4 -0
  91. package/template/src/modules/organisations/application/services/organisations.service.ts +67 -1
  92. package/template/src/modules/organisations/dto/organisation-response.dto.ts +52 -0
  93. package/template/src/modules/organisations/presentation/organisations.controller.ts +25 -3
  94. package/template/src/modules/reports/application/services/reports-actions.service.ts +54 -0
  95. package/template/src/modules/reports/application/services/reports-definitions.service.ts +66 -0
  96. package/template/src/modules/reports/application/services/reports-error.mapper.ts +97 -0
  97. package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +124 -0
  98. package/template/src/modules/reports/application/services/reports-exports.service.ts +74 -0
  99. package/template/src/modules/reports/application/services/reports-queries.service.ts +35 -0
  100. package/template/src/modules/reports/application/services/reports-settings-reader.service.ts +49 -0
  101. package/template/src/modules/reports/application/services/reports-views.service.ts +79 -0
  102. package/template/src/modules/reports/dto/action-result-response.dto.ts +21 -0
  103. package/template/src/modules/reports/dto/create-report-definition.dto.ts +86 -0
  104. package/template/src/modules/reports/dto/create-saved-view.dto.ts +26 -0
  105. package/template/src/modules/reports/dto/execute-action.dto.ts +71 -0
  106. package/template/src/modules/reports/dto/export-job-response.dto.ts +60 -0
  107. package/template/src/modules/reports/dto/export-request.dto.ts +34 -0
  108. package/template/src/modules/reports/dto/list-reports-query.dto.ts +10 -0
  109. package/template/src/modules/reports/dto/list-views-query.dto.ts +17 -0
  110. package/template/src/modules/reports/dto/prepare-action-response.dto.ts +14 -0
  111. package/template/src/modules/reports/dto/prepare-action.dto.ts +27 -0
  112. package/template/src/modules/reports/dto/query-response.dto.ts +64 -0
  113. package/template/src/modules/reports/dto/query-spec.dto.ts +120 -0
  114. package/template/src/modules/reports/dto/report-definition-response.dto.ts +64 -0
  115. package/template/src/modules/reports/dto/report-meta-query.dto.ts +16 -0
  116. package/template/src/modules/reports/dto/report-meta-response.dto.ts +113 -0
  117. package/template/src/modules/reports/dto/saved-view-response.dto.ts +66 -0
  118. package/template/src/modules/reports/dto/update-report-definition.dto.ts +9 -0
  119. package/template/src/modules/reports/dto/update-saved-view.dto.ts +27 -0
  120. package/template/src/modules/reports/examples/abstract-review-board.report.json +54 -0
  121. package/template/src/modules/reports/examples/org-members.report.json +55 -0
  122. package/template/src/modules/reports/infrastructure/audit-sink.adapter.ts +31 -0
  123. package/template/src/modules/reports/infrastructure/casl-reports-authorization.ts +39 -0
  124. package/template/src/modules/reports/infrastructure/forms-adapter/form-report-source.adapter.ts +292 -0
  125. package/template/src/modules/reports/infrastructure/forms-adapter/form-row-actions.ts +171 -0
  126. package/template/src/modules/reports/infrastructure/forms-adapter/forms-bridge-bootstrap.service.ts +32 -0
  127. package/template/src/modules/reports/infrastructure/prisma-catalog.adapter.ts +95 -0
  128. package/template/src/modules/reports/infrastructure/prisma-query-executor.ts +103 -0
  129. package/template/src/modules/reports/infrastructure/prisma-snapshot-runner.ts +47 -0
  130. package/template/src/modules/reports/infrastructure/prisma-tx-runner.ts +18 -0
  131. package/template/src/modules/reports/infrastructure/registry/registry-bootstrap.service.ts +61 -0
  132. package/template/src/modules/reports/infrastructure/registry/report-extension.decorators.ts +14 -0
  133. package/template/src/modules/reports/infrastructure/reports-job-queue.adapter.ts +28 -0
  134. package/template/src/modules/reports/infrastructure/request-reports-context.ts +42 -0
  135. package/template/src/modules/reports/infrastructure/schema-check/reports-schema-check.service.ts +116 -0
  136. package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +79 -0
  137. package/template/src/modules/reports/infrastructure/stores/index.ts +5 -0
  138. package/template/src/modules/reports/infrastructure/stores/prisma-bulk-action-run.store.ts +89 -0
  139. package/template/src/modules/reports/infrastructure/stores/prisma-export-job.store.ts +93 -0
  140. package/template/src/modules/reports/infrastructure/stores/prisma-report-definition.store.ts +171 -0
  141. package/template/src/modules/reports/infrastructure/stores/prisma-row-tag.store.ts +110 -0
  142. package/template/src/modules/reports/infrastructure/stores/prisma-saved-view.store.ts +144 -0
  143. package/template/src/modules/reports/presentation/reports-actions.controller.ts +83 -0
  144. package/template/src/modules/reports/presentation/reports-definitions.controller.ts +156 -0
  145. package/template/src/modules/reports/presentation/reports-export-jobs.controller.ts +61 -0
  146. package/template/src/modules/reports/presentation/reports-export.controller.ts +76 -0
  147. package/template/src/modules/reports/presentation/reports-query.controller.ts +52 -0
  148. package/template/src/modules/reports/presentation/reports-views.controller.ts +140 -0
  149. package/template/src/modules/reports/reports-forms.module.ts +33 -0
  150. package/template/src/modules/reports/reports.module.ts +335 -0
  151. package/template/src/modules/reports/reports.tokens.ts +11 -0
  152. package/template/src/modules/reports/sources/org-members.source.ts +112 -0
  153. package/template/src/modules/settings/types/setting-definitions.ts +94 -0
  154. package/template/test/forms-definitions.e2e-spec.ts +394 -0
  155. package/template/test/forms-export.e2e-spec.ts +390 -0
  156. package/template/test/forms-files.e2e-spec.ts +345 -0
  157. package/template/test/forms-outbox.e2e-spec.ts +309 -0
  158. package/template/test/forms-permission-sync.spec.ts +27 -0
  159. package/template/test/forms-public.e2e-spec.ts +269 -0
  160. package/template/test/forms-schema-check.e2e-spec.ts +65 -0
  161. package/template/test/forms-submissions.e2e-spec.ts +500 -0
  162. package/template/test/forms-webhooks.e2e-spec.ts +261 -0
  163. package/template/test/frontend-bootstrap.spec.ts +181 -0
  164. package/template/test/reports-advanced.e2e-spec.ts +368 -0
  165. package/template/test/reports-permission-sync.spec.ts +30 -0
  166. package/template/test/reports-query.e2e-spec.ts +350 -0
  167. package/template/test/reports-tiers.e2e-spec.ts +257 -0
  168. package/template/test/route-registry.validator.spec.ts +34 -0
  169. package/template/test/security.e2e-spec.ts +134 -2
@@ -1,12 +1,22 @@
1
- import { Body, Controller, Post, UseGuards } from '@nestjs/common';
2
- import { ApiBearerAuth, ApiCreatedResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
1
+ import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
2
+ import {
3
+ ApiBearerAuth,
4
+ ApiCreatedResponse,
5
+ ApiOkResponse,
6
+ ApiOperation,
7
+ ApiTags,
8
+ } from '@nestjs/swagger';
9
+ import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
3
10
  import { ApiErrorResponses } from '../../../common/swagger/api-error-responses';
4
11
  import { JwtAuthGuard } from '../../auth/infrastructure/passport/jwt-auth.guard';
5
12
  import { CurrentUser } from '../../auth/presentation/current-user.decorator';
6
13
  import { AuthenticatedUser } from '../../auth/types/authenticated-user';
7
14
  import { OrganisationsService } from '../application/services/organisations.service';
8
15
  import { CreateOrganisationDto } from '../dto/create-organisation.dto';
9
- import { CreateOrganisationResponseDto } from '../dto/organisation-response.dto';
16
+ import {
17
+ CreateOrganisationResponseDto,
18
+ MyOrganisationListResponseDto,
19
+ } from '../dto/organisation-response.dto';
10
20
 
11
21
  @ApiTags('Organisations')
12
22
  @ApiBearerAuth()
@@ -14,6 +24,18 @@ import { CreateOrganisationResponseDto } from '../dto/organisation-response.dto'
14
24
  export class OrganisationsController {
15
25
  constructor(private readonly organisationsService: OrganisationsService) {}
16
26
 
27
+ @Get('mine')
28
+ @UseGuards(JwtAuthGuard)
29
+ @ApiOperation({ summary: 'List active organisations for the current user.' })
30
+ @ApiOkResponse({
31
+ description: 'Current user organisations.',
32
+ type: MyOrganisationListResponseDto,
33
+ })
34
+ @ApiErrorResponses(400, 401)
35
+ mine(@CurrentUser() user: AuthenticatedUser, @Query() query: PaginationQueryDto) {
36
+ return this.organisationsService.listMine(user, query);
37
+ }
38
+
17
39
  @Post()
18
40
  @UseGuards(JwtAuthGuard)
19
41
  @ApiOperation({ summary: 'Create an organisation for the current user.' })
@@ -0,0 +1,54 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ReportActionService } from '@ftisindia/report-builder';
3
+ import type { Selection } from '@ftisindia/report-builder';
4
+ import { RequestContextService } from '../../../request-context/application/services/request-context.service';
5
+ import { ExecuteActionDto } from '../../dto/execute-action.dto';
6
+ import { PrepareActionDto } from '../../dto/prepare-action.dto';
7
+ import { RequestReportsContext } from '../../infrastructure/request-reports-context';
8
+ import { withReportsErrorMapping } from './reports-error.mapper';
9
+
10
+ /**
11
+ * Thin HTTP-facing wrapper over the engine's ReportActionService. The §6.3
12
+ * protocol — token verification, drift detection, keyset batching, the
13
+ * idempotency ledger — and the per-action permission checks (§6.1's
14
+ * execute-time layer) all live in the engine.
15
+ */
16
+ @Injectable()
17
+ export class ReportsActionsService {
18
+ constructor(
19
+ private readonly engine: ReportActionService,
20
+ private readonly reportsContext: RequestReportsContext,
21
+ private readonly requestContext: RequestContextService,
22
+ ) {}
23
+
24
+ async prepare(orgId: string, key: string, actionName: string, dto: PrepareActionDto) {
25
+ this.requestContext.assertOrgScope(orgId);
26
+ return withReportsErrorMapping(() =>
27
+ this.engine.prepare(
28
+ orgId,
29
+ key,
30
+ actionName,
31
+ dto.selection as unknown as Selection,
32
+ this.reportsContext,
33
+ ),
34
+ );
35
+ }
36
+
37
+ async execute(orgId: string, key: string, actionName: string, dto: ExecuteActionDto) {
38
+ this.requestContext.assertOrgScope(orgId);
39
+ return withReportsErrorMapping(() =>
40
+ this.engine.execute(
41
+ orgId,
42
+ key,
43
+ actionName,
44
+ {
45
+ selection: dto.selection as unknown as Selection | undefined,
46
+ actionToken: dto.actionToken,
47
+ input: dto.input,
48
+ idempotencyKey: dto.idempotencyKey,
49
+ },
50
+ this.reportsContext,
51
+ ),
52
+ );
53
+ }
54
+ }
@@ -0,0 +1,66 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ReportDefinitionService } from '@ftisindia/report-builder';
3
+ import { resolvePageLimit, toPage } from '../../../../common/dto/pagination-query.dto';
4
+ import { RequestContextService } from '../../../request-context/application/services/request-context.service';
5
+ import { CreateReportDefinitionDto } from '../../dto/create-report-definition.dto';
6
+ import { ListReportsQueryDto } from '../../dto/list-reports-query.dto';
7
+ import { UpdateReportDefinitionDto } from '../../dto/update-report-definition.dto';
8
+ import { RequestReportsContext } from '../../infrastructure/request-reports-context';
9
+ import { withReportsErrorMapping } from './reports-error.mapper';
10
+
11
+ /**
12
+ * Thin HTTP-facing wrapper over the engine's ReportDefinitionService:
13
+ * org-scope assertion first, engine call through the ReportsContext seam,
14
+ * engine-typed errors mapped onto the app's HTTP envelope. No business logic
15
+ * lives here — save/publish linting, tier gates, and state transitions all
16
+ * belong to the engine (design §2/§8/§10).
17
+ */
18
+ @Injectable()
19
+ export class ReportsDefinitionsService {
20
+ constructor(
21
+ private readonly engine: ReportDefinitionService,
22
+ private readonly reportsContext: RequestReportsContext,
23
+ private readonly requestContext: RequestContextService,
24
+ ) {}
25
+
26
+ async list(orgId: string, query: ListReportsQueryDto) {
27
+ this.requestContext.assertOrgScope(orgId);
28
+ const limit = resolvePageLimit(query.limit);
29
+
30
+ const records = await withReportsErrorMapping(() =>
31
+ this.engine.list(orgId, this.reportsContext, {
32
+ status: query.status,
33
+ cursor: query.cursor,
34
+ limit: limit + 1,
35
+ }),
36
+ );
37
+
38
+ return toPage(records, limit);
39
+ }
40
+
41
+ async create(orgId: string, dto: CreateReportDefinitionDto) {
42
+ this.requestContext.assertOrgScope(orgId);
43
+ return withReportsErrorMapping(() => this.engine.create(orgId, dto, this.reportsContext));
44
+ }
45
+
46
+ async getLatest(orgId: string, key: string) {
47
+ this.requestContext.assertOrgScope(orgId);
48
+ return withReportsErrorMapping(() => this.engine.get(orgId, key, this.reportsContext));
49
+ }
50
+
51
+ async update(orgId: string, key: string, dto: UpdateReportDefinitionDto) {
52
+ this.requestContext.assertOrgScope(orgId);
53
+ return withReportsErrorMapping(() => this.engine.update(orgId, key, dto, this.reportsContext));
54
+ }
55
+
56
+ async publish(orgId: string, key: string) {
57
+ this.requestContext.assertOrgScope(orgId);
58
+ return withReportsErrorMapping(() => this.engine.publish(orgId, key, this.reportsContext));
59
+ }
60
+
61
+ async archive(orgId: string, key: string) {
62
+ this.requestContext.assertOrgScope(orgId);
63
+ await withReportsErrorMapping(() => this.engine.archive(orgId, key, this.reportsContext));
64
+ return { archived: true };
65
+ }
66
+ }
@@ -0,0 +1,97 @@
1
+ import {
2
+ BadRequestException,
3
+ ConflictException,
4
+ ForbiddenException,
5
+ GoneException,
6
+ NotFoundException,
7
+ RequestTimeoutException,
8
+ UnauthorizedException,
9
+ UnprocessableEntityException,
10
+ } from '@nestjs/common';
11
+ import {
12
+ ReportAuthzDeniedError,
13
+ ReportConflictError,
14
+ ReportCursorError,
15
+ ReportDriftError,
16
+ ReportExportError,
17
+ ReportLintError,
18
+ ReportNotFoundError,
19
+ ReportQueryBudgetError,
20
+ ReportSpecError,
21
+ ReportStateError,
22
+ ReportTokenError,
23
+ } from '@ftisindia/report-builder';
24
+ import { Prisma } from '@prisma/client';
25
+
26
+ /**
27
+ * Maps the engine's typed errors (ecosystem guide §2.1, report design §10)
28
+ * onto the app's HTTP error envelope. Object bodies survive the global
29
+ * HttpExceptionFilter, which lifts every non-message key into `error.details`
30
+ * — so 422 lint responses carry the engine's `issues` array (plus the §8
31
+ * `suggestionSql` migration snippet) verbatim, and 409 drift responses carry
32
+ * both counts so the client can re-confirm with fresh facts.
33
+ */
34
+ export function mapReportsError(error: unknown): never {
35
+ if (error instanceof ReportSpecError || error instanceof ReportCursorError) {
36
+ throw new BadRequestException({ message: error.message, code: error.code });
37
+ }
38
+
39
+ if (error instanceof ReportTokenError) {
40
+ if (error.expired) {
41
+ throw new GoneException(error.message);
42
+ }
43
+ throw new UnauthorizedException(error.message);
44
+ }
45
+
46
+ if (error instanceof ReportLintError) {
47
+ throw new UnprocessableEntityException({
48
+ message: error.message,
49
+ issues: error.issues,
50
+ ...(error.suggestionSql === undefined ? {} : { suggestionSql: error.suggestionSql }),
51
+ });
52
+ }
53
+
54
+ if (error instanceof ReportDriftError) {
55
+ throw new ConflictException({
56
+ message: error.message,
57
+ expectedCount: error.expectedCount,
58
+ currentCount: error.currentCount,
59
+ code: 'REPORTS_SELECTION_DRIFT',
60
+ });
61
+ }
62
+
63
+ if (error instanceof ReportAuthzDeniedError) {
64
+ throw new ForbiddenException(error.message);
65
+ }
66
+
67
+ if (error instanceof ReportNotFoundError) {
68
+ throw new NotFoundException(error.message);
69
+ }
70
+
71
+ if (error instanceof ReportConflictError || error instanceof ReportStateError) {
72
+ throw new ConflictException(error.message);
73
+ }
74
+
75
+ if (error instanceof ReportQueryBudgetError) {
76
+ throw new RequestTimeoutException(error.message);
77
+ }
78
+
79
+ if (error instanceof ReportExportError) {
80
+ throw new UnprocessableEntityException(error.message);
81
+ }
82
+
83
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
84
+ throw new ConflictException('A report record with these unique fields already exists.');
85
+ }
86
+
87
+ throw error;
88
+ }
89
+
90
+ /** Awaits an engine call and maps any engine-typed failure onto HTTP errors. */
91
+ export async function withReportsErrorMapping<T>(fn: () => Promise<T>): Promise<T> {
92
+ try {
93
+ return await fn();
94
+ } catch (error) {
95
+ mapReportsError(error);
96
+ }
97
+ }
@@ -0,0 +1,124 @@
1
+ import {
2
+ Injectable,
3
+ Logger,
4
+ OnApplicationBootstrap,
5
+ OnModuleDestroy,
6
+ } from '@nestjs/common';
7
+ import { ConfigService } from '@nestjs/config';
8
+ import { ReportExportService } from '@ftisindia/report-builder';
9
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
10
+ import { RequestContextService } from '../../../request-context/application/services/request-context.service';
11
+ import { RequestReportsContext } from '../../infrastructure/request-reports-context';
12
+
13
+ /**
14
+ * Reports-OWNED async export worker (report design §9). Instead of riding the
15
+ * forms transactional outbox, it polls the engine's own `ReportExportJob`
16
+ * table for PENDING rows — so the reports module needs no FormsModule. Each
17
+ * job is claimed atomically (PENDING → RUNNING) to prevent double-processing,
18
+ * then run inside a restored worker request context (source: 'worker', orgId,
19
+ * userId) exactly as the forms dispatcher restores context for its handlers.
20
+ *
21
+ * The engine's ReportExportService.runJob streams from one REPEATABLE READ
22
+ * snapshot into the reports export storage and marks the row DONE/FAILED.
23
+ */
24
+ @Injectable()
25
+ export class ReportsExportDispatcherService implements OnApplicationBootstrap, OnModuleDestroy {
26
+ private readonly logger = new Logger('ReportsExportWorker');
27
+ private timer?: NodeJS.Timeout;
28
+ private ticking = false;
29
+
30
+ constructor(
31
+ private readonly config: ConfigService,
32
+ private readonly prisma: PrismaService,
33
+ private readonly exports: ReportExportService,
34
+ private readonly requestContext: RequestContextService,
35
+ private readonly reportsContext: RequestReportsContext,
36
+ ) {}
37
+
38
+ onApplicationBootstrap(): void {
39
+ if (this.config.get<boolean>('reports.exportWorkerEnabled') === false) {
40
+ return;
41
+ }
42
+ const pollMs = this.config.get<number>('reports.exportPollMs') ?? 5000;
43
+ this.timer = setInterval(() => {
44
+ void this.tick();
45
+ }, pollMs);
46
+ this.timer.unref?.();
47
+ }
48
+
49
+ onModuleDestroy(): void {
50
+ if (this.timer) {
51
+ clearInterval(this.timer);
52
+ }
53
+ }
54
+
55
+ /** One poll cycle — exposed for tests / manual draining. */
56
+ async runOnce(batchSize = 10): Promise<{ claimed: number; done: number; failed: number }> {
57
+ const pending = await this.prisma.reportExportJob.findMany({
58
+ where: { status: 'PENDING' },
59
+ orderBy: { createdAt: 'asc' },
60
+ take: batchSize,
61
+ select: { id: true, orgId: true, requestedBy: true },
62
+ });
63
+
64
+ let claimed = 0;
65
+ let done = 0;
66
+ let failed = 0;
67
+ for (const job of pending) {
68
+ // Atomic claim: only the worker that flips PENDING → RUNNING runs it.
69
+ const claim = await this.prisma.reportExportJob.updateMany({
70
+ where: { id: job.id, orgId: job.orgId, status: 'PENDING' },
71
+ data: { status: 'RUNNING' },
72
+ });
73
+ if (claim.count !== 1) {
74
+ continue;
75
+ }
76
+ claimed += 1;
77
+
78
+ const outcome = await this.requestContext.run(
79
+ {
80
+ source: 'worker',
81
+ orgId: job.orgId,
82
+ userId: job.requestedBy,
83
+ },
84
+ async () => {
85
+ try {
86
+ await this.exports.runJob(job.orgId, job.id, this.reportsContext);
87
+ return 'done' as const;
88
+ } catch (error) {
89
+ this.logger.error(
90
+ `Export job ${job.id} failed.`,
91
+ error instanceof Error ? error.stack : String(error),
92
+ );
93
+ return 'failed' as const;
94
+ }
95
+ },
96
+ );
97
+ if (outcome === 'done') {
98
+ done += 1;
99
+ } else {
100
+ failed += 1;
101
+ }
102
+ }
103
+ return { claimed, done, failed };
104
+ }
105
+
106
+ private async tick(): Promise<void> {
107
+ if (this.ticking) {
108
+ return;
109
+ }
110
+ this.ticking = true;
111
+ try {
112
+ const result = await this.runOnce();
113
+ if (result.claimed > 0) {
114
+ this.logger.log(
115
+ `Export cycle: claimed ${result.claimed}, done ${result.done}, failed ${result.failed}.`,
116
+ );
117
+ }
118
+ } catch (error) {
119
+ this.logger.error('Export cycle failed.', error instanceof Error ? error.stack : String(error));
120
+ } finally {
121
+ this.ticking = false;
122
+ }
123
+ }
124
+ }
@@ -0,0 +1,74 @@
1
+ import { Injectable, NotFoundException } from '@nestjs/common';
2
+ import type { Readable } from 'node:stream';
3
+ import { ReportExportService } from '@ftisindia/report-builder';
4
+ import type { ExportOutcome, QuerySpec } from '@ftisindia/report-builder';
5
+ import { RequestContextService } from '../../../request-context/application/services/request-context.service';
6
+ import { ExportRequestDto } from '../../dto/export-request.dto';
7
+ import { RequestReportsContext } from '../../infrastructure/request-reports-context';
8
+ import { LocalDiskReportExportStorage } from '../../infrastructure/storage/local-disk-export-storage.adapter';
9
+ import { withReportsErrorMapping } from './reports-error.mapper';
10
+
11
+ const FORMAT_MIME: Record<string, string> = {
12
+ csv: 'text/csv',
13
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
14
+ };
15
+
16
+ /**
17
+ * Thin HTTP-facing wrapper over the engine's ReportExportService. The sync-vs-
18
+ * async split by ACTUAL size, snapshot consistency, exportable:false stripping,
19
+ * and the PII-egress audit all live in the engine (design §9). The controller
20
+ * turns the ExportOutcome union into a StreamableFile or a 202 job response.
21
+ *
22
+ * Async export files land in the reports-OWNED export storage (no dependency
23
+ * on the forms file machinery); `download` streams a finished job's file back.
24
+ */
25
+ @Injectable()
26
+ export class ReportsExportsService {
27
+ constructor(
28
+ private readonly engine: ReportExportService,
29
+ private readonly reportsContext: RequestReportsContext,
30
+ private readonly requestContext: RequestContextService,
31
+ private readonly storage: LocalDiskReportExportStorage,
32
+ ) {}
33
+
34
+ async requestExport(orgId: string, key: string, dto: ExportRequestDto): Promise<ExportOutcome> {
35
+ this.requestContext.assertOrgScope(orgId);
36
+ return withReportsErrorMapping(() =>
37
+ this.engine.requestExport(
38
+ orgId,
39
+ key,
40
+ {
41
+ format: dto.format,
42
+ spec: dto.spec as unknown as QuerySpec | undefined,
43
+ columns: dto.columns,
44
+ },
45
+ this.reportsContext,
46
+ ),
47
+ );
48
+ }
49
+
50
+ async getJob(orgId: string, jobId: string) {
51
+ this.requestContext.assertOrgScope(orgId);
52
+ return withReportsErrorMapping(() => this.engine.getJob(orgId, jobId, this.reportsContext));
53
+ }
54
+
55
+ /** Stream a finished async-export file (the job's fileId is its storage key). */
56
+ async download(
57
+ orgId: string,
58
+ jobId: string,
59
+ ): Promise<{ stream: Readable; fileName: string; mimeType: string }> {
60
+ this.requestContext.assertOrgScope(orgId);
61
+ const job = await withReportsErrorMapping(() =>
62
+ this.engine.getJob(orgId, jobId, this.reportsContext),
63
+ );
64
+ if (job.status !== 'DONE' || job.fileId === null) {
65
+ throw new NotFoundException('The export is not ready for download.');
66
+ }
67
+ const format = job.spec.format;
68
+ return {
69
+ stream: this.storage.read(job.fileId),
70
+ fileName: `${job.reportKey}-v${job.reportVersion}.${format}`,
71
+ mimeType: FORMAT_MIME[format] ?? 'application/octet-stream',
72
+ };
73
+ }
74
+ }
@@ -0,0 +1,35 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ReportQueryService } from '@ftisindia/report-builder';
3
+ import type { QuerySpec } from '@ftisindia/report-builder';
4
+ import { RequestContextService } from '../../../request-context/application/services/request-context.service';
5
+ import { QuerySpecDto } from '../../dto/query-spec.dto';
6
+ import { RequestReportsContext } from '../../infrastructure/request-reports-context';
7
+ import { withReportsErrorMapping } from './reports-error.mapper';
8
+
9
+ /**
10
+ * Thin HTTP-facing wrapper over the engine's ReportQueryService — THE grid
11
+ * endpoint plus the self-describing meta descriptor (design §4). The DTO
12
+ * validated the envelope; the engine's compiler validates the semantics.
13
+ */
14
+ @Injectable()
15
+ export class ReportsQueriesService {
16
+ constructor(
17
+ private readonly engine: ReportQueryService,
18
+ private readonly reportsContext: RequestReportsContext,
19
+ private readonly requestContext: RequestContextService,
20
+ ) {}
21
+
22
+ async query(orgId: string, key: string, spec: QuerySpecDto) {
23
+ this.requestContext.assertOrgScope(orgId);
24
+ return withReportsErrorMapping(() =>
25
+ this.engine.query(orgId, key, spec as unknown as QuerySpec, this.reportsContext),
26
+ );
27
+ }
28
+
29
+ async meta(orgId: string, key: string, version: number | null) {
30
+ this.requestContext.assertOrgScope(orgId);
31
+ return withReportsErrorMapping(() =>
32
+ this.engine.meta(orgId, key, version, this.reportsContext),
33
+ );
34
+ }
35
+ }
@@ -0,0 +1,49 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { DEFAULT_REPORTS_POLICY } from '@ftisindia/report-builder';
3
+ import type { ReportsPolicy } from '@ftisindia/report-builder';
4
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
5
+ import { settingDefinitions } from '../../../settings/types/setting-definitions';
6
+
7
+ const REPORTS_SETTING_KEYS = [
8
+ 'reports.statementTimeoutMs',
9
+ 'reports.exportMaxSnapshotSeconds',
10
+ 'reports.maxRowsSync',
11
+ 'reports.countCap',
12
+ 'reports.liveTierMaxRows',
13
+ 'reports.resultCacheTtlMs',
14
+ 'reports.tagVocabulary',
15
+ ] as const;
16
+
17
+ /**
18
+ * Reads the org's reports policy from typed settings, falling back to the
19
+ * setting definitions' defaults for unset keys (the SettingsService 404s on
20
+ * unset keys, which is wrong for policy reads). Keys without a setting (page
21
+ * sizes, batch sizes, token TTLs, ...) keep the engine's DEFAULT_REPORTS_POLICY
22
+ * values. The result feeds every engine call that takes a ReportsPolicy —
23
+ * statement budgets, count caps, tier gates, export bounds (design §5.2/§8/§9).
24
+ */
25
+ @Injectable()
26
+ export class ReportsSettingsReader {
27
+ constructor(private readonly prisma: PrismaService) {}
28
+
29
+ async policyFor(orgId: string): Promise<ReportsPolicy> {
30
+ const rows = await this.prisma.organisationSetting.findMany({
31
+ where: { orgId, key: { in: [...REPORTS_SETTING_KEYS] } },
32
+ select: { key: true, value: true },
33
+ });
34
+ const values = new Map(rows.map((row) => [row.key, row.value as unknown]));
35
+ const read = <T>(key: (typeof REPORTS_SETTING_KEYS)[number]): T =>
36
+ (values.has(key) ? values.get(key) : settingDefinitions[key].defaultValue) as T;
37
+
38
+ return {
39
+ ...DEFAULT_REPORTS_POLICY,
40
+ statementTimeoutMs: read<number>('reports.statementTimeoutMs'),
41
+ exportMaxSnapshotSeconds: read<number>('reports.exportMaxSnapshotSeconds'),
42
+ maxRowsSync: read<number>('reports.maxRowsSync'),
43
+ countCap: read<number>('reports.countCap'),
44
+ liveTierMaxRows: read<number>('reports.liveTierMaxRows'),
45
+ resultCacheTtlMs: read<number>('reports.resultCacheTtlMs'),
46
+ tagVocabulary: read<string[]>('reports.tagVocabulary'),
47
+ };
48
+ }
49
+ }
@@ -0,0 +1,79 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import { ReportViewService } from '@ftisindia/report-builder';
3
+ import type { QuerySpec } from '@ftisindia/report-builder';
4
+ import { RequestContextService } from '../../../request-context/application/services/request-context.service';
5
+ import { CreateSavedViewDto } from '../../dto/create-saved-view.dto';
6
+ import { ListViewsQueryDto } from '../../dto/list-views-query.dto';
7
+ import { UpdateSavedViewDto } from '../../dto/update-saved-view.dto';
8
+ import { RequestReportsContext } from '../../infrastructure/request-reports-context';
9
+ import { withReportsErrorMapping } from './reports-error.mapper';
10
+
11
+ /**
12
+ * Thin HTTP-facing wrapper over the engine's ReportViewService. Personal vs
13
+ * shared semantics (ownerId null ⇒ shared, gated by reports.update) and the
14
+ * per-view compatibility verdicts live in the engine (design §4, finding #9).
15
+ */
16
+ @Injectable()
17
+ export class ReportsViewsService {
18
+ constructor(
19
+ private readonly engine: ReportViewService,
20
+ private readonly reportsContext: RequestReportsContext,
21
+ private readonly requestContext: RequestContextService,
22
+ ) {}
23
+
24
+ async list(orgId: string, key: string, query: ListViewsQueryDto) {
25
+ this.requestContext.assertOrgScope(orgId);
26
+ const items = await withReportsErrorMapping(() =>
27
+ this.engine.list(orgId, key, query.version ?? null, this.reportsContext),
28
+ );
29
+ return { items };
30
+ }
31
+
32
+ async createPersonal(orgId: string, key: string, dto: CreateSavedViewDto) {
33
+ this.requestContext.assertOrgScope(orgId);
34
+ return withReportsErrorMapping(() =>
35
+ this.engine.createPersonal(
36
+ orgId,
37
+ key,
38
+ { name: dto.name, spec: dto.spec as unknown as QuerySpec },
39
+ this.reportsContext,
40
+ ),
41
+ );
42
+ }
43
+
44
+ async createShared(orgId: string, key: string, dto: CreateSavedViewDto) {
45
+ this.requestContext.assertOrgScope(orgId);
46
+ return withReportsErrorMapping(() =>
47
+ this.engine.createShared(
48
+ orgId,
49
+ key,
50
+ { name: dto.name, spec: dto.spec as unknown as QuerySpec },
51
+ this.reportsContext,
52
+ ),
53
+ );
54
+ }
55
+
56
+ async update(orgId: string, key: string, viewId: string, dto: UpdateSavedViewDto) {
57
+ this.requestContext.assertOrgScope(orgId);
58
+ return withReportsErrorMapping(() =>
59
+ this.engine.update(
60
+ orgId,
61
+ key,
62
+ viewId,
63
+ {
64
+ ...(dto.name === undefined ? {} : { name: dto.name }),
65
+ ...(dto.spec === undefined ? {} : { spec: dto.spec as unknown as QuerySpec }),
66
+ },
67
+ this.reportsContext,
68
+ ),
69
+ );
70
+ }
71
+
72
+ async delete(orgId: string, key: string, viewId: string) {
73
+ this.requestContext.assertOrgScope(orgId);
74
+ await withReportsErrorMapping(() =>
75
+ this.engine.delete(orgId, key, viewId, this.reportsContext),
76
+ );
77
+ return { deleted: true };
78
+ }
79
+ }
@@ -0,0 +1,21 @@
1
+ import { ApiProperty } from '@nestjs/swagger';
2
+
3
+ export class ActionResultResponseDto {
4
+ @ApiProperty({ enum: ['DONE'], example: 'DONE' })
5
+ status!: 'DONE';
6
+
7
+ @ApiProperty({ example: 240 })
8
+ affectedRows!: number;
9
+
10
+ @ApiProperty({
11
+ nullable: true,
12
+ description: 'Whatever the delegated handler returned (e.g. { updated: 12 }).',
13
+ })
14
+ result!: unknown;
15
+
16
+ @ApiProperty({
17
+ example: false,
18
+ description: 'True when this call replayed a previously recorded idempotent run (§6.3).',
19
+ })
20
+ replayed!: boolean;
21
+ }