@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.
- package/package.json +1 -1
- package/template/.env.example +28 -0
- package/template/README.md +51 -0
- package/template/_gitignore +6 -0
- package/template/_package.json +10 -1
- package/template/docs/FORMS.md +188 -0
- package/template/docs/FORMS_CHECKLIST.md +69 -0
- package/template/docs/REPORTS.md +255 -0
- package/template/docs/REPORTS_CHECKLIST.md +152 -0
- package/template/prisma/migrations/20260612000000_add_form_builder/migration.sql +147 -0
- package/template/prisma/migrations/20260613000000_add_report_builder/migration.sql +129 -0
- package/template/prisma/migrations/20260616000000_add_form_outbox_claimed_by/migration.sql +5 -0
- package/template/prisma/schema.prisma +289 -0
- package/template/scripts/export-openapi.ts +85 -0
- package/template/scripts/gen-form.mjs +149 -0
- package/template/scripts/push-form.ts +124 -0
- package/template/src/app.module.ts +30 -8
- package/template/src/common/dto/membership-response.dto.ts +1 -0
- package/template/src/common/dto/role-summary.dto.ts +3 -3
- package/template/src/common/dto/user-summary.dto.ts +3 -3
- package/template/src/config/env.validation.ts +28 -0
- package/template/src/config/forms.config.ts +13 -0
- package/template/src/config/index.ts +2 -0
- package/template/src/config/openapi.ts +12 -0
- package/template/src/config/reports-secret.ts +15 -0
- package/template/src/config/reports.config.ts +18 -0
- package/template/src/main.ts +3 -12
- package/template/src/modules/access-control/dto/access-control-response.dto.ts +3 -0
- package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +5 -1
- package/template/src/modules/access-control/types/permission-key.ts +27 -0
- package/template/src/modules/access-control/types/route-permission-registry.ts +183 -0
- package/template/src/modules/audit/dto/audit-response.dto.ts +7 -3
- package/template/src/modules/auth/auth.module.ts +3 -1
- package/template/src/modules/auth/dto/auth-response.dto.ts +1 -1
- package/template/src/modules/forms/application/services/file-gc.service.ts +85 -0
- package/template/src/modules/forms/application/services/forms-definitions.service.ts +137 -0
- package/template/src/modules/forms/application/services/forms-error.mapper.ts +64 -0
- package/template/src/modules/forms/application/services/forms-export.service.ts +210 -0
- package/template/src/modules/forms/application/services/forms-files.service.ts +164 -0
- package/template/src/modules/forms/application/services/forms-public.service.ts +49 -0
- package/template/src/modules/forms/application/services/forms-settings-reader.service.ts +53 -0
- package/template/src/modules/forms/application/services/forms-submissions.service.ts +103 -0
- package/template/src/modules/forms/application/services/handlers/authenticate.action.ts +37 -0
- package/template/src/modules/forms/application/services/handlers/logging-email.handler.ts +22 -0
- package/template/src/modules/forms/application/services/handlers/send-confirmation-email.action.ts +40 -0
- package/template/src/modules/forms/application/services/handlers/webhook-delivery.transport.ts +319 -0
- package/template/src/modules/forms/application/services/handlers/webhook.handler.ts +89 -0
- package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +131 -0
- package/template/src/modules/forms/dto/create-form-definition.dto.ts +12 -0
- package/template/src/modules/forms/dto/data-source-response.dto.ts +19 -0
- package/template/src/modules/forms/dto/export-submissions-query.dto.ts +33 -0
- package/template/src/modules/forms/dto/file-upload-response.dto.ts +24 -0
- package/template/src/modules/forms/dto/form-definition-response.dto.ts +50 -0
- package/template/src/modules/forms/dto/form-render-response.dto.ts +17 -0
- package/template/src/modules/forms/dto/list-form-definitions-query.dto.ts +10 -0
- package/template/src/modules/forms/dto/list-submissions-query.dto.ts +10 -0
- package/template/src/modules/forms/dto/public-submit-form.dto.ts +24 -0
- package/template/src/modules/forms/dto/set-public-access.dto.ts +8 -0
- package/template/src/modules/forms/dto/submission-response.dto.ts +99 -0
- package/template/src/modules/forms/dto/submit-form.dto.ts +50 -0
- package/template/src/modules/forms/dto/update-form-definition.dto.ts +12 -0
- package/template/src/modules/forms/dto/upload-file-query.dto.ts +33 -0
- package/template/src/modules/forms/dto/validate-submission.dto.ts +22 -0
- package/template/src/modules/forms/examples/abstract-submission.form.json +80 -0
- package/template/src/modules/forms/examples/login.form.json +24 -0
- package/template/src/modules/forms/examples/registration.form.json +44 -0
- package/template/src/modules/forms/forms.module.ts +228 -0
- package/template/src/modules/forms/forms.tokens.ts +6 -0
- package/template/src/modules/forms/infrastructure/audit-sink.adapter.ts +30 -0
- package/template/src/modules/forms/infrastructure/casl-forms-authorization.ts +31 -0
- package/template/src/modules/forms/infrastructure/prisma-tx-runner.ts +17 -0
- package/template/src/modules/forms/infrastructure/registry/form-extension.decorators.ts +17 -0
- package/template/src/modules/forms/infrastructure/registry/registry-bootstrap.service.ts +82 -0
- package/template/src/modules/forms/infrastructure/request-forms-context.ts +60 -0
- package/template/src/modules/forms/infrastructure/schema-check/forms-schema-check.service.ts +76 -0
- package/template/src/modules/forms/infrastructure/storage/local-disk-storage.adapter.ts +43 -0
- package/template/src/modules/forms/infrastructure/stores/index.ts +5 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-action-log.store.ts +37 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-file.store.ts +108 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-form-definition.store.ts +147 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-outbox.store.ts +156 -0
- package/template/src/modules/forms/infrastructure/stores/prisma-submission.store.ts +164 -0
- package/template/src/modules/forms/presentation/forms-data-sources.controller.ts +58 -0
- package/template/src/modules/forms/presentation/forms-definitions.controller.ts +191 -0
- package/template/src/modules/forms/presentation/forms-files.controller.ts +79 -0
- package/template/src/modules/forms/presentation/forms-submissions.controller.ts +154 -0
- package/template/src/modules/forms/presentation/forms-upload.interceptor.ts +33 -0
- package/template/src/modules/forms/presentation/public-forms.controller.ts +51 -0
- package/template/src/modules/invitations/dto/invitation-response.dto.ts +4 -0
- package/template/src/modules/organisations/dto/organisation-response.dto.ts +1 -0
- package/template/src/modules/reports/application/services/reports-actions.service.ts +54 -0
- package/template/src/modules/reports/application/services/reports-definitions.service.ts +66 -0
- package/template/src/modules/reports/application/services/reports-error.mapper.ts +97 -0
- package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts +205 -0
- package/template/src/modules/reports/application/services/reports-exports.service.ts +78 -0
- package/template/src/modules/reports/application/services/reports-queries.service.ts +35 -0
- package/template/src/modules/reports/application/services/reports-settings-reader.service.ts +49 -0
- package/template/src/modules/reports/application/services/reports-views.service.ts +79 -0
- package/template/src/modules/reports/dto/action-result-response.dto.ts +21 -0
- package/template/src/modules/reports/dto/create-report-definition.dto.ts +86 -0
- package/template/src/modules/reports/dto/create-saved-view.dto.ts +26 -0
- package/template/src/modules/reports/dto/execute-action.dto.ts +71 -0
- package/template/src/modules/reports/dto/export-job-response.dto.ts +60 -0
- package/template/src/modules/reports/dto/export-request.dto.ts +34 -0
- package/template/src/modules/reports/dto/list-reports-query.dto.ts +10 -0
- package/template/src/modules/reports/dto/list-views-query.dto.ts +17 -0
- package/template/src/modules/reports/dto/prepare-action-response.dto.ts +14 -0
- package/template/src/modules/reports/dto/prepare-action.dto.ts +27 -0
- package/template/src/modules/reports/dto/query-response.dto.ts +64 -0
- package/template/src/modules/reports/dto/query-spec.dto.ts +120 -0
- package/template/src/modules/reports/dto/report-definition-response.dto.ts +64 -0
- package/template/src/modules/reports/dto/report-meta-query.dto.ts +16 -0
- package/template/src/modules/reports/dto/report-meta-response.dto.ts +113 -0
- package/template/src/modules/reports/dto/saved-view-response.dto.ts +66 -0
- package/template/src/modules/reports/dto/update-report-definition.dto.ts +9 -0
- package/template/src/modules/reports/dto/update-saved-view.dto.ts +27 -0
- package/template/src/modules/reports/examples/abstract-review-board.report.json +54 -0
- package/template/src/modules/reports/examples/org-members.report.json +55 -0
- package/template/src/modules/reports/infrastructure/audit-sink.adapter.ts +31 -0
- package/template/src/modules/reports/infrastructure/casl-reports-authorization.ts +39 -0
- package/template/src/modules/reports/infrastructure/forms-adapter/form-report-source.adapter.ts +292 -0
- package/template/src/modules/reports/infrastructure/forms-adapter/form-row-actions.ts +171 -0
- package/template/src/modules/reports/infrastructure/forms-adapter/forms-bridge-bootstrap.service.ts +32 -0
- package/template/src/modules/reports/infrastructure/prisma-catalog.adapter.ts +95 -0
- package/template/src/modules/reports/infrastructure/prisma-query-executor.ts +103 -0
- package/template/src/modules/reports/infrastructure/prisma-snapshot-runner.ts +47 -0
- package/template/src/modules/reports/infrastructure/prisma-tx-runner.ts +18 -0
- package/template/src/modules/reports/infrastructure/registry/registry-bootstrap.service.ts +61 -0
- package/template/src/modules/reports/infrastructure/registry/report-extension.decorators.ts +14 -0
- package/template/src/modules/reports/infrastructure/reports-job-queue.adapter.ts +28 -0
- package/template/src/modules/reports/infrastructure/request-reports-context.ts +42 -0
- package/template/src/modules/reports/infrastructure/schema-check/reports-schema-check.service.ts +116 -0
- package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts +92 -0
- package/template/src/modules/reports/infrastructure/stores/index.ts +5 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-bulk-action-run.store.ts +89 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-export-job.store.ts +93 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-report-definition.store.ts +171 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-row-tag.store.ts +110 -0
- package/template/src/modules/reports/infrastructure/stores/prisma-saved-view.store.ts +144 -0
- package/template/src/modules/reports/presentation/reports-actions.controller.ts +83 -0
- package/template/src/modules/reports/presentation/reports-definitions.controller.ts +156 -0
- package/template/src/modules/reports/presentation/reports-export-jobs.controller.ts +61 -0
- package/template/src/modules/reports/presentation/reports-export.controller.ts +76 -0
- package/template/src/modules/reports/presentation/reports-query.controller.ts +52 -0
- package/template/src/modules/reports/presentation/reports-views.controller.ts +140 -0
- package/template/src/modules/reports/reports-forms.module.ts +33 -0
- package/template/src/modules/reports/reports.module.ts +335 -0
- package/template/src/modules/reports/reports.tokens.ts +11 -0
- package/template/src/modules/reports/sources/org-members.source.ts +112 -0
- package/template/src/modules/settings/types/setting-definitions.ts +94 -0
- package/template/test/forms-captcha.e2e-spec.ts +163 -0
- package/template/test/forms-definitions.e2e-spec.ts +394 -0
- package/template/test/forms-export.e2e-spec.ts +390 -0
- package/template/test/forms-files.e2e-spec.ts +345 -0
- package/template/test/forms-outbox.e2e-spec.ts +570 -0
- package/template/test/forms-permission-sync.spec.ts +27 -0
- package/template/test/forms-public.e2e-spec.ts +293 -0
- package/template/test/forms-schema-check.e2e-spec.ts +65 -0
- package/template/test/forms-submissions.e2e-spec.ts +500 -0
- package/template/test/forms-throttling.e2e-spec.ts +146 -0
- package/template/test/forms-webhooks.e2e-spec.ts +403 -0
- package/template/test/jest-e2e.json +1 -0
- package/template/test/reports-advanced.e2e-spec.ts +381 -0
- package/template/test/reports-permission-sync.spec.ts +30 -0
- package/template/test/reports-query.e2e-spec.ts +402 -0
- package/template/test/reports-tiers.e2e-spec.ts +343 -0
- package/template/test/route-registry.validator.spec.ts +22 -0
package/template/src/modules/reports/application/services/reports-export-dispatcher.service.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
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 { LocalDiskReportExportStorage } from '../../infrastructure/storage/local-disk-export-storage.adapter';
|
|
12
|
+
import { RequestReportsContext } from '../../infrastructure/request-reports-context';
|
|
13
|
+
|
|
14
|
+
const RETENTION_BATCH_SIZE = 100;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Reports-OWNED async export worker (report design §9). Instead of riding the
|
|
18
|
+
* forms transactional outbox, it polls the engine's own `ReportExportJob`
|
|
19
|
+
* table for PENDING rows — so the reports module needs no FormsModule. Each
|
|
20
|
+
* job is claimed atomically (PENDING → RUNNING) to prevent double-processing,
|
|
21
|
+
* then run inside a restored worker request context (source: 'worker', orgId,
|
|
22
|
+
* userId) exactly as the forms dispatcher restores context for its handlers.
|
|
23
|
+
*
|
|
24
|
+
* The engine's ReportExportService.runJob streams from one REPEATABLE READ
|
|
25
|
+
* snapshot into the reports export storage and marks the row DONE/FAILED.
|
|
26
|
+
*/
|
|
27
|
+
@Injectable()
|
|
28
|
+
export class ReportsExportDispatcherService implements OnApplicationBootstrap, OnModuleDestroy {
|
|
29
|
+
private readonly logger = new Logger('ReportsExportWorker');
|
|
30
|
+
private timer?: NodeJS.Timeout;
|
|
31
|
+
private retentionTimer?: NodeJS.Timeout;
|
|
32
|
+
private ticking = false;
|
|
33
|
+
private sweepingRetention = false;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
private readonly config: ConfigService,
|
|
37
|
+
private readonly prisma: PrismaService,
|
|
38
|
+
private readonly exports: ReportExportService,
|
|
39
|
+
private readonly storage: LocalDiskReportExportStorage,
|
|
40
|
+
private readonly requestContext: RequestContextService,
|
|
41
|
+
private readonly reportsContext: RequestReportsContext,
|
|
42
|
+
) {}
|
|
43
|
+
|
|
44
|
+
onApplicationBootstrap(): void {
|
|
45
|
+
if (this.config.get<boolean>('reports.exportWorkerEnabled') === false) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const pollMs = this.config.get<number>('reports.exportPollMs') ?? 5000;
|
|
49
|
+
this.timer = setInterval(() => {
|
|
50
|
+
void this.tick();
|
|
51
|
+
}, pollMs);
|
|
52
|
+
this.timer.unref?.();
|
|
53
|
+
|
|
54
|
+
const retentionSweepMs = this.config.get<number>('reports.exportRetentionSweepMs') ?? 3_600_000;
|
|
55
|
+
this.retentionTimer = setInterval(() => {
|
|
56
|
+
void this.sweepRetention();
|
|
57
|
+
}, retentionSweepMs);
|
|
58
|
+
this.retentionTimer.unref?.();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
onModuleDestroy(): void {
|
|
62
|
+
if (this.timer) {
|
|
63
|
+
clearInterval(this.timer);
|
|
64
|
+
this.timer = undefined;
|
|
65
|
+
}
|
|
66
|
+
if (this.retentionTimer) {
|
|
67
|
+
clearInterval(this.retentionTimer);
|
|
68
|
+
this.retentionTimer = undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One poll cycle — exposed for tests / manual draining. */
|
|
73
|
+
async runOnce(batchSize = 10): Promise<{ claimed: number; done: number; failed: number }> {
|
|
74
|
+
const pending = await this.prisma.reportExportJob.findMany({
|
|
75
|
+
where: { status: 'PENDING' },
|
|
76
|
+
orderBy: { createdAt: 'asc' },
|
|
77
|
+
take: batchSize,
|
|
78
|
+
select: { id: true, orgId: true, requestedBy: true },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
let claimed = 0;
|
|
82
|
+
let done = 0;
|
|
83
|
+
let failed = 0;
|
|
84
|
+
for (const job of pending) {
|
|
85
|
+
// Atomic claim: only the worker that flips PENDING → RUNNING runs it.
|
|
86
|
+
const claim = await this.prisma.reportExportJob.updateMany({
|
|
87
|
+
where: { id: job.id, orgId: job.orgId, status: 'PENDING' },
|
|
88
|
+
data: { status: 'RUNNING' },
|
|
89
|
+
});
|
|
90
|
+
if (claim.count !== 1) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
claimed += 1;
|
|
94
|
+
|
|
95
|
+
const outcome = await this.requestContext.run(
|
|
96
|
+
{
|
|
97
|
+
source: 'worker',
|
|
98
|
+
orgId: job.orgId,
|
|
99
|
+
userId: job.requestedBy,
|
|
100
|
+
},
|
|
101
|
+
async () => {
|
|
102
|
+
try {
|
|
103
|
+
await this.exports.runJob(job.orgId, job.id, this.reportsContext);
|
|
104
|
+
return 'done' as const;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
this.logger.error(
|
|
107
|
+
`Export job ${job.id} failed.`,
|
|
108
|
+
error instanceof Error ? error.stack : String(error),
|
|
109
|
+
);
|
|
110
|
+
return 'failed' as const;
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
if (outcome === 'done') {
|
|
115
|
+
done += 1;
|
|
116
|
+
} else {
|
|
117
|
+
failed += 1;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { claimed, done, failed };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Delete expired local export files and clear their job fileId. */
|
|
124
|
+
async cleanupExpiredFiles(now = new Date()): Promise<number> {
|
|
125
|
+
const retentionDays = this.config.get<number>('reports.exportRetentionDays') ?? 7;
|
|
126
|
+
if (retentionDays <= 0) {
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const cutoff = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000);
|
|
131
|
+
const jobs = await this.prisma.reportExportJob.findMany({
|
|
132
|
+
where: {
|
|
133
|
+
status: 'DONE',
|
|
134
|
+
fileId: { not: null },
|
|
135
|
+
updatedAt: { lt: cutoff },
|
|
136
|
+
},
|
|
137
|
+
orderBy: { updatedAt: 'asc' },
|
|
138
|
+
take: RETENTION_BATCH_SIZE,
|
|
139
|
+
select: { id: true, orgId: true, fileId: true },
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
let removed = 0;
|
|
143
|
+
for (const job of jobs) {
|
|
144
|
+
if (job.fileId === null) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
await this.storage.delete(job.fileId);
|
|
149
|
+
const result = await this.prisma.reportExportJob.updateMany({
|
|
150
|
+
where: { id: job.id, orgId: job.orgId, fileId: job.fileId },
|
|
151
|
+
data: {
|
|
152
|
+
fileId: null,
|
|
153
|
+
error: `Export file expired after ${retentionDays} day(s).`,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
removed += result.count;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
this.logger.warn(
|
|
159
|
+
`Failed to expire export file for job ${job.id}: ${error instanceof Error ? error.message : String(error)}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return removed;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private async tick(): Promise<void> {
|
|
168
|
+
if (this.ticking) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
this.ticking = true;
|
|
172
|
+
try {
|
|
173
|
+
const result = await this.runOnce();
|
|
174
|
+
if (result.claimed > 0) {
|
|
175
|
+
this.logger.log(
|
|
176
|
+
`Export cycle: claimed ${result.claimed}, done ${result.done}, failed ${result.failed}.`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
this.logger.error('Export cycle failed.', error instanceof Error ? error.stack : String(error));
|
|
181
|
+
} finally {
|
|
182
|
+
this.ticking = false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async sweepRetention(): Promise<void> {
|
|
187
|
+
if (this.sweepingRetention) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
this.sweepingRetention = true;
|
|
191
|
+
try {
|
|
192
|
+
const removed = await this.cleanupExpiredFiles();
|
|
193
|
+
if (removed > 0) {
|
|
194
|
+
this.logger.log(`Expired ${removed} report export file(s).`);
|
|
195
|
+
}
|
|
196
|
+
} catch (error) {
|
|
197
|
+
this.logger.error(
|
|
198
|
+
'Export retention sweep failed.',
|
|
199
|
+
error instanceof Error ? error.stack : String(error),
|
|
200
|
+
);
|
|
201
|
+
} finally {
|
|
202
|
+
this.sweepingRetention = false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
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') {
|
|
65
|
+
throw new NotFoundException('The export is not ready for download.');
|
|
66
|
+
}
|
|
67
|
+
const fileId = job.fileId;
|
|
68
|
+
if (fileId === null) {
|
|
69
|
+
throw new NotFoundException('The export file is no longer available.');
|
|
70
|
+
}
|
|
71
|
+
const format = job.spec.format;
|
|
72
|
+
return {
|
|
73
|
+
stream: this.storage.read(fileId),
|
|
74
|
+
fileName: `${job.reportKey}-v${job.reportVersion}.${format}`,
|
|
75
|
+
mimeType: FORMAT_MIME[format] ?? 'application/octet-stream',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import {
|
|
3
|
+
Allow,
|
|
4
|
+
ArrayMinSize,
|
|
5
|
+
IsArray,
|
|
6
|
+
IsIn,
|
|
7
|
+
IsObject,
|
|
8
|
+
IsOptional,
|
|
9
|
+
IsString,
|
|
10
|
+
Matches,
|
|
11
|
+
} from 'class-validator';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The request body IS the ReportDefinition document (report design §2/§10).
|
|
15
|
+
* Envelope-only validation — the same two-layer split as forms: this DTO
|
|
16
|
+
* whitelists the document's top-level keys (the global ValidationPipe runs
|
|
17
|
+
* with forbidNonWhitelisted) and checks the cheap invariants; the engine's
|
|
18
|
+
* meta-schema + linter are the real gate for everything nested (columns,
|
|
19
|
+
* source binding, export config, ...), so nested content passes through
|
|
20
|
+
* untouched.
|
|
21
|
+
*/
|
|
22
|
+
export class CreateReportDefinitionDto {
|
|
23
|
+
@ApiProperty({ example: 'abstract-review-board' })
|
|
24
|
+
@IsString()
|
|
25
|
+
@Matches(/^[a-z][a-z0-9-]*$/)
|
|
26
|
+
key!: string;
|
|
27
|
+
|
|
28
|
+
@ApiPropertyOptional({ example: 'Abstract review board' })
|
|
29
|
+
@IsOptional()
|
|
30
|
+
@IsString()
|
|
31
|
+
title?: string;
|
|
32
|
+
|
|
33
|
+
@ApiPropertyOptional({ example: 'All submitted abstracts with review status.' })
|
|
34
|
+
@IsOptional()
|
|
35
|
+
@IsString()
|
|
36
|
+
description?: string;
|
|
37
|
+
|
|
38
|
+
@ApiProperty({
|
|
39
|
+
type: 'object',
|
|
40
|
+
additionalProperties: true,
|
|
41
|
+
description: 'Source binding ({ kind, key, options? }) — resolved and validated by the engine.',
|
|
42
|
+
})
|
|
43
|
+
@IsObject()
|
|
44
|
+
source!: Record<string, unknown>;
|
|
45
|
+
|
|
46
|
+
@ApiProperty({
|
|
47
|
+
type: 'array',
|
|
48
|
+
items: { type: 'object', additionalProperties: true },
|
|
49
|
+
description: 'Column definitions — validated against the engine meta-schema and linter.',
|
|
50
|
+
})
|
|
51
|
+
@IsArray()
|
|
52
|
+
@ArrayMinSize(1)
|
|
53
|
+
columns!: Record<string, unknown>[];
|
|
54
|
+
|
|
55
|
+
@ApiPropertyOptional({
|
|
56
|
+
type: 'array',
|
|
57
|
+
items: { type: 'object', additionalProperties: true },
|
|
58
|
+
})
|
|
59
|
+
@Allow()
|
|
60
|
+
defaultSort?: unknown;
|
|
61
|
+
|
|
62
|
+
@ApiPropertyOptional({ type: 'object', additionalProperties: true })
|
|
63
|
+
@Allow()
|
|
64
|
+
search?: unknown;
|
|
65
|
+
|
|
66
|
+
@ApiPropertyOptional({ type: [String] })
|
|
67
|
+
@Allow()
|
|
68
|
+
rowActions?: unknown;
|
|
69
|
+
|
|
70
|
+
@ApiPropertyOptional({ type: 'object', additionalProperties: true })
|
|
71
|
+
@Allow()
|
|
72
|
+
export?: unknown;
|
|
73
|
+
|
|
74
|
+
@ApiProperty({ enum: ['live', 'indexed', 'materialized'], example: 'indexed' })
|
|
75
|
+
@IsIn(['live', 'indexed', 'materialized'])
|
|
76
|
+
performanceTier!: 'live' | 'indexed' | 'materialized';
|
|
77
|
+
|
|
78
|
+
// Server-assigned; tolerated on input so fetched documents round-trip.
|
|
79
|
+
@ApiPropertyOptional({ description: 'Server-assigned; ignored on input.' })
|
|
80
|
+
@Allow()
|
|
81
|
+
version?: unknown;
|
|
82
|
+
|
|
83
|
+
@ApiPropertyOptional({ description: 'Server-assigned; ignored on input.' })
|
|
84
|
+
@Allow()
|
|
85
|
+
status?: unknown;
|
|
86
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ApiProperty } from '@nestjs/swagger';
|
|
2
|
+
import { Type } from 'class-transformer';
|
|
3
|
+
import {
|
|
4
|
+
IsDefined,
|
|
5
|
+
IsObject,
|
|
6
|
+
IsString,
|
|
7
|
+
MaxLength,
|
|
8
|
+
MinLength,
|
|
9
|
+
ValidateNested,
|
|
10
|
+
} from 'class-validator';
|
|
11
|
+
import { QuerySpecDto } from './query-spec.dto';
|
|
12
|
+
|
|
13
|
+
export class CreateSavedViewDto {
|
|
14
|
+
@ApiProperty({ example: 'My ML shortlist', minLength: 1, maxLength: 120 })
|
|
15
|
+
@IsString()
|
|
16
|
+
@MinLength(1)
|
|
17
|
+
@MaxLength(120)
|
|
18
|
+
name!: string;
|
|
19
|
+
|
|
20
|
+
@ApiProperty({ type: QuerySpecDto })
|
|
21
|
+
@IsDefined()
|
|
22
|
+
@IsObject()
|
|
23
|
+
@ValidateNested()
|
|
24
|
+
@Type(() => QuerySpecDto)
|
|
25
|
+
spec!: QuerySpecDto;
|
|
26
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import { Type } from 'class-transformer';
|
|
3
|
+
import {
|
|
4
|
+
ArrayMaxSize,
|
|
5
|
+
IsArray,
|
|
6
|
+
IsObject,
|
|
7
|
+
IsOptional,
|
|
8
|
+
IsString,
|
|
9
|
+
MaxLength,
|
|
10
|
+
MinLength,
|
|
11
|
+
ValidateNested,
|
|
12
|
+
} from 'class-validator';
|
|
13
|
+
import { QuerySpecDto } from './query-spec.dto';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Bulk selection (§6.3): exactly one of byIds / byFilter — enforced by the
|
|
17
|
+
* engine. byIds is self-snapshotting and capped; byFilter requires the
|
|
18
|
+
* prepared action token because rows matching at confirmation may differ at
|
|
19
|
+
* execution.
|
|
20
|
+
*/
|
|
21
|
+
export class ExecuteActionSelectionDto {
|
|
22
|
+
@ApiPropertyOptional({ type: [String], maxItems: 1000 })
|
|
23
|
+
@IsOptional()
|
|
24
|
+
@IsArray()
|
|
25
|
+
@ArrayMaxSize(1000)
|
|
26
|
+
@IsString({ each: true })
|
|
27
|
+
byIds?: string[];
|
|
28
|
+
|
|
29
|
+
@ApiPropertyOptional({ type: QuerySpecDto })
|
|
30
|
+
@IsOptional()
|
|
31
|
+
@IsObject()
|
|
32
|
+
@ValidateNested()
|
|
33
|
+
@Type(() => QuerySpecDto)
|
|
34
|
+
byFilter?: QuerySpecDto;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class ExecuteActionDto {
|
|
38
|
+
@ApiPropertyOptional({ type: ExecuteActionSelectionDto })
|
|
39
|
+
@IsOptional()
|
|
40
|
+
@IsObject()
|
|
41
|
+
@ValidateNested()
|
|
42
|
+
@Type(() => ExecuteActionSelectionDto)
|
|
43
|
+
selection?: ExecuteActionSelectionDto;
|
|
44
|
+
|
|
45
|
+
@ApiPropertyOptional({
|
|
46
|
+
description: 'Prepared action token — REQUIRED for byFilter selections (§6.3).',
|
|
47
|
+
})
|
|
48
|
+
@IsOptional()
|
|
49
|
+
@IsString()
|
|
50
|
+
actionToken?: string;
|
|
51
|
+
|
|
52
|
+
@ApiPropertyOptional({
|
|
53
|
+
type: 'object',
|
|
54
|
+
additionalProperties: true,
|
|
55
|
+
description: "Action input — validated against the action's JSON Schema by the engine.",
|
|
56
|
+
})
|
|
57
|
+
@IsOptional()
|
|
58
|
+
@IsObject()
|
|
59
|
+
input?: Record<string, unknown>;
|
|
60
|
+
|
|
61
|
+
@ApiPropertyOptional({
|
|
62
|
+
minLength: 1,
|
|
63
|
+
maxLength: 120,
|
|
64
|
+
description: 'Idempotency ledger key — retries return the recorded outcome (§6.3).',
|
|
65
|
+
})
|
|
66
|
+
@IsOptional()
|
|
67
|
+
@IsString()
|
|
68
|
+
@MinLength(1)
|
|
69
|
+
@MaxLength(120)
|
|
70
|
+
idempotencyKey?: string;
|
|
71
|
+
}
|