@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
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { Prisma } from '@prisma/client';
|
|
3
|
+
import type { AuditEntry, EngineTx, FormsAuditSink } from '@ftisindia/form-builder';
|
|
4
|
+
import { PrismaService } from '../../../database/prisma/prisma.service';
|
|
5
|
+
import { AuditService } from '../../audit/application/services/audit.service';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Routes engine audit entries through the template's AuditService. When the
|
|
9
|
+
* engine supplies a transaction, the audit row is written through it so it
|
|
10
|
+
* commits atomically with the change it describes (ecosystem guide §7).
|
|
11
|
+
*/
|
|
12
|
+
@Injectable()
|
|
13
|
+
export class PrismaFormsAuditSink implements FormsAuditSink {
|
|
14
|
+
constructor(
|
|
15
|
+
private readonly audit: AuditService,
|
|
16
|
+
private readonly prisma: PrismaService,
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
async write(entry: AuditEntry, tx?: EngineTx): Promise<void> {
|
|
20
|
+
const client = tx ? (tx as unknown as Prisma.TransactionClient) : this.prisma;
|
|
21
|
+
await this.audit.write(client, {
|
|
22
|
+
orgId: entry.orgId ?? null,
|
|
23
|
+
actorUserId: entry.actorUserId ?? null,
|
|
24
|
+
action: entry.action,
|
|
25
|
+
targetType: entry.targetType,
|
|
26
|
+
targetId: entry.targetId ?? null,
|
|
27
|
+
metadata: entry.metadata as Prisma.InputJsonValue | undefined,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import type { FormPermissionKey, FormsAuthorization } from '@ftisindia/form-builder';
|
|
3
|
+
import { AbilityFactory } from '../../access-control/application/services/ability.factory';
|
|
4
|
+
import { permissionKeyToRule } from '../../access-control/types/permission-key';
|
|
5
|
+
import { RequestContextService } from '../../request-context/application/services/request-context.service';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The engine's internal authorization seam over CASL — mirrors
|
|
9
|
+
* PermissionGuard's own logic and fails closed when no RBAC context exists
|
|
10
|
+
* (anonymous/public requests). Route guards remain the first enforcement
|
|
11
|
+
* layer; this is the defense-in-depth second layer (ecosystem guide §5.3).
|
|
12
|
+
*/
|
|
13
|
+
@Injectable()
|
|
14
|
+
export class CaslFormsAuthorization implements FormsAuthorization {
|
|
15
|
+
constructor(
|
|
16
|
+
private readonly abilities: AbilityFactory,
|
|
17
|
+
private readonly ctx: RequestContextService,
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
can(required: FormPermissionKey[]): boolean {
|
|
21
|
+
const rbac = this.ctx.getRbacContext();
|
|
22
|
+
if (!rbac) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const ability = this.abilities.createForContext(rbac);
|
|
26
|
+
return required.every((key) => {
|
|
27
|
+
const { action, subject } = permissionKeyToRule(key);
|
|
28
|
+
return ability.can(action, subject);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import type { EngineTx, TxRunner } from '@ftisindia/form-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 design doc's Phase-2 atomicity boundary.
|
|
9
|
+
*/
|
|
10
|
+
@Injectable()
|
|
11
|
+
export class PrismaTxRunner implements TxRunner {
|
|
12
|
+
constructor(private readonly prisma: PrismaService) {}
|
|
13
|
+
|
|
14
|
+
run<T>(fn: (tx: EngineTx) => Promise<T>): Promise<T> {
|
|
15
|
+
return this.prisma.$transaction((tx) => fn(tx as unknown as EngineTx));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { SetMetadata } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
export const FORM_FIELD_TYPE_METADATA = 'ftis:forms:field-type';
|
|
4
|
+
export const FORM_ACTION_METADATA = 'ftis:forms:action';
|
|
5
|
+
export const FORM_DATA_SOURCE_METADATA = 'ftis:forms:data-source';
|
|
6
|
+
export const FORM_LIFECYCLE_METADATA = 'ftis:forms:lifecycle';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The standardized extension story: implement the engine interface
|
|
10
|
+
* (FieldTypeDef / FormAction / DataSourceDef / FormLifecycle), decorate the
|
|
11
|
+
* @Injectable class, list it as a provider — RegistryBootstrapService
|
|
12
|
+
* discovers and registers it at startup. Same three steps for every kind.
|
|
13
|
+
*/
|
|
14
|
+
export const FormFieldType = () => SetMetadata(FORM_FIELD_TYPE_METADATA, true);
|
|
15
|
+
export const FormActionHandler = () => SetMetadata(FORM_ACTION_METADATA, true);
|
|
16
|
+
export const FormDataSource = () => SetMetadata(FORM_DATA_SOURCE_METADATA, true);
|
|
17
|
+
export const FormLifecycleHook = () => SetMetadata(FORM_LIFECYCLE_METADATA, true);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Injectable, OnModuleInit } from '@nestjs/common';
|
|
2
|
+
import { DiscoveryService, Reflector } from '@nestjs/core';
|
|
3
|
+
import {
|
|
4
|
+
ActionRegistry,
|
|
5
|
+
DataSourceRegistry,
|
|
6
|
+
FieldTypeRegistry,
|
|
7
|
+
LifecycleRegistry,
|
|
8
|
+
OutboxHandlerRegistry,
|
|
9
|
+
ValidationEngine,
|
|
10
|
+
registerBuiltinActions,
|
|
11
|
+
} from '@ftisindia/form-builder';
|
|
12
|
+
import type {
|
|
13
|
+
DataSourceDef,
|
|
14
|
+
FieldTypeDef,
|
|
15
|
+
FormAction,
|
|
16
|
+
FormLifecycle,
|
|
17
|
+
} from '@ftisindia/form-builder';
|
|
18
|
+
import { PrismaSubmissionStore } from '../stores/prisma-submission.store';
|
|
19
|
+
import { LoggingEmailHandler } from '../../application/services/handlers/logging-email.handler';
|
|
20
|
+
import { WebhookOutboxHandler } from '../../application/services/handlers/webhook.handler';
|
|
21
|
+
import {
|
|
22
|
+
FORM_ACTION_METADATA,
|
|
23
|
+
FORM_DATA_SOURCE_METADATA,
|
|
24
|
+
FORM_FIELD_TYPE_METADATA,
|
|
25
|
+
FORM_LIFECYCLE_METADATA,
|
|
26
|
+
} from './form-extension.decorators';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Populates the engine registries at startup: builtin actions first
|
|
30
|
+
* (validateAll/persist/persistDraft/lockEditing), the default outbox
|
|
31
|
+
* handlers, then every provider decorated with @FormFieldType /
|
|
32
|
+
* @FormActionHandler / @FormDataSource / @FormLifecycleHook — the app
|
|
33
|
+
* declares its extensions as ordinary providers; the engine finds them.
|
|
34
|
+
*/
|
|
35
|
+
@Injectable()
|
|
36
|
+
export class RegistryBootstrapService implements OnModuleInit {
|
|
37
|
+
constructor(
|
|
38
|
+
private readonly discovery: DiscoveryService,
|
|
39
|
+
private readonly reflector: Reflector,
|
|
40
|
+
private readonly fieldTypes: FieldTypeRegistry,
|
|
41
|
+
private readonly actions: ActionRegistry,
|
|
42
|
+
private readonly dataSources: DataSourceRegistry,
|
|
43
|
+
private readonly lifecycles: LifecycleRegistry,
|
|
44
|
+
private readonly outboxHandlers: OutboxHandlerRegistry,
|
|
45
|
+
private readonly validation: ValidationEngine,
|
|
46
|
+
private readonly submissions: PrismaSubmissionStore,
|
|
47
|
+
private readonly emailHandler: LoggingEmailHandler,
|
|
48
|
+
private readonly webhookHandler: WebhookOutboxHandler,
|
|
49
|
+
) {}
|
|
50
|
+
|
|
51
|
+
onModuleInit(): void {
|
|
52
|
+
registerBuiltinActions(this.actions, {
|
|
53
|
+
validate: (def, data, mode) => this.validation.validate(def, data, mode),
|
|
54
|
+
submissions: this.submissions,
|
|
55
|
+
});
|
|
56
|
+
this.outboxHandlers.register(this.emailHandler);
|
|
57
|
+
this.outboxHandlers.register(this.webhookHandler);
|
|
58
|
+
|
|
59
|
+
for (const wrapper of this.discovery.getProviders()) {
|
|
60
|
+
const instance: unknown = wrapper.instance;
|
|
61
|
+
if (!instance || typeof instance !== 'object') {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const ctor = instance.constructor as object | undefined;
|
|
65
|
+
if (!ctor || ctor === Object) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (this.reflector.get<boolean>(FORM_FIELD_TYPE_METADATA, ctor as never)) {
|
|
69
|
+
this.fieldTypes.register(instance as FieldTypeDef);
|
|
70
|
+
}
|
|
71
|
+
if (this.reflector.get<boolean>(FORM_ACTION_METADATA, ctor as never)) {
|
|
72
|
+
this.actions.register(instance as FormAction);
|
|
73
|
+
}
|
|
74
|
+
if (this.reflector.get<boolean>(FORM_DATA_SOURCE_METADATA, ctor as never)) {
|
|
75
|
+
this.dataSources.register(instance as DataSourceDef);
|
|
76
|
+
}
|
|
77
|
+
if (this.reflector.get<boolean>(FORM_LIFECYCLE_METADATA, ctor as never)) {
|
|
78
|
+
this.lifecycles.register(instance as FormLifecycle);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import type { ContextPath, FormsContext } from '@ftisindia/form-builder';
|
|
3
|
+
import { RequestContextService } from '../../request-context/application/services/request-context.service';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Binds the engine's FormsContext seam to the app's AsyncLocalStorage request
|
|
7
|
+
* context. The closed ContextPath union IS the design doc's anti-tenant-leak
|
|
8
|
+
* rule for hidden fields, enforced by the type system (ecosystem guide §3).
|
|
9
|
+
*/
|
|
10
|
+
@Injectable()
|
|
11
|
+
export class RequestFormsContext implements FormsContext {
|
|
12
|
+
constructor(private readonly ctx: RequestContextService) {}
|
|
13
|
+
|
|
14
|
+
requestId() {
|
|
15
|
+
return this.ctx.getRequestId();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
source(): 'http' | 'worker' {
|
|
19
|
+
return this.ctx.get()?.source ?? 'worker';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
userId() {
|
|
23
|
+
return this.ctx.getUserId();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
orgId() {
|
|
27
|
+
return this.ctx.getOrgId();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
permissionKeys() {
|
|
31
|
+
return this.ctx.getPermissions();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
isOwner() {
|
|
35
|
+
return this.ctx.getRbacContext()?.isOwner ?? false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
assertOrgScope(orgId: string) {
|
|
39
|
+
this.ctx.assertOrgScope(orgId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
resolve(path: ContextPath) {
|
|
43
|
+
switch (path) {
|
|
44
|
+
case 'user.id':
|
|
45
|
+
return this.ctx.getUserId();
|
|
46
|
+
case 'org.id':
|
|
47
|
+
return this.ctx.getOrgId();
|
|
48
|
+
case 'request.id':
|
|
49
|
+
return this.ctx.getRequestId();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
ipAddress() {
|
|
54
|
+
return this.ctx.getIpAddress();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
userAgent() {
|
|
58
|
+
return this.ctx.getUserAgent();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
|
2
|
+
import { ConfigService } from '@nestjs/config';
|
|
3
|
+
import { Prisma } from '@prisma/client';
|
|
4
|
+
import {
|
|
5
|
+
ENGINE_SCHEMA_VERSION,
|
|
6
|
+
EXPECTED_FORMS_SCHEMA,
|
|
7
|
+
FORMS_PRISMA_SNIPPET_PATH,
|
|
8
|
+
} from '@ftisindia/form-builder';
|
|
9
|
+
import { PrismaService } from '../../../../database/prisma/prisma.service';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Boot-time schema compatibility check (ecosystem guide §10.1): the app owns
|
|
13
|
+
* schema.prisma and migrations; the engine ships the canonical snippet and a
|
|
14
|
+
* tables/columns manifest. On mismatch the app fails fast at startup with an
|
|
15
|
+
* actionable message instead of crashing mid-request with a cryptic Prisma
|
|
16
|
+
* error. Escape hatch: FORMS_SCHEMA_CHECK=off.
|
|
17
|
+
*/
|
|
18
|
+
@Injectable()
|
|
19
|
+
export class FormsSchemaCheckService implements OnApplicationBootstrap {
|
|
20
|
+
private readonly logger = new Logger('FormsSchemaCheck');
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly prisma: PrismaService,
|
|
24
|
+
private readonly config: ConfigService,
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
async onApplicationBootstrap(): Promise<void> {
|
|
28
|
+
if (this.config.get<string>('forms.schemaCheck') === 'off') {
|
|
29
|
+
this.logger.warn('Forms schema check is disabled (FORMS_SCHEMA_CHECK=off).');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
await this.check();
|
|
33
|
+
this.logger.log(`Forms schema check passed (engine schema version ${ENGINE_SCHEMA_VERSION}).`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async check(): Promise<void> {
|
|
37
|
+
const tables = EXPECTED_FORMS_SCHEMA.map((expected) => expected.table);
|
|
38
|
+
const rows = await this.prisma.$queryRaw<Array<{ table_name: string; column_name: string }>>`
|
|
39
|
+
SELECT table_name, column_name
|
|
40
|
+
FROM information_schema.columns
|
|
41
|
+
WHERE table_schema = current_schema()
|
|
42
|
+
AND table_name IN (${Prisma.join(tables)})
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
const present = new Map<string, Set<string>>();
|
|
46
|
+
for (const row of rows) {
|
|
47
|
+
const columns = present.get(row.table_name) ?? new Set<string>();
|
|
48
|
+
columns.add(row.column_name);
|
|
49
|
+
present.set(row.table_name, columns);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const problems: string[] = [];
|
|
53
|
+
for (const expected of EXPECTED_FORMS_SCHEMA) {
|
|
54
|
+
const columns = present.get(expected.table);
|
|
55
|
+
if (!columns) {
|
|
56
|
+
problems.push(`missing table "${expected.table}"`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const missingColumns = expected.columns.filter((column) => !columns.has(column));
|
|
60
|
+
if (missingColumns.length > 0) {
|
|
61
|
+
problems.push(`table "${expected.table}" is missing column(s): ${missingColumns.join(', ')}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (problems.length > 0) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
[
|
|
68
|
+
`The database does not match @ftisindia/form-builder engine schema version ${ENGINE_SCHEMA_VERSION}:`,
|
|
69
|
+
...problems.map((problem) => ` - ${problem}`),
|
|
70
|
+
`Fix: copy the canonical models from "${FORMS_PRISMA_SNIPPET_PATH}" (in node_modules) into prisma/schema.prisma and run "npx prisma migrate dev".`,
|
|
71
|
+
'To bypass temporarily (NOT recommended), set FORMS_SCHEMA_CHECK=off.',
|
|
72
|
+
].join('\n'),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, resolve, sep } from 'node:path';
|
|
3
|
+
import { Injectable } from '@nestjs/common';
|
|
4
|
+
import { ConfigService } from '@nestjs/config';
|
|
5
|
+
import type { FileStoragePort } from '@ftisindia/form-builder';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Default byte storage under FORMS_FILE_STORAGE_DIR. Storage keys are always
|
|
9
|
+
* server-generated (orgId/uuid — never client-controlled names), and the
|
|
10
|
+
* resolved path is verified to stay inside the base directory as defense in
|
|
11
|
+
* depth against traversal. Swap this provider for an S3/GCS adapter without
|
|
12
|
+
* touching the engine.
|
|
13
|
+
*/
|
|
14
|
+
@Injectable()
|
|
15
|
+
export class LocalDiskStorageAdapter implements FileStoragePort {
|
|
16
|
+
private readonly baseDir: string;
|
|
17
|
+
|
|
18
|
+
constructor(config: ConfigService) {
|
|
19
|
+
this.baseDir = resolve(config.get<string>('forms.storageDir') ?? './var/uploads');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async put(storageKey: string, bytes: Uint8Array): Promise<void> {
|
|
23
|
+
const path = this.fullPath(storageKey);
|
|
24
|
+
await mkdir(dirname(path), { recursive: true });
|
|
25
|
+
await writeFile(path, bytes);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async get(storageKey: string): Promise<Uint8Array> {
|
|
29
|
+
return readFile(this.fullPath(storageKey));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async delete(storageKey: string): Promise<void> {
|
|
33
|
+
await rm(this.fullPath(storageKey), { force: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private fullPath(storageKey: string): string {
|
|
37
|
+
const path = resolve(this.baseDir, storageKey);
|
|
38
|
+
if (path !== this.baseDir && !path.startsWith(this.baseDir + sep)) {
|
|
39
|
+
throw new Error('Storage key resolves outside the configured storage directory.');
|
|
40
|
+
}
|
|
41
|
+
return path;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { Prisma } from '@prisma/client';
|
|
3
|
+
import type { ActionLogEntry, ActionLogStore, EngineTx } from '@ftisindia/form-builder';
|
|
4
|
+
import { PrismaService } from '../../../../database/prisma/prisma.service';
|
|
5
|
+
|
|
6
|
+
@Injectable()
|
|
7
|
+
export class PrismaActionLogStore implements ActionLogStore {
|
|
8
|
+
constructor(private readonly prisma: PrismaService) {}
|
|
9
|
+
|
|
10
|
+
private client(tx?: EngineTx) {
|
|
11
|
+
return tx ? (tx as unknown as Prisma.TransactionClient) : this.prisma;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async write(entry: ActionLogEntry, tx?: EngineTx): Promise<void> {
|
|
15
|
+
await this.client(tx).formActionLog.create({
|
|
16
|
+
data: {
|
|
17
|
+
orgId: entry.orgId,
|
|
18
|
+
submissionId: entry.submissionId ?? null,
|
|
19
|
+
formKey: entry.formKey,
|
|
20
|
+
formVersion: entry.formVersion ?? null,
|
|
21
|
+
action: entry.action,
|
|
22
|
+
status: entry.status,
|
|
23
|
+
...(entry.input !== undefined
|
|
24
|
+
? { input: entry.input as unknown as Prisma.InputJsonValue }
|
|
25
|
+
: {}),
|
|
26
|
+
...(entry.output !== undefined
|
|
27
|
+
? { output: entry.output as unknown as Prisma.InputJsonValue }
|
|
28
|
+
: {}),
|
|
29
|
+
errorCode: entry.errorCode ?? null,
|
|
30
|
+
errorMessage: entry.errorMessage ?? null,
|
|
31
|
+
actorId: entry.actorId ?? null,
|
|
32
|
+
requestId: entry.requestId ?? null,
|
|
33
|
+
durationMs: entry.durationMs ?? null,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { Prisma, UploadedFile as UploadedFileRow } from '@prisma/client';
|
|
3
|
+
import type {
|
|
4
|
+
EngineTx,
|
|
5
|
+
FileStatus,
|
|
6
|
+
FileStore,
|
|
7
|
+
NewUploadedFileRecord,
|
|
8
|
+
UploadedFileRecord,
|
|
9
|
+
} from '@ftisindia/form-builder';
|
|
10
|
+
import { PrismaService } from '../../../../database/prisma/prisma.service';
|
|
11
|
+
|
|
12
|
+
@Injectable()
|
|
13
|
+
export class PrismaFileStore implements FileStore {
|
|
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 create(record: NewUploadedFileRecord, tx?: EngineTx): Promise<UploadedFileRecord> {
|
|
21
|
+
const row = await this.client(tx).uploadedFile.create({
|
|
22
|
+
data: {
|
|
23
|
+
storageKey: record.storageKey,
|
|
24
|
+
originalName: record.originalName,
|
|
25
|
+
mimeType: record.mimeType,
|
|
26
|
+
size: record.size,
|
|
27
|
+
checksum: record.checksum,
|
|
28
|
+
ownerId: record.ownerId,
|
|
29
|
+
orgId: record.orgId,
|
|
30
|
+
status: record.status,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return this.toRecord(row);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async findById(orgId: string, id: string, tx?: EngineTx): Promise<UploadedFileRecord | null> {
|
|
38
|
+
const row = await this.client(tx).uploadedFile.findFirst({
|
|
39
|
+
where: { id, orgId },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return row ? this.toRecord(row) : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async updateStatus(
|
|
46
|
+
orgId: string,
|
|
47
|
+
id: string,
|
|
48
|
+
status: FileStatus,
|
|
49
|
+
submissionId?: string,
|
|
50
|
+
tx?: EngineTx,
|
|
51
|
+
): Promise<UploadedFileRecord> {
|
|
52
|
+
const client = this.client(tx);
|
|
53
|
+
|
|
54
|
+
const existing = await client.uploadedFile.findFirst({
|
|
55
|
+
where: { id, orgId },
|
|
56
|
+
select: { id: true },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!existing) {
|
|
60
|
+
throw new Error(`Uploaded file ${id} was not found in this organisation.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const row = await client.uploadedFile.update({
|
|
64
|
+
where: { id: existing.id },
|
|
65
|
+
data: {
|
|
66
|
+
status,
|
|
67
|
+
...(submissionId !== undefined ? { submissionId } : {}),
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return this.toRecord(row);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async listGcEligible(cutoff: Date, limit: number): Promise<UploadedFileRecord[]> {
|
|
75
|
+
const rows = await this.client().uploadedFile.findMany({
|
|
76
|
+
where: {
|
|
77
|
+
status: 'TEMPORARY',
|
|
78
|
+
createdAt: { lte: cutoff },
|
|
79
|
+
},
|
|
80
|
+
orderBy: { createdAt: 'asc' },
|
|
81
|
+
take: limit,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return rows.map((row) => this.toRecord(row));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async delete(id: string, tx?: EngineTx): Promise<void> {
|
|
88
|
+
await this.client(tx).uploadedFile.deleteMany({
|
|
89
|
+
where: { id },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private toRecord(row: UploadedFileRow): UploadedFileRecord {
|
|
94
|
+
return {
|
|
95
|
+
id: row.id,
|
|
96
|
+
storageKey: row.storageKey,
|
|
97
|
+
originalName: row.originalName,
|
|
98
|
+
mimeType: row.mimeType,
|
|
99
|
+
size: row.size,
|
|
100
|
+
checksum: row.checksum,
|
|
101
|
+
ownerId: row.ownerId,
|
|
102
|
+
orgId: row.orgId,
|
|
103
|
+
status: row.status as FileStatus,
|
|
104
|
+
submissionId: row.submissionId,
|
|
105
|
+
createdAt: row.createdAt,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { FormDefinition as FormDefinitionRow, Prisma } from '@prisma/client';
|
|
3
|
+
import type {
|
|
4
|
+
EngineTx,
|
|
5
|
+
FormDefinition,
|
|
6
|
+
FormDefinitionPatch,
|
|
7
|
+
FormDefinitionRecord,
|
|
8
|
+
FormDefinitionStatus,
|
|
9
|
+
FormDefinitionStore,
|
|
10
|
+
NewFormDefinitionRecord,
|
|
11
|
+
} from '@ftisindia/form-builder';
|
|
12
|
+
import { PrismaService } from '../../../../database/prisma/prisma.service';
|
|
13
|
+
|
|
14
|
+
@Injectable()
|
|
15
|
+
export class PrismaFormDefinitionStore implements FormDefinitionStore {
|
|
16
|
+
constructor(private readonly prisma: PrismaService) {}
|
|
17
|
+
|
|
18
|
+
private client(tx?: EngineTx) {
|
|
19
|
+
return tx ? (tx as unknown as Prisma.TransactionClient) : this.prisma;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async create(record: NewFormDefinitionRecord, tx?: EngineTx): Promise<FormDefinitionRecord> {
|
|
23
|
+
const row = await this.client(tx).formDefinition.create({
|
|
24
|
+
data: {
|
|
25
|
+
orgId: record.orgId,
|
|
26
|
+
key: record.key,
|
|
27
|
+
version: record.version,
|
|
28
|
+
status: record.status,
|
|
29
|
+
schema: record.schema as unknown as Prisma.InputJsonValue,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return this.toRecord(row);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async update(
|
|
37
|
+
orgId: string,
|
|
38
|
+
key: string,
|
|
39
|
+
version: number,
|
|
40
|
+
patch: FormDefinitionPatch,
|
|
41
|
+
tx?: EngineTx,
|
|
42
|
+
): Promise<FormDefinitionRecord> {
|
|
43
|
+
const client = this.client(tx);
|
|
44
|
+
|
|
45
|
+
const existing = await client.formDefinition.findUnique({
|
|
46
|
+
where: { orgId_key_version: { orgId, key, version } },
|
|
47
|
+
select: { id: true },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (!existing) {
|
|
51
|
+
throw new Error(`Form definition ${key} v${version} was not found in this organisation.`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const row = await client.formDefinition.update({
|
|
55
|
+
where: { id: existing.id },
|
|
56
|
+
data: {
|
|
57
|
+
...(patch.status !== undefined ? { status: patch.status } : {}),
|
|
58
|
+
...(patch.schema !== undefined
|
|
59
|
+
? { schema: patch.schema as unknown as Prisma.InputJsonValue }
|
|
60
|
+
: {}),
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return this.toRecord(row);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async findByKeyVersion(
|
|
68
|
+
orgId: string,
|
|
69
|
+
key: string,
|
|
70
|
+
version: number,
|
|
71
|
+
tx?: EngineTx,
|
|
72
|
+
): Promise<FormDefinitionRecord | null> {
|
|
73
|
+
const row = await this.client(tx).formDefinition.findUnique({
|
|
74
|
+
where: { orgId_key_version: { orgId, key, version } },
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return row ? this.toRecord(row) : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async findLatest(
|
|
81
|
+
orgId: string,
|
|
82
|
+
key: string,
|
|
83
|
+
status?: FormDefinitionStatus,
|
|
84
|
+
): Promise<FormDefinitionRecord | null> {
|
|
85
|
+
const row = await this.client().formDefinition.findFirst({
|
|
86
|
+
where: { orgId, key, ...(status ? { status } : {}) },
|
|
87
|
+
orderBy: { version: 'desc' },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return row ? this.toRecord(row) : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async findAllByKeyStatus(
|
|
94
|
+
orgId: string,
|
|
95
|
+
key: string,
|
|
96
|
+
status: FormDefinitionStatus,
|
|
97
|
+
tx?: EngineTx,
|
|
98
|
+
): Promise<FormDefinitionRecord[]> {
|
|
99
|
+
const rows = await this.client(tx).formDefinition.findMany({
|
|
100
|
+
where: { orgId, key, status },
|
|
101
|
+
orderBy: { version: 'desc' },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return rows.map((row) => this.toRecord(row));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async list(
|
|
108
|
+
orgId: string,
|
|
109
|
+
options: { status?: FormDefinitionStatus; cursor?: string; limit: number },
|
|
110
|
+
): Promise<FormDefinitionRecord[]> {
|
|
111
|
+
const rows = await this.client().formDefinition.findMany({
|
|
112
|
+
where: { orgId, ...(options.status ? { status: options.status } : {}) },
|
|
113
|
+
orderBy: [{ key: 'asc' }, { version: 'desc' }],
|
|
114
|
+
take: options.limit,
|
|
115
|
+
...(options.cursor
|
|
116
|
+
? {
|
|
117
|
+
cursor: { id: options.cursor },
|
|
118
|
+
skip: 1,
|
|
119
|
+
}
|
|
120
|
+
: {}),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return rows.map((row) => this.toRecord(row));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async maxVersion(orgId: string, key: string, tx?: EngineTx): Promise<number> {
|
|
127
|
+
const result = await this.client(tx).formDefinition.aggregate({
|
|
128
|
+
_max: { version: true },
|
|
129
|
+
where: { orgId, key },
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return result._max.version ?? 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private toRecord(row: FormDefinitionRow): FormDefinitionRecord {
|
|
136
|
+
return {
|
|
137
|
+
id: row.id,
|
|
138
|
+
orgId: row.orgId,
|
|
139
|
+
key: row.key,
|
|
140
|
+
version: row.version,
|
|
141
|
+
status: row.status as FormDefinitionStatus,
|
|
142
|
+
schema: row.schema as unknown as FormDefinition,
|
|
143
|
+
createdAt: row.createdAt,
|
|
144
|
+
updatedAt: row.updatedAt,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|