@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.
- package/package.json +1 -1
- package/template/.env.example +31 -0
- package/template/README.md +61 -0
- package/template/_gitignore +6 -0
- package/template/_package.json +6 -0
- package/template/docs/FORMS.md +169 -0
- package/template/docs/FORMS_CHECKLIST.md +61 -0
- package/template/docs/REPORTS.md +246 -0
- package/template/docs/REPORTS_CHECKLIST.md +97 -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/schema.prisma +285 -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 +29 -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/app.config.ts +6 -1
- package/template/src/config/env.validation.ts +45 -0
- package/template/src/config/forms.config.ts +12 -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 +16 -0
- package/template/src/main.ts +16 -12
- package/template/src/modules/access-control/access-control.module.ts +2 -1
- 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 +35 -0
- package/template/src/modules/access-control/presentation/current-access-control.controller.ts +40 -0
- 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.handler.ts +41 -0
- package/template/src/modules/forms/application/services/outbox-dispatcher.service.ts +109 -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 +226 -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 +133 -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/application/services/organisations.service.ts +67 -1
- package/template/src/modules/organisations/dto/organisation-response.dto.ts +52 -0
- package/template/src/modules/organisations/presentation/organisations.controller.ts +25 -3
- 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 +124 -0
- package/template/src/modules/reports/application/services/reports-exports.service.ts +74 -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 +79 -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-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 +309 -0
- package/template/test/forms-permission-sync.spec.ts +27 -0
- package/template/test/forms-public.e2e-spec.ts +269 -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-webhooks.e2e-spec.ts +261 -0
- package/template/test/frontend-bootstrap.spec.ts +181 -0
- package/template/test/reports-advanced.e2e-spec.ts +368 -0
- package/template/test/reports-permission-sync.spec.ts +30 -0
- package/template/test/reports-query.e2e-spec.ts +350 -0
- package/template/test/reports-tiers.e2e-spec.ts +257 -0
- package/template/test/route-registry.validator.spec.ts +34 -0
- package/template/test/security.e2e-spec.ts +134 -2
package/template/src/modules/reports/infrastructure/schema-check/reports-schema-check.service.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
|
2
|
+
import { ConfigService } from '@nestjs/config';
|
|
3
|
+
import { Prisma } from '@prisma/client';
|
|
4
|
+
import {
|
|
5
|
+
EXPECTED_REPORTS_SCHEMA,
|
|
6
|
+
REPORTS_ENGINE_SCHEMA_VERSION,
|
|
7
|
+
REPORTS_PARTIAL_UNIQUE_SQL,
|
|
8
|
+
REPORTS_PRISMA_SNIPPET_PATH,
|
|
9
|
+
REQUIRED_REPORTS_INDEXES,
|
|
10
|
+
} from '@ftisindia/report-builder';
|
|
11
|
+
import { PrismaService } from '../../../../database/prisma/prisma.service';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Boot-time schema compatibility check (ecosystem guide §10.1): the app owns
|
|
15
|
+
* schema.prisma and migrations; the engine ships the canonical snippet and a
|
|
16
|
+
* tables/columns manifest. On mismatch the app fails fast at startup with an
|
|
17
|
+
* actionable message instead of crashing mid-request with a cryptic Prisma
|
|
18
|
+
* error. Beyond tables/columns, the saved-view name uniques are PARTIAL
|
|
19
|
+
* unique indexes Prisma cannot represent (report design §12, finding #5) —
|
|
20
|
+
* they are verified via pg_indexes so a `migrate dev` regression cannot ship
|
|
21
|
+
* silently. Escape hatch: REPORTS_SCHEMA_CHECK=off.
|
|
22
|
+
*/
|
|
23
|
+
@Injectable()
|
|
24
|
+
export class ReportsSchemaCheckService implements OnApplicationBootstrap {
|
|
25
|
+
private readonly logger = new Logger('ReportsSchemaCheck');
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly prisma: PrismaService,
|
|
29
|
+
private readonly config: ConfigService,
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
async onApplicationBootstrap(): Promise<void> {
|
|
33
|
+
if (this.config.get<string>('reports.schemaCheck') === 'off') {
|
|
34
|
+
this.logger.warn('Reports schema check is disabled (REPORTS_SCHEMA_CHECK=off).');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
await this.check();
|
|
38
|
+
this.logger.log(
|
|
39
|
+
`Reports schema check passed (engine schema version ${REPORTS_ENGINE_SCHEMA_VERSION}).`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async check(): Promise<void> {
|
|
44
|
+
const tableProblems = await this.missingTablesOrColumns();
|
|
45
|
+
const indexProblems = await this.missingPartialUniques();
|
|
46
|
+
const problems = [...tableProblems, ...indexProblems];
|
|
47
|
+
|
|
48
|
+
if (problems.length === 0) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const lines = [
|
|
53
|
+
`The database does not match @ftisindia/report-builder engine schema version ${REPORTS_ENGINE_SCHEMA_VERSION}:`,
|
|
54
|
+
...problems.map((problem) => ` - ${problem}`),
|
|
55
|
+
`Fix: copy the canonical models from "${REPORTS_PRISMA_SNIPPET_PATH}" (in node_modules) into prisma/schema.prisma and run "npx prisma migrate dev".`,
|
|
56
|
+
];
|
|
57
|
+
if (indexProblems.length > 0) {
|
|
58
|
+
lines.push(
|
|
59
|
+
'The saved-view name uniques are partial indexes Prisma cannot represent — re-apply them as raw SQL inside a migration:',
|
|
60
|
+
REPORTS_PARTIAL_UNIQUE_SQL,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
lines.push('To bypass temporarily (NOT recommended), set REPORTS_SCHEMA_CHECK=off.');
|
|
64
|
+
|
|
65
|
+
throw new Error(lines.join('\n'));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private async missingTablesOrColumns(): Promise<string[]> {
|
|
69
|
+
const tables = EXPECTED_REPORTS_SCHEMA.map((expected) => expected.table);
|
|
70
|
+
const rows = await this.prisma.$queryRaw<Array<{ table_name: string; column_name: string }>>`
|
|
71
|
+
SELECT table_name, column_name
|
|
72
|
+
FROM information_schema.columns
|
|
73
|
+
WHERE table_schema = current_schema()
|
|
74
|
+
AND table_name IN (${Prisma.join(tables)})
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
const present = new Map<string, Set<string>>();
|
|
78
|
+
for (const row of rows) {
|
|
79
|
+
const columns = present.get(row.table_name) ?? new Set<string>();
|
|
80
|
+
columns.add(row.column_name);
|
|
81
|
+
present.set(row.table_name, columns);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const problems: string[] = [];
|
|
85
|
+
for (const expected of EXPECTED_REPORTS_SCHEMA) {
|
|
86
|
+
const columns = present.get(expected.table);
|
|
87
|
+
if (!columns) {
|
|
88
|
+
problems.push(`missing table "${expected.table}"`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const missingColumns = expected.columns.filter((column) => !columns.has(column));
|
|
92
|
+
if (missingColumns.length > 0) {
|
|
93
|
+
problems.push(
|
|
94
|
+
`table "${expected.table}" is missing column(s): ${missingColumns.join(', ')}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return problems;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async missingPartialUniques(): Promise<string[]> {
|
|
103
|
+
const names = REQUIRED_REPORTS_INDEXES.map((index) => index.name);
|
|
104
|
+
const rows = await this.prisma.$queryRaw<Array<{ indexname: string }>>`
|
|
105
|
+
SELECT indexname
|
|
106
|
+
FROM pg_indexes
|
|
107
|
+
WHERE schemaname = current_schema()
|
|
108
|
+
AND indexname IN (${Prisma.join(names)})
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
const present = new Set(rows.map((row) => row.indexname));
|
|
112
|
+
return REQUIRED_REPORTS_INDEXES.filter((index) => !present.has(index.name)).map(
|
|
113
|
+
(index) => `missing index "${index.name}" on "${index.table}" — ${index.description}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
package/template/src/modules/reports/infrastructure/storage/local-disk-export-storage.adapter.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { createReadStream } from 'node:fs';
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import type { Readable } from 'node:stream';
|
|
6
|
+
import { Injectable } from '@nestjs/common';
|
|
7
|
+
import { ConfigService } from '@nestjs/config';
|
|
8
|
+
import type { ExportFileSink } from '@ftisindia/report-builder';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reports-OWNED export file storage (report design §9). The reports module
|
|
12
|
+
* keeps its own org-scoped export directory so it does not depend on the form
|
|
13
|
+
* builder's UploadedFile machinery — the engine core's standalone guarantee
|
|
14
|
+
* (§3.2) extends to the glue: ReportsModule needs no FormsModule.
|
|
15
|
+
*
|
|
16
|
+
* Files land under `<exportStorageDir>/<orgId>/<uuid>.<ext>`; the returned
|
|
17
|
+
* `fileId` is that storage key, stored on the ReportExportJob row and used by
|
|
18
|
+
* the download route. The key is server-generated (no user input), and reads
|
|
19
|
+
* are guarded against escaping the configured directory.
|
|
20
|
+
*
|
|
21
|
+
* Swap this for an S3/GCS-backed ExportFileSink without touching the engine or
|
|
22
|
+
* the rest of the glue.
|
|
23
|
+
*/
|
|
24
|
+
@Injectable()
|
|
25
|
+
export class LocalDiskReportExportStorage implements ExportFileSink {
|
|
26
|
+
private readonly baseDir: string;
|
|
27
|
+
|
|
28
|
+
constructor(config: ConfigService) {
|
|
29
|
+
this.baseDir = resolve(config.get<string>('reports.exportStorageDir') ?? './var/report-exports');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async putStream(
|
|
33
|
+
opts: { orgId: string; ownerId: string; fileName: string; mimeType: string },
|
|
34
|
+
chunks: AsyncIterable<Uint8Array>,
|
|
35
|
+
): Promise<{ fileId: string; size: number }> {
|
|
36
|
+
// Collected into one buffer before writing. Bounded by the engine's export
|
|
37
|
+
// caps (row limits + the snapshot duration bound, §9) long before memory
|
|
38
|
+
// becomes a concern.
|
|
39
|
+
const collected: Buffer[] = [];
|
|
40
|
+
for await (const chunk of chunks) {
|
|
41
|
+
collected.push(Buffer.from(chunk));
|
|
42
|
+
}
|
|
43
|
+
const bytes = Buffer.concat(collected);
|
|
44
|
+
|
|
45
|
+
const safeOrg = sanitizeSegment(opts.orgId);
|
|
46
|
+
const storageKey = `${safeOrg}/${randomUUID()}${extensionOf(opts.fileName)}`;
|
|
47
|
+
const absolute = this.resolveKey(storageKey);
|
|
48
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
49
|
+
await writeFile(absolute, bytes);
|
|
50
|
+
|
|
51
|
+
return { fileId: storageKey, size: bytes.byteLength };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A readable stream for the download route. Guarded against path traversal. */
|
|
55
|
+
read(storageKey: string): Readable {
|
|
56
|
+
return createReadStream(this.resolveKey(storageKey));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Resolve a storage key inside the base dir, rejecting any escape. */
|
|
60
|
+
private resolveKey(storageKey: string): string {
|
|
61
|
+
const absolute = resolve(join(this.baseDir, storageKey));
|
|
62
|
+
if (absolute !== this.baseDir && !absolute.startsWith(this.baseDir + sep)) {
|
|
63
|
+
throw new Error('Export storage key resolves outside the configured directory.');
|
|
64
|
+
}
|
|
65
|
+
return absolute;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Keep path segments to a safe alphabet (uuids / org ids are already safe). */
|
|
70
|
+
function sanitizeSegment(segment: string): string {
|
|
71
|
+
return segment.replace(/[^A-Za-z0-9_-]/g, '_');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Server-controlled extension: a short alphanumeric suffix, or none. */
|
|
75
|
+
function extensionOf(fileName: string): string {
|
|
76
|
+
const dot = fileName.lastIndexOf('.');
|
|
77
|
+
const extension = dot >= 0 ? fileName.slice(dot) : '';
|
|
78
|
+
return /^\.[A-Za-z0-9]+$/.test(extension) ? extension.toLowerCase() : '';
|
|
79
|
+
}
|
|
@@ -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
|
+
}
|