@ftisindia/create-app 0.1.5 → 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 +25 -0
- package/template/README.md +51 -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/env.validation.ts +25 -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 +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.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/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 +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/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 +22 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import type { CatalogColumn, CatalogIndex, CatalogPort } from '@ftisindia/report-builder';
|
|
3
|
+
import { PrismaService } from '../../../database/prisma/prisma.service';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Catalog introspection for the publish-time lints (report design §5.2/§8):
|
|
7
|
+
* declared indexes are verified against pg_indexes, generated columns against
|
|
8
|
+
* information_schema, tier caps against the planner's pg_class estimates.
|
|
9
|
+
* Every lookup is scoped to current_schema() so other schemas (extensions,
|
|
10
|
+
* shadow databases) cannot shadow or leak into lint verdicts.
|
|
11
|
+
*/
|
|
12
|
+
@Injectable()
|
|
13
|
+
export class PrismaReportsCatalog implements CatalogPort {
|
|
14
|
+
constructor(private readonly prisma: PrismaService) {}
|
|
15
|
+
|
|
16
|
+
async indexesFor(table: string): Promise<CatalogIndex[]> {
|
|
17
|
+
const rows = await this.prisma.$queryRawUnsafe<
|
|
18
|
+
Array<{ indexname: string; tablename: string; indexdef: string }>
|
|
19
|
+
>(
|
|
20
|
+
`SELECT indexname, tablename, indexdef
|
|
21
|
+
FROM pg_indexes
|
|
22
|
+
WHERE schemaname = current_schema()
|
|
23
|
+
AND tablename = $1`,
|
|
24
|
+
table,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
return rows.map((row) => ({
|
|
28
|
+
name: row.indexname,
|
|
29
|
+
table: row.tablename,
|
|
30
|
+
indexDef: row.indexdef,
|
|
31
|
+
isUnique: row.indexdef.includes('UNIQUE'),
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async columnsFor(table: string): Promise<CatalogColumn[]> {
|
|
36
|
+
const rows = await this.prisma.$queryRawUnsafe<
|
|
37
|
+
Array<{ column_name: string; data_type: string; is_generated: string }>
|
|
38
|
+
>(
|
|
39
|
+
`SELECT column_name, data_type, is_generated
|
|
40
|
+
FROM information_schema.columns
|
|
41
|
+
WHERE table_schema = current_schema()
|
|
42
|
+
AND table_name = $1`,
|
|
43
|
+
table,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return rows.map((row) => ({
|
|
47
|
+
name: row.column_name,
|
|
48
|
+
dataType: row.data_type,
|
|
49
|
+
isGenerated: row.is_generated === 'ALWAYS',
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async hasExtension(name: string): Promise<boolean> {
|
|
54
|
+
const rows = await this.prisma.$queryRawUnsafe<Array<{ found: number }>>(
|
|
55
|
+
`SELECT 1 AS found FROM pg_extension WHERE extname = $1`,
|
|
56
|
+
name,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return rows.length > 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async estimatedRowCount(table: string): Promise<number> {
|
|
63
|
+
const rows = await this.prisma.$queryRawUnsafe<Array<{ reltuples: unknown }>>(
|
|
64
|
+
`SELECT c.reltuples
|
|
65
|
+
FROM pg_class c
|
|
66
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
67
|
+
WHERE n.nspname = current_schema()
|
|
68
|
+
AND c.relname = $1
|
|
69
|
+
AND c.relkind IN ('r', 'p')`,
|
|
70
|
+
table,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (rows.length === 0) {
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
const estimate = Number(rows[0].reltuples);
|
|
77
|
+
return Number.isFinite(estimate) ? Math.max(0, estimate) : 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async relationExists(name: string): Promise<boolean> {
|
|
81
|
+
// Tables, views, materialized views, and partitioned tables all count —
|
|
82
|
+
// the materialized tier reads from any of them (report design §8).
|
|
83
|
+
const rows = await this.prisma.$queryRawUnsafe<Array<{ found: number }>>(
|
|
84
|
+
`SELECT 1 AS found
|
|
85
|
+
FROM pg_class c
|
|
86
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
87
|
+
WHERE n.nspname = current_schema()
|
|
88
|
+
AND c.relname = $1
|
|
89
|
+
AND c.relkind IN ('r', 'v', 'm', 'p')`,
|
|
90
|
+
name,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return rows.length > 0;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { Prisma } from '@prisma/client';
|
|
3
|
+
import { ReportQueryBudgetError, type QueryExecutor, type SqlRow } from '@ftisindia/report-builder';
|
|
4
|
+
import { PrismaService } from '../../../database/prisma/prisma.service';
|
|
5
|
+
|
|
6
|
+
/** Hard ceiling on the per-statement budget — matches Prisma's own tx limits. */
|
|
7
|
+
const MAX_STATEMENT_TIMEOUT_MS = 600_000;
|
|
8
|
+
|
|
9
|
+
function assertValidStatementTimeout(ms: number): void {
|
|
10
|
+
if (!Number.isInteger(ms) || ms < 1 || ms > MAX_STATEMENT_TIMEOUT_MS) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
`Invalid report statement timeout: ${ms} (expected an integer between 1 and ${MAX_STATEMENT_TIMEOUT_MS} ms).`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Postgres 57014 — 'canceling statement due to statement timeout'. */
|
|
18
|
+
function isStatementTimeout(error: unknown): boolean {
|
|
19
|
+
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
20
|
+
const meta = error.meta as { code?: unknown } | undefined;
|
|
21
|
+
if (meta?.code === '57014') {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const message = error instanceof Error ? error.message : '';
|
|
26
|
+
return message.includes('57014') || message.includes('canceling statement due to statement timeout');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Runs one compiler-built statement under a SET LOCAL statement_timeout on an
|
|
31
|
+
* open transaction client. SQL identifiers come only from code-owned manifests
|
|
32
|
+
* and every VALUE travels as a positional $1..$n bind; SET LOCAL cannot take
|
|
33
|
+
* binds, so the timeout — a VALIDATED INTEGER — is the one sanctioned
|
|
34
|
+
* interpolation exception (report design §2.1/§5.2).
|
|
35
|
+
*
|
|
36
|
+
* Shared between PrismaQueryExecutor (one-off transactions) and
|
|
37
|
+
* PrismaSnapshotRunner (the export's REPEATABLE READ transaction).
|
|
38
|
+
*/
|
|
39
|
+
export async function runBoundedQuery(
|
|
40
|
+
client: Prisma.TransactionClient,
|
|
41
|
+
sql: string,
|
|
42
|
+
params: readonly unknown[],
|
|
43
|
+
opts: { statementTimeoutMs: number },
|
|
44
|
+
): Promise<SqlRow[]> {
|
|
45
|
+
assertValidStatementTimeout(opts.statementTimeoutMs);
|
|
46
|
+
try {
|
|
47
|
+
await client.$executeRawUnsafe(`SET LOCAL statement_timeout = ${opts.statementTimeoutMs}`);
|
|
48
|
+
return await client.$queryRawUnsafe<SqlRow[]>(sql, ...params);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (isStatementTimeout(error)) {
|
|
51
|
+
// The runtime backstop (§5.2): a pathological plan fails fast, typed.
|
|
52
|
+
throw new ReportQueryBudgetError();
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* EXPLAIN (FORMAT JSON) for the plan lint and estimated counts (§5.2/§5.4).
|
|
60
|
+
* Postgres returns one row shaped [{ "QUERY PLAN": <document> }]; drivers may
|
|
61
|
+
* surface the json column already parsed or as a string — the engine expects
|
|
62
|
+
* the parsed array-root document either way.
|
|
63
|
+
*/
|
|
64
|
+
export async function runExplain(
|
|
65
|
+
client: Prisma.TransactionClient,
|
|
66
|
+
sql: string,
|
|
67
|
+
params: readonly unknown[],
|
|
68
|
+
): Promise<unknown> {
|
|
69
|
+
const rows = await client.$queryRawUnsafe<Array<Record<string, unknown>>>(
|
|
70
|
+
`EXPLAIN (FORMAT JSON) ${sql}`,
|
|
71
|
+
...params,
|
|
72
|
+
);
|
|
73
|
+
const plan = rows[0]?.['QUERY PLAN'];
|
|
74
|
+
return typeof plan === 'string' ? (JSON.parse(plan) as unknown) : plan;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The engine's SQL seam (report design §5) over Prisma. Each call runs inside
|
|
79
|
+
* its own short transaction because SET LOCAL is transaction-scoped — outside
|
|
80
|
+
* one it would be a silent no-op and the statement budget would not exist.
|
|
81
|
+
*/
|
|
82
|
+
@Injectable()
|
|
83
|
+
export class PrismaQueryExecutor implements QueryExecutor {
|
|
84
|
+
constructor(private readonly prisma: PrismaService) {}
|
|
85
|
+
|
|
86
|
+
query(
|
|
87
|
+
sql: string,
|
|
88
|
+
params: readonly unknown[],
|
|
89
|
+
opts: { statementTimeoutMs: number },
|
|
90
|
+
): Promise<SqlRow[]> {
|
|
91
|
+
assertValidStatementTimeout(opts.statementTimeoutMs);
|
|
92
|
+
return this.prisma.$transaction((tx) => runBoundedQuery(tx, sql, params, opts), {
|
|
93
|
+
// Keep Prisma's transaction window above the statement budget so the
|
|
94
|
+
// budget — not Prisma's 5s interactive-tx default — is what fires.
|
|
95
|
+
timeout: opts.statementTimeoutMs + 1000,
|
|
96
|
+
maxWait: 5000,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
explain(sql: string, params: readonly unknown[]): Promise<unknown> {
|
|
101
|
+
return this.prisma.$transaction((tx) => runExplain(tx, sql, params));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { Prisma } from '@prisma/client';
|
|
3
|
+
import type { QueryExecutor, SnapshotRunner } from '@ftisindia/report-builder';
|
|
4
|
+
import { PrismaService } from '../../../database/prisma/prisma.service';
|
|
5
|
+
import { runBoundedQuery, runExplain } from './prisma-query-executor';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One REPEATABLE READ transaction per export (report design §9, finding #8):
|
|
9
|
+
* every keyset batch reads the same MVCC snapshot, so rows moving under the
|
|
10
|
+
* export can neither be skipped nor duplicated. The transaction is
|
|
11
|
+
* duration-bounded — an export that cannot finish inside the bound aborts
|
|
12
|
+
* (and the engine maps that to guidance: tighten filters or move the report
|
|
13
|
+
* to the materialized tier).
|
|
14
|
+
*/
|
|
15
|
+
@Injectable()
|
|
16
|
+
export class PrismaSnapshotRunner implements SnapshotRunner {
|
|
17
|
+
constructor(private readonly prisma: PrismaService) {}
|
|
18
|
+
|
|
19
|
+
withSnapshot<T>(
|
|
20
|
+
opts: { maxDurationMs: number },
|
|
21
|
+
fn: (exec: QueryExecutor, asOf: Date) => Promise<T>,
|
|
22
|
+
): Promise<T> {
|
|
23
|
+
return this.prisma.$transaction(
|
|
24
|
+
async (tx) => {
|
|
25
|
+
// Under REPEATABLE READ now() is the transaction (= snapshot) start
|
|
26
|
+
// time — the export's `asOf`, stamped into the job and file metadata.
|
|
27
|
+
const rows = await tx.$queryRawUnsafe<Array<{ now: unknown }>>('SELECT now() AS now');
|
|
28
|
+
const now = rows[0]?.now;
|
|
29
|
+
const asOf = now instanceof Date ? now : new Date();
|
|
30
|
+
|
|
31
|
+
// Executor bound to THIS transaction: same SET LOCAL budget + EXPLAIN
|
|
32
|
+
// logic as PrismaQueryExecutor, valid only while `fn` runs.
|
|
33
|
+
const exec: QueryExecutor = {
|
|
34
|
+
query: (sql, params, queryOpts) => runBoundedQuery(tx, sql, params, queryOpts),
|
|
35
|
+
explain: (sql, params) => runExplain(tx, sql, params),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return fn(exec, asOf);
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead,
|
|
42
|
+
timeout: opts.maxDurationMs,
|
|
43
|
+
maxWait: 5000,
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import type { EngineTx, TxRunner } from '@ftisindia/report-builder';
|
|
3
|
+
import { PrismaService } from '../../../database/prisma/prisma.service';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The engine's transaction seam over PrismaService.$transaction. The opaque
|
|
7
|
+
* EngineTx brand is a Prisma.TransactionClient underneath; the stores cast it
|
|
8
|
+
* back. One TxRunner.run call is the engine's atomicity boundary (export job
|
|
9
|
+
* row + outbox enqueue, bulk-action ledger + audit — report design §6.3/§9).
|
|
10
|
+
*/
|
|
11
|
+
@Injectable()
|
|
12
|
+
export class ReportsPrismaTxRunner implements TxRunner {
|
|
13
|
+
constructor(private readonly prisma: PrismaService) {}
|
|
14
|
+
|
|
15
|
+
run<T>(fn: (tx: EngineTx) => Promise<T>): Promise<T> {
|
|
16
|
+
return this.prisma.$transaction((tx) => fn(tx as unknown as EngineTx));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Injectable, OnModuleInit } from '@nestjs/common';
|
|
2
|
+
import { DiscoveryService, Reflector } from '@nestjs/core';
|
|
3
|
+
import {
|
|
4
|
+
ReportRowActionRegistry,
|
|
5
|
+
ReportSourceRegistry,
|
|
6
|
+
ReportTagService,
|
|
7
|
+
SourceProviderRegistry,
|
|
8
|
+
createCustomSourceProvider,
|
|
9
|
+
createManageTagsAction,
|
|
10
|
+
} from '@ftisindia/report-builder';
|
|
11
|
+
import type { ReportRowActionDef, ReportSourceDef } from '@ftisindia/report-builder';
|
|
12
|
+
import {
|
|
13
|
+
REPORT_ROW_ACTION_METADATA,
|
|
14
|
+
REPORT_SOURCE_METADATA,
|
|
15
|
+
} from './report-extension.decorators';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Populates the engine registries at startup — WITHOUT any form-builder
|
|
19
|
+
* dependency (the standalone guarantee extends to the glue, report design
|
|
20
|
+
* §3.2): the built-in manageTags action (§7), the 'custom' source provider
|
|
21
|
+
* over the code-owned source registry (the one provider the core ships), and
|
|
22
|
+
* a discovery scan for every app provider decorated with @ReportSource /
|
|
23
|
+
* @ReportRowAction (e.g. the org-members example source).
|
|
24
|
+
*
|
|
25
|
+
* Form-backed sources and the delegated editSubmission/updateStatus verbs are
|
|
26
|
+
* added by the OPTIONAL ReportsFormsModule, which registers the 'form'
|
|
27
|
+
* provider + actions into these same registries only when the app ships forms.
|
|
28
|
+
*/
|
|
29
|
+
@Injectable()
|
|
30
|
+
export class ReportsRegistryBootstrapService implements OnModuleInit {
|
|
31
|
+
constructor(
|
|
32
|
+
private readonly discovery: DiscoveryService,
|
|
33
|
+
private readonly reflector: Reflector,
|
|
34
|
+
private readonly sourceRegistry: ReportSourceRegistry,
|
|
35
|
+
private readonly sourceProviders: SourceProviderRegistry,
|
|
36
|
+
private readonly actionRegistry: ReportRowActionRegistry,
|
|
37
|
+
private readonly tagService: ReportTagService,
|
|
38
|
+
) {}
|
|
39
|
+
|
|
40
|
+
onModuleInit(): void {
|
|
41
|
+
this.actionRegistry.register(createManageTagsAction(this.tagService));
|
|
42
|
+
this.sourceProviders.register(createCustomSourceProvider(this.sourceRegistry));
|
|
43
|
+
|
|
44
|
+
for (const wrapper of this.discovery.getProviders()) {
|
|
45
|
+
const instance: unknown = wrapper.instance;
|
|
46
|
+
if (!instance || typeof instance !== 'object') {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const ctor = instance.constructor as object | undefined;
|
|
50
|
+
if (!ctor || ctor === Object) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (this.reflector.get<boolean>(REPORT_SOURCE_METADATA, ctor as never)) {
|
|
54
|
+
this.sourceRegistry.register(instance as ReportSourceDef);
|
|
55
|
+
}
|
|
56
|
+
if (this.reflector.get<boolean>(REPORT_ROW_ACTION_METADATA, ctor as never)) {
|
|
57
|
+
this.actionRegistry.register(instance as ReportRowActionDef);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { SetMetadata } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
export const REPORT_SOURCE_METADATA = 'ftis:reports:source';
|
|
4
|
+
export const REPORT_ROW_ACTION_METADATA = 'ftis:reports:row-action';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The standardized extension story (report design §3.2/§6): implement the
|
|
8
|
+
* engine interface (ReportSourceDef / ReportRowActionDef), decorate the
|
|
9
|
+
* @Injectable class, list it as a provider — ReportsRegistryBootstrapService
|
|
10
|
+
* discovers and registers it at startup. Same three steps as the form
|
|
11
|
+
* registries.
|
|
12
|
+
*/
|
|
13
|
+
export const ReportSource = () => SetMetadata(REPORT_SOURCE_METADATA, true);
|
|
14
|
+
export const ReportRowAction = () => SetMetadata(REPORT_ROW_ACTION_METADATA, true);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import type { EngineTx, ReportsJobQueue } from '@ftisindia/report-builder';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The async-export job queue (report design §9). The durable signal IS the
|
|
6
|
+
* `ReportExportJob` row the engine creates in the same transaction — the
|
|
7
|
+
* reports-owned ReportsExportDispatcherService polls that table for PENDING
|
|
8
|
+
* jobs. So `enqueue` is intentionally a no-op: there is no separate queue to
|
|
9
|
+
* write, and reports stays independent of the forms outbox.
|
|
10
|
+
*
|
|
11
|
+
* (An app that prefers an external queue can bind a different ReportsJobQueue
|
|
12
|
+
* — e.g. one that publishes to SQS — without changing the engine.)
|
|
13
|
+
*/
|
|
14
|
+
@Injectable()
|
|
15
|
+
export class ReportsJobQueueNoop implements ReportsJobQueue {
|
|
16
|
+
async enqueue(
|
|
17
|
+
_job: {
|
|
18
|
+
type: string;
|
|
19
|
+
payload: Record<string, unknown>;
|
|
20
|
+
orgId: string;
|
|
21
|
+
actorUserId?: string | null;
|
|
22
|
+
idempotencyKey?: string;
|
|
23
|
+
},
|
|
24
|
+
_tx: EngineTx,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
// The ReportExportJob row (committed with this tx) is the queue entry.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import type { ReportsContext } from '@ftisindia/report-builder';
|
|
3
|
+
import { RequestContextService } from '../../request-context/application/services/request-context.service';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Binds the engine's ReportsContext seam to the app's AsyncLocalStorage
|
|
7
|
+
* request context (report design §13). Worker contexts (async export jobs)
|
|
8
|
+
* are restored by the forms outbox dispatcher before the engine runs, so the
|
|
9
|
+
* same adapter serves HTTP and worker callers (ecosystem guide §3).
|
|
10
|
+
*/
|
|
11
|
+
@Injectable()
|
|
12
|
+
export class RequestReportsContext implements ReportsContext {
|
|
13
|
+
constructor(private readonly ctx: RequestContextService) {}
|
|
14
|
+
|
|
15
|
+
requestId() {
|
|
16
|
+
return this.ctx.getRequestId();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
source(): 'http' | 'worker' {
|
|
20
|
+
return this.ctx.get()?.source ?? 'worker';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
userId() {
|
|
24
|
+
return this.ctx.getUserId();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
orgId() {
|
|
28
|
+
return this.ctx.getOrgId();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
permissionKeys() {
|
|
32
|
+
return this.ctx.getPermissions();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
isOwner() {
|
|
36
|
+
return this.ctx.getRbacContext()?.isOwner ?? false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
assertOrgScope(orgId: string) {
|
|
40
|
+
this.ctx.assertOrgScope(orgId);
|
|
41
|
+
}
|
|
42
|
+
}
|
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
|
+
}
|