diff --git a/.dev/tech-debt.md b/.dev/tech-debt.md
index 373f33e2..17458bf9 100644
--- a/.dev/tech-debt.md
+++ b/.dev/tech-debt.md
@@ -59,8 +59,51 @@ context: `dictionary_categories.id` is a plain auto-increment `serial`, used dir
---
+### No integration tests exist for the submission edit, delete, or commit endpoints — let alone mixed insert+update+delete scenarios
+standalone: yes
+context: `packages/data-provider/test/integration/routers/submission/` only covers `submissionRouter-submit*` (insert-only file/JSON uploads). There is no integration spec for `editSubmittedData`, `deleteSubmittedDataBySystemId`, or the commit flow (`performCommitSubmissionAsync`/`commitSubmissionWorker.ts`) at all, despite the test infra (`test/integration/dependencies/containers.ts`) already running a real Postgres container capable of exercising the real commit transaction. The new UPDATE/DELETE conflict resolution added 2026-08-12 has unit coverage only (`test/unit/utils/submission/findUpdateDeleteConflicts.spec.ts` and friends) — still no HTTP+DB-level test proving a submission with a staged conflict actually ends up INVALID end-to-end. Fix: add integration specs that stage inserts+updates+deletes in the same active submission (including same-systemId collisions) and assert the committed `submitted_data` table end state and the active submission's status/record states.
+
+### `GET /submission/:submissionId/details` can't filter records by state
+standalone: yes
+context: `submissionController.ts::getSubmissionDetailsById` (294-321) and `submissionService.ts::getSubmissionDetailsById` (326-372) only accept `entityNames`/`actionTypes` filters (`submissionDetailsRequestSchema`, `schemas.ts:339-351`), then call `submissionRecordsRepository.getBySubmissionId` (365-369) without a `states` filter. The repository itself already supports it — `getBySubmissionId`/`getByFileIds` (`submissionRecordsRepository.ts`) both accept `filterOptions.states?: SubmissionRecordState[]` and `SUBMISSION_RECORD_STATE` (`types.ts`) already enumerates `RECEIVED`/`VALID`/`INVALID` — so this is a plumbing gap, not a missing capability. Fix: add a `states`/`state` query param to `submissionDetailsRequestSchema`, thread it through `getSubmissionDetailsById`'s `filterOptions` in both the controller and service, and pass it to `submissionRecordsRepository.getBySubmissionId`.
+
+### API to download the originally uploaded file
+standalone: yes
+context: No original file bytes are stored anywhere today. Uploads land in a multer temp path (`dest: '/tmp'`, `submissionRouter.ts:26`), get streamed and parsed by `collectRows` (`fileUtils.ts:62-80`), and the temp file is deleted immediately after parsing (`fileUtils.ts:78`, `fs.unlink`). `submission_files` (`packages/data-model/src/models/submission_files.ts:7-17`) only keeps metadata (`fileName`, `entityName`, `fileSize`); `submission_records` only keeps parsed row data (jsonb), not the raw file. Needs a scope decision before implementation: (a) persist the raw uploaded bytes somewhere (object store or DB blob) at upload time going forward — adds storage/retention cost and doesn't help for submissions already committed under the old behavior, or (b) reconstruct a file from the stored parsed rows — lossy, won't reproduce original column order, formatting, or any extra/ignored columns, and "recreate" may not satisfy whatever this is needed for (audit, re-upload, external sharing). No download/`Content-Disposition` endpoint exists for submission files today; the only precedent for that response pattern in the codebase is `dictionaryController.ts:74-87` (zips dictionary templates, unrelated data).
+
+### Download error report for a file
+standalone: no
+context: Depends on the storage/identity decision above and needs its own format decision. Per-record errors already exist as `SubmissionRecordError[]` in `submission_records.errors` (jsonb, `packages/data-model/src/models/submission_records.ts:59-73`), and a file already has a stable identity distinct from `entityName` (`submission_files.id`, `submission_records.fileId`) — so per-file error retrieval is possible today via `getBySubmissionId`/`getByFileIds` (`submissionRecordsRepository.ts:34-63,149-177`), just not exposed as a dedicated download endpoint. Open question flagged by the requirement itself: return the raw per-record `errors` JSON as-is, or design a purpose-built report format (e.g. row/field/message table)? Note that line numbers are computed at parse time (`fileUtils.ts:104`, `+1 for header row, +1 for 1-based line numbers`) but aren't currently persisted into the stored `errors` — worth carrying through if the report should reference original file line numbers.
+
+### Download error report as a zip for all files in a submission
+standalone: no
+context: Depends on the single-file error report above being defined first — this is just "download that report N times, zipped." `jszip` is already a dependency (`package.json:48`) and already used for exactly this response shape (`zip.generateAsync` + `Content-Disposition: attachment` + `application/zip`) in `dictionaryController.ts:74-87`, so no new library or pattern work is needed once the per-file report format exists.
+
+### List submissions endpoint needs sorting and filtering
+standalone: yes
+context: `GET /submission/category/:categoryId` (`submissionController.ts:233-276`) only accepts `onlyActive`, `organization`, `username` (`submissionsByCategoryRequestSchema`, `schemas.ts:318-329`), plus `page`/`pageSize`. Sort order is hardcoded to `desc(submissions.createdAt)` (`activeSubmissionRepository.ts:216`) with no sort param at all. The mandatory requirement (reverse creation-date sort) already matches today's fixed default — the gap is that it isn't documented as a stable, guaranteed default, and there's no way to choose anything else. Nice-to-have filters not yet supported: study/category beyond the path param, date range (created/updated), creator, contributing users, statuses. `auditRepository.ts` already has a directly reusable pattern for both pieces: date-range filtering (lines 76-81, `lt`/`gt` on `createdAt` against `startDate`/`endDate`) and configurable-direction `orderBy` (line 126), driven by `AuditFilterOptions` (`types.ts:89-98`) — worth modeling the new filter options after that rather than inventing a new shape. Swagger (`submission-api.yml:144-171`) will need the new params documented too.
+
+### `GET /submission/:submissionId` needs richer per-file details
+standalone: yes
+context: The response (`SubmissionSummaryResponse`, `types.ts:302-305`, built by `createSubmissionSummaryResponse`/`submissionResponseParser.ts:65-78`) groups `inserts`/`updates` by `entityName`, each entry only exposing `batchName` (the original filename), `recordsCount`, and `errors` (a count, not detail) — see `submissionResponseParser.ts:14-58` and the source rows shape in `submissionRecordsRepository.ts:20-26,188-208`. Missing relative to the ask: no `fileId` in the response (so a client can't correlate a listed file to a future per-file download/error-report endpoint, above); no file size, even though `submission_files.fileSize` is already stored in the DB (`submission_files.ts:16`) and just isn't selected by `getRecordsSummaryBySubmissionId` (`submissionRecordsRepository.ts:188-197`); no rolled-up per-file validation status (only an error count — the per-record `state` enum `RECEIVED`/`VALID`/`INVALID` exists at `submission_records.ts:12` but isn't aggregated to file level); `deletes` has no per-file breakdown at all in the current model (`DataDeletesSubmissionSummary` is not batched by file). Also found in passing: swagger's `SubmissionDetailsResult` schema (`schemas.yml:87-111`) documents a shape that doesn't match what `/submission/:submissionId/details` actually returns (`SubmissionRecordWithEntityName[]`, i.e. `{id, actionType, state, fileId, data, errors, entityName}[]`) — worth correcting alongside this work since both touch the same response family.
+
## Resolved
+### Flaky integration test: dictionary migration force-retry intermittently returned 409 instead of 200
+resolved: 2026-08-12, made the test wait for the background migration worker to reach a terminal status before mutating it directly, removing the race. `dictionaryMigration.spec.ts`'s "should retry migration..." test now calls a `waitForMigrationToFinish` helper (same polling pattern already used in `dictionaryMigrationData.spec.ts`) right before overwriting the migration's status to `FAILED`, instead of assuming `initiateMigration`'s fire-and-forget worker had already finished. Verified with 3 consecutive full integration-suite runs, 380/380 passing each time, no failures.
+
+### Staging a DELETE submission record never checked for or merged with an existing pending record for the same systemId
+resolved: 2026-08-12, added a staging-time check instead of letting one action override the other silently. `resolveDeleteStagingConflicts` (`packages/data-provider/src/utils/submissionUtils.ts`) is now called from `deleteSubmittedDataBySystemId` (`submmittedData.ts`) before any new DELETE record is inserted: it fetches the Active Submission's existing UPDATE/DELETE records and cross-checks them against the systemIds about to be deleted (the target record plus its dependents). A systemId with a pending UPDATE is now rejected outright (`INVALID_SUBMISSION` response, no record staged) — consistent with the "both sides invalid" policy used for the same conflict at validation time — instead of quietly reaching `performDataValidation` later. A systemId that already has a pending DELETE is treated as a duplicate and skipped rather than inserted a second time, addressing the original in-code TODO directly. Previously the function always blindly inserted new DELETE rows regardless of what was already staged.
+
+### Duplicate UPDATE submission records for the same systemId collapsed via undefined last-write-wins at commit time
+resolved: 2026-08-12, added a deterministic `ORDER BY` instead of relying on unspecified Postgres row order. `submissionRecordsRepository.ts::getByFileIds` now does `.orderBy(submissionRecords.id)` (ascending) — `id` is a `serial` primary key, monotonic with insertion order, so `commitSubmissionWorker.ts`'s `record[systemId] = record` reduction now deterministically keeps the most recently inserted UPDATE row for a given systemId instead of whichever row Postgres happened to scan last. Considered adding a `created_at` timestamp column instead, but `id` already gives the same ordering guarantee without a schema migration, so that was dropped in favour of the simpler fix.
+
+### UPDATE/DELETE conflict on the same systemId was silently dropped instead of surfaced as an error
+resolved: 2026-08-12, added explicit conflict detection ahead of dictionary validation. `findUpdateDeleteConflicts` (`packages/data-provider/src/utils/submissionUtils.ts`) scans staged submission records for entityName+systemId pairs with both an UPDATE and a DELETE, before `performDataValidation` (`submissionProcessor.ts`) runs `validateSchemas`. Conflicting records are excluded from validation, both sides are marked `INVALID` with a new `CONFLICTING_ACTION` error (`RecordErrorActionConflict`, added to `SubmissionRecordError` in `@overture-stack/lyric-data-model`), the active submission is marked `INVALID`, and the scenario is `logger.error`-logged. Previously this was only prevented by incidental JS array-filtering order with no error surfaced (see the still-open items above for what remains: the staging-time TODO, and integration coverage).
+
+### `filterDeletesFromUpdates`/`filterRecordsByConflicts` removed as dead code
+resolved: 2026-08-12, same session as the fix above. Once `findUpdateDeleteConflicts` became the real, wired-in conflict detector, the unused `filterRecordsByConflicts`/`filterDeletesFromUpdates` pair (`submissionUtils.ts`, previously lines 234-280) had no remaining reason to exist — deleted both functions and their orphaned spec (`test/unit/utils/submission/filterDeletesFromUpdates.spec.ts`). Confirmed via repo-wide grep that nothing else referenced them before removing.
+
### Kafka publish tracking: no unit tests for `createPublishTracker`
diff --git a/apps/server/swagger/schemas.yml b/apps/server/swagger/schemas.yml
index 0e18592a..72046796 100644
--- a/apps/server/swagger/schemas.yml
+++ b/apps/server/swagger/schemas.yml
@@ -95,7 +95,7 @@ components:
type:
type: string
description: Type of action
- enum: ['INSERTS', 'UPDATES', 'DELETES']
+ enum: ['INSERT', 'UPDATE', 'DELETE']
entity:
type: string
description: Name of the entity
diff --git a/apps/server/swagger/submission-api.yml b/apps/server/swagger/submission-api.yml
index e7665673..8c0bb3fd 100644
--- a/apps/server/swagger/submission-api.yml
+++ b/apps/server/swagger/submission-api.yml
@@ -54,9 +54,9 @@
503:
$ref: '#/components/responses/ServiceUnavailableError'
-/submission/{submissionId}/details:
+/submission/{submissionId}/data:
get:
- summary: Fetch Submission Data records. Sorted in their original file order and grouped by `inserts`, `updates`, and `deletes`.
+ summary: Fetch Submission Data records. Sorted in their original file order and grouped by `insert`, `update`, and `delete`.
tags:
- Submission
parameters:
@@ -67,9 +67,9 @@
type: array
items:
type: string
- enum: [inserts, updates, deletes]
+ enum: [insert, update, delete]
uniqueItems: true
- description: Filters the Submission Data records by action type. Valid values are `inserts`, `updates`, and `deletes` (case insensitive). If not provided, all action types are returned.
+ description: Filters the Submission Data records by action type. Valid values are `insert`, `update`, and `delete` (case insensitive). If not provided, all action types are returned.
- name: entityNames
in: query
schema:
@@ -78,6 +78,12 @@
type: string
uniqueItems: true
description: Filters the Submission Data records by entity name. Must match names listed in the Submission Summary endpoint. If not provided, all entity names are returned.
+ - name: fileId
+ in: query
+ required: false
+ schema:
+ type: integer
+ description: An optional query parameter used to specify the file ID within the submission to be retrieved.
- $ref: '#/components/parameters/query/Page'
- $ref: '#/components/parameters/query/PageSize'
responses:
@@ -98,7 +104,6 @@
503:
$ref: '#/components/responses/ServiceUnavailableError'
-/submission/{submissionId}/{actionType}:
delete:
summary: Clear Active Submission by entity name
tags:
@@ -109,24 +114,18 @@
type: string
required: true
description: The ID of the Submission
- - name: actionType
- in: path
- required: true
- schema:
- type: string
- enum: [inserts, updates, deletes]
- description: Parameter to specify the type of record to remove from the Submission. Must be one of `inserts`, `updates`, or `deletes` (case insensitive)
- - name: entityName
+ - name: recordId
in: query
- type: string
- required: true
- description: The name of the entity
- - name: index
+ required: false
+ schema:
+ type: integer
+ description: An optional query parameter used to specify the record ID within the submission to be deleted.
Only one of `recordId` or `fileId` can be provided
+ - name: fileId
in: query
required: false
schema:
type: integer
- description: An optional query parameter used to specify the index of the item within the submission type to be deleted.
If not provided all the items within the submission type will be deleted.
+ description: An optional query parameter used to specify the file ID within the submission to be deleted.
Only one of `recordId` or `fileId` can be provided
responses:
200:
description: Submission cleared successfully. Returns the current Active Submission
diff --git a/packages/data-model/docs/schema.dbml b/packages/data-model/docs/schema.dbml
index 54b4e98b..0460ab44 100644
--- a/packages/data-model/docs/schema.dbml
+++ b/packages/data-model/docs/schema.dbml
@@ -128,10 +128,8 @@ table submission_records {
table submissions {
id serial [pk, not null, increment]
- data jsonb [not null]
dictionary_category_id integer [not null]
dictionary_id integer [not null]
- errors jsonb
organization varchar [not null]
status submission_status [not null]
created_at timestamp [default: `now()`]
@@ -187,7 +185,7 @@ ref: dictionary_migration.to_dictionary_id - dictionaries.id
ref: dictionary_migration.submission_id - submissions.id
-ref: submission_files.submission_id - submissions.id
+ref: submission_files.submission_id > submissions.id
ref: submission_records.file_id > submission_files.id
diff --git a/packages/data-model/migrations/0017_remove_submission_data.sql b/packages/data-model/migrations/0017_remove_submission_data.sql
new file mode 100644
index 00000000..a262dd9a
--- /dev/null
+++ b/packages/data-model/migrations/0017_remove_submission_data.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "submissions" DROP COLUMN IF EXISTS "data";--> statement-breakpoint
+ALTER TABLE "submissions" DROP COLUMN IF EXISTS "errors";
\ No newline at end of file
diff --git a/packages/data-model/migrations/meta/0017_snapshot.json b/packages/data-model/migrations/meta/0017_snapshot.json
new file mode 100644
index 00000000..919f3f5a
--- /dev/null
+++ b/packages/data-model/migrations/meta/0017_snapshot.json
@@ -0,0 +1,959 @@
+{
+ "id": "02b8e9d6-f2dc-4b13-9795-78b274688e7a",
+ "prevId": "d21aa03b-6754-4978-9209-7113bb3d5890",
+ "version": "5",
+ "dialect": "pg",
+ "tables": {
+ "audit_submitted_data": {
+ "name": "audit_submitted_data",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "audit_action",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dictionary_category_id": {
+ "name": "dictionary_category_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data_diff": {
+ "name": "data_diff",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "entity_name": {
+ "name": "entity_name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "errors": {
+ "name": "errors",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_valid_schema_id": {
+ "name": "last_valid_schema_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "new_data_is_valid": {
+ "name": "new_data_is_valid",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "old_data_is_valid": {
+ "name": "old_data_is_valid",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization": {
+ "name": "organization",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "original_schema_id": {
+ "name": "original_schema_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "submission_id": {
+ "name": "submission_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "system_id": {
+ "name": "system_id",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "audit_submitted_data_dictionary_index": {
+ "name": "audit_submitted_data_dictionary_index",
+ "columns": [
+ "dictionary_category_id"
+ ],
+ "isUnique": false
+ },
+ "audit_submitted_data_organization_index": {
+ "name": "audit_submitted_data_organization_index",
+ "columns": [
+ "organization"
+ ],
+ "isUnique": false
+ },
+ "audit_submitted_data_submission_index": {
+ "name": "audit_submitted_data_submission_index",
+ "columns": [
+ "submission_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "audit_submitted_data_dictionary_category_id_dictionary_categories_id_fk": {
+ "name": "audit_submitted_data_dictionary_category_id_dictionary_categories_id_fk",
+ "tableFrom": "audit_submitted_data",
+ "tableTo": "dictionary_categories",
+ "columnsFrom": [
+ "dictionary_category_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "audit_submitted_data_last_valid_schema_id_dictionaries_id_fk": {
+ "name": "audit_submitted_data_last_valid_schema_id_dictionaries_id_fk",
+ "tableFrom": "audit_submitted_data",
+ "tableTo": "dictionaries",
+ "columnsFrom": [
+ "last_valid_schema_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "audit_submitted_data_original_schema_id_dictionaries_id_fk": {
+ "name": "audit_submitted_data_original_schema_id_dictionaries_id_fk",
+ "tableFrom": "audit_submitted_data",
+ "tableTo": "dictionaries",
+ "columnsFrom": [
+ "original_schema_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "audit_submitted_data_submission_id_submissions_id_fk": {
+ "name": "audit_submitted_data_submission_id_submissions_id_fk",
+ "tableFrom": "audit_submitted_data",
+ "tableTo": "submissions",
+ "columnsFrom": [
+ "submission_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "dictionaries": {
+ "name": "dictionaries",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "dictionary": {
+ "name": "dictionary",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "dictionary_categories": {
+ "name": "dictionary_categories",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "active_dictionary_id": {
+ "name": "active_dictionary_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "alias": {
+ "name": "alias",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "default_centric_entity": {
+ "name": "default_centric_entity",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "dictionary_categories_alias_unique": {
+ "name": "dictionary_categories_alias_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "alias"
+ ]
+ },
+ "dictionary_categories_name_unique": {
+ "name": "dictionary_categories_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ }
+ },
+ "dictionary_migration": {
+ "name": "dictionary_migration",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "category_id": {
+ "name": "category_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "from_dictionary_id": {
+ "name": "from_dictionary_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "to_dictionary_id": {
+ "name": "to_dictionary_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "submission_id": {
+ "name": "submission_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "migration_status",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "retries": {
+ "name": "retries",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "dictionary_migration_category_id_index": {
+ "name": "dictionary_migration_category_id_index",
+ "columns": [
+ "category_id"
+ ],
+ "isUnique": false
+ },
+ "dictionary_migration_from_dictionary_id_index": {
+ "name": "dictionary_migration_from_dictionary_id_index",
+ "columns": [
+ "from_dictionary_id"
+ ],
+ "isUnique": false
+ },
+ "dictionary_migration_to_dictionary_id_index": {
+ "name": "dictionary_migration_to_dictionary_id_index",
+ "columns": [
+ "to_dictionary_id"
+ ],
+ "isUnique": false
+ },
+ "dictionary_migration_submission_id_index": {
+ "name": "dictionary_migration_submission_id_index",
+ "columns": [
+ "submission_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "dictionary_migration_category_id_dictionary_categories_id_fk": {
+ "name": "dictionary_migration_category_id_dictionary_categories_id_fk",
+ "tableFrom": "dictionary_migration",
+ "tableTo": "dictionary_categories",
+ "columnsFrom": [
+ "category_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "dictionary_migration_from_dictionary_id_dictionaries_id_fk": {
+ "name": "dictionary_migration_from_dictionary_id_dictionaries_id_fk",
+ "tableFrom": "dictionary_migration",
+ "tableTo": "dictionaries",
+ "columnsFrom": [
+ "from_dictionary_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "dictionary_migration_to_dictionary_id_dictionaries_id_fk": {
+ "name": "dictionary_migration_to_dictionary_id_dictionaries_id_fk",
+ "tableFrom": "dictionary_migration",
+ "tableTo": "dictionaries",
+ "columnsFrom": [
+ "to_dictionary_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "dictionary_migration_submission_id_submissions_id_fk": {
+ "name": "dictionary_migration_submission_id_submissions_id_fk",
+ "tableFrom": "dictionary_migration",
+ "tableTo": "submissions",
+ "columnsFrom": [
+ "submission_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "submission_files": {
+ "name": "submission_files",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "submission_id": {
+ "name": "submission_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_name": {
+ "name": "file_name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_name": {
+ "name": "entity_name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_size": {
+ "name": "file_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "submission_files_submission_id_index": {
+ "name": "submission_files_submission_id_index",
+ "columns": [
+ "submission_id"
+ ],
+ "isUnique": false
+ },
+ "submission_files_submission_entity_file_index": {
+ "name": "submission_files_submission_entity_file_index",
+ "columns": [
+ "submission_id",
+ "entity_name",
+ "file_name"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "submission_files_submission_id_submissions_id_fk": {
+ "name": "submission_files_submission_id_submissions_id_fk",
+ "tableFrom": "submission_files",
+ "tableTo": "submissions",
+ "columnsFrom": [
+ "submission_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "submission_records": {
+ "name": "submission_records",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "file_id": {
+ "name": "file_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "data": {
+ "name": "data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action_type": {
+ "name": "action_type",
+ "type": "submission_record_type",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "errors": {
+ "name": "errors",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "state": {
+ "name": "state",
+ "type": "submission_record_state",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "submission_records_file_id_index": {
+ "name": "submission_records_file_id_index",
+ "columns": [
+ "file_id"
+ ],
+ "isUnique": false
+ },
+ "submission_records_file_id_action_type_index": {
+ "name": "submission_records_file_id_action_type_index",
+ "columns": [
+ "file_id",
+ "action_type"
+ ],
+ "isUnique": false
+ },
+ "submission_records_file_id_state_action_type_index": {
+ "name": "submission_records_file_id_state_action_type_index",
+ "columns": [
+ "file_id",
+ "state",
+ "action_type"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "submission_records_file_id_submission_files_id_fk": {
+ "name": "submission_records_file_id_submission_files_id_fk",
+ "tableFrom": "submission_records",
+ "tableTo": "submission_files",
+ "columnsFrom": [
+ "file_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "submissions": {
+ "name": "submissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "dictionary_category_id": {
+ "name": "dictionary_category_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dictionary_id": {
+ "name": "dictionary_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization": {
+ "name": "organization",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "submission_status",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "submission_organization_index": {
+ "name": "submission_organization_index",
+ "columns": [
+ "organization"
+ ],
+ "isUnique": false
+ },
+ "submission_category_index": {
+ "name": "submission_category_index",
+ "columns": [
+ "dictionary_category_id"
+ ],
+ "isUnique": false
+ },
+ "submission_created_by_index": {
+ "name": "submission_created_by_index",
+ "columns": [
+ "created_by"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "submissions_dictionary_category_id_dictionary_categories_id_fk": {
+ "name": "submissions_dictionary_category_id_dictionary_categories_id_fk",
+ "tableFrom": "submissions",
+ "tableTo": "dictionary_categories",
+ "columnsFrom": [
+ "dictionary_category_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "submissions_dictionary_id_dictionaries_id_fk": {
+ "name": "submissions_dictionary_id_dictionaries_id_fk",
+ "tableFrom": "submissions",
+ "tableTo": "dictionaries",
+ "columnsFrom": [
+ "dictionary_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {}
+ },
+ "submitted_data": {
+ "name": "submitted_data",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "data": {
+ "name": "data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dictionary_category_id": {
+ "name": "dictionary_category_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_name": {
+ "name": "entity_name",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_valid": {
+ "name": "is_valid",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_valid_schema_id": {
+ "name": "last_valid_schema_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "organization": {
+ "name": "organization",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "original_schema_id": {
+ "name": "original_schema_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "system_id": {
+ "name": "system_id",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "now()"
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "varchar",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "submitted_data_organization_index": {
+ "name": "submitted_data_organization_index",
+ "columns": [
+ "organization"
+ ],
+ "isUnique": false
+ },
+ "submitted_data_category_index": {
+ "name": "submitted_data_category_index",
+ "columns": [
+ "dictionary_category_id"
+ ],
+ "isUnique": false
+ },
+ "submitted_data_system_id_index": {
+ "name": "submitted_data_system_id_index",
+ "columns": [
+ "system_id"
+ ],
+ "isUnique": false
+ },
+ "submitted_data_entity_name_index": {
+ "name": "submitted_data_entity_name_index",
+ "columns": [
+ "entity_name"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "submitted_data_dictionary_category_id_dictionary_categories_id_fk": {
+ "name": "submitted_data_dictionary_category_id_dictionary_categories_id_fk",
+ "tableFrom": "submitted_data",
+ "tableTo": "dictionary_categories",
+ "columnsFrom": [
+ "dictionary_category_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "submitted_data_last_valid_schema_id_dictionaries_id_fk": {
+ "name": "submitted_data_last_valid_schema_id_dictionaries_id_fk",
+ "tableFrom": "submitted_data",
+ "tableTo": "dictionaries",
+ "columnsFrom": [
+ "last_valid_schema_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "submitted_data_original_schema_id_dictionaries_id_fk": {
+ "name": "submitted_data_original_schema_id_dictionaries_id_fk",
+ "tableFrom": "submitted_data",
+ "tableTo": "dictionaries",
+ "columnsFrom": [
+ "original_schema_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "submitted_data_system_id_unique": {
+ "name": "submitted_data_system_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "system_id"
+ ]
+ }
+ }
+ }
+ },
+ "enums": {
+ "audit_action": {
+ "name": "audit_action",
+ "values": {
+ "UPDATE": "UPDATE",
+ "DELETE": "DELETE",
+ "MIGRATION": "MIGRATION"
+ }
+ },
+ "migration_status": {
+ "name": "migration_status",
+ "values": {
+ "IN_PROGRESS": "IN_PROGRESS",
+ "COMPLETED": "COMPLETED",
+ "FAILED": "FAILED"
+ }
+ },
+ "submission_record_state": {
+ "name": "submission_record_state",
+ "values": {
+ "RECEIVED": "RECEIVED",
+ "VALID": "VALID",
+ "INVALID": "INVALID"
+ }
+ },
+ "submission_record_type": {
+ "name": "submission_record_type",
+ "values": {
+ "INSERT": "INSERT",
+ "UPDATE": "UPDATE",
+ "DELETE": "DELETE"
+ }
+ },
+ "submission_status": {
+ "name": "submission_status",
+ "values": {
+ "OPEN": "OPEN",
+ "VALIDATING": "VALIDATING",
+ "VALID": "VALID",
+ "INVALID": "INVALID",
+ "CLOSED": "CLOSED",
+ "COMMITTING": "COMMITTING",
+ "COMMITTED": "COMMITTED"
+ }
+ }
+ },
+ "schemas": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/data-model/migrations/meta/_journal.json b/packages/data-model/migrations/meta/_journal.json
index f24afaef..1c159718 100644
--- a/packages/data-model/migrations/meta/_journal.json
+++ b/packages/data-model/migrations/meta/_journal.json
@@ -120,6 +120,13 @@
"when": 1786388108952,
"tag": "0016_CUSTOM_migrate_submission_records",
"breakpoints": true
+ },
+ {
+ "idx": 17,
+ "version": "5",
+ "when": 1786414328157,
+ "tag": "0017_remove_submission_data",
+ "breakpoints": true
}
]
}
diff --git a/packages/data-model/src/models/submission_records.ts b/packages/data-model/src/models/submission_records.ts
index 01ab8a66..d6d91e95 100644
--- a/packages/data-model/src/models/submission_records.ts
+++ b/packages/data-model/src/models/submission_records.ts
@@ -13,43 +13,53 @@ export const submissionRecordState = pgEnum('submission_record_state', ['RECEIVE
export const submissionRecordType = pgEnum('submission_record_type', ['INSERT', 'UPDATE', 'DELETE']);
-// TODO: export this type
-type SubmissionInsertData = DataRecord;
+export type SubmissionInsertData = DataRecord;
-// TODO: export this type
-type SubmissionUpdateData = {
+export type SubmissionUpdateData = {
systemId: string;
old: DataRecord;
new: DataRecord;
};
-// TODO: export this type
-type SubmissionDeleteData = {
+export type SubmissionDeleteData = {
systemId: string;
data: DataRecord;
isValid: boolean;
organization: string;
};
-// TODO: export this type
-type SubmissionData = SubmissionInsertData | SubmissionUpdateData | SubmissionDeleteData;
+export type SubmissionData = SubmissionInsertData | SubmissionUpdateData | SubmissionDeleteData;
-// TODO: export this type
-type FieldDetails = {
+export type FieldDetails = {
fieldName: string;
fieldValue: DataRecordValue;
};
-// TODO: export this type
-type UnrecognizedValueReason = {
+export type UnrecognizedValueReason = {
reason: 'UNRECOGNIZED_VALUE';
};
-// TODO: export this type
-type RecordErrorInvalidValue = FieldDetails & UnrecognizedValueReason;
+export type RecordErrorInvalidValue = FieldDetails & UnrecognizedValueReason;
-// TODO: export this type
-type SubmissionRecordError = DictionaryValidationRecordErrorDetails | RecordErrorInvalidValue;
+export type ConflictingActionReason = {
+ reason: 'CONFLICTING_ACTION';
+};
+
+/**
+ * Raised when a record's `systemId` has both an UPDATE and a DELETE staged in the same
+ * Active Submission. `conflictingActionType` names the *other* action type this record
+ * conflicts with, so both sides of the conflict can be reported independently.
+ */
+export type RecordErrorActionConflict = ConflictingActionReason & {
+ systemId: string;
+ conflictingActionType: 'UPDATE' | 'DELETE';
+ message: string;
+};
+
+export type SubmissionRecordError =
+ | DictionaryValidationRecordErrorDetails
+ | RecordErrorInvalidValue
+ | RecordErrorActionConflict;
export const submissionRecords = pgTable(
'submission_records',
diff --git a/packages/data-model/src/models/submissions.ts b/packages/data-model/src/models/submissions.ts
index 7020a81e..d264628a 100644
--- a/packages/data-model/src/models/submissions.ts
+++ b/packages/data-model/src/models/submissions.ts
@@ -1,14 +1,9 @@
import { relations } from 'drizzle-orm';
-import { index, integer, jsonb, pgEnum, pgTable, serial, timestamp, varchar } from 'drizzle-orm/pg-core';
-
-import {
- type DataRecord,
- type DataRecordValue,
- type DictionaryValidationRecordErrorDetails,
-} from '@overture-stack/lectern-client';
+import { index, integer, pgEnum, pgTable, serial, timestamp, varchar } from 'drizzle-orm/pg-core';
import { dictionaries } from './dictionaries.js';
import { dictionaryCategories } from './dictionary_categories.js';
+import { submissionFiles } from './submission_files.js';
export const submissionStatusEnum = pgEnum('submission_status', [
'OPEN',
@@ -20,64 +15,16 @@ export const submissionStatusEnum = pgEnum('submission_status', [
'COMMITTED',
]);
-export type SubmissionInsertData = {
- batchName: string;
- records: DataRecord[];
-};
-
-export type SubmissionUpdateData = {
- systemId: string;
- old: DataRecord;
- new: DataRecord;
-};
-
-export type SubmissionDeleteData = {
- systemId: string;
- data: DataRecord;
- entityName: string;
- isValid: boolean;
- organization: string;
-};
-
-export type SubmissionData = {
- inserts?: Record;
- updates?: Record;
- deletes?: Record;
-};
-
-export type FieldDetails = {
- fieldName: string;
- fieldValue: DataRecordValue;
-};
-
-export type UnrecognizedValueReason = {
- reason: 'UNRECOGNIZED_VALUE';
-};
-
-export type RecordErrorInvalidValue = FieldDetails & UnrecognizedValueReason;
-
-export type SubmissionRecordErrorDetails = {
- index: number;
-} & (DictionaryValidationRecordErrorDetails | RecordErrorInvalidValue);
-
-export type SubmissionErrors = {
- inserts?: Record;
- updates?: Record;
- deletes?: Record;
-};
-
export const submissions = pgTable(
'submissions',
{
id: serial('id').primaryKey(),
- data: jsonb('data').$type().notNull(),
dictionaryCategoryId: integer('dictionary_category_id')
.references(() => dictionaryCategories.id)
.notNull(),
dictionaryId: integer('dictionary_id')
.references(() => dictionaries.id)
.notNull(),
- errors: jsonb('errors').$type(),
organization: varchar('organization').notNull(),
status: submissionStatusEnum('status').notNull(),
createdAt: timestamp('created_at').defaultNow(),
@@ -94,7 +41,7 @@ export const submissions = pgTable(
},
);
-export const submissionRelations = relations(submissions, ({ one }) => ({
+export const submissionRelations = relations(submissions, ({ one, many }) => ({
dictionary: one(dictionaries, {
fields: [submissions.dictionaryId],
references: [dictionaries.id],
@@ -103,6 +50,7 @@ export const submissionRelations = relations(submissions, ({ one }) => ({
fields: [submissions.dictionaryCategoryId],
references: [dictionaryCategories.id],
}),
+ submissionFiles: many(submissionFiles),
}));
export type Submission = typeof submissions.$inferSelect; // return type when queried
diff --git a/packages/data-provider/src/controllers/submissionController.ts b/packages/data-provider/src/controllers/submissionController.ts
index 29ab35a5..e2ea9bf0 100644
--- a/packages/data-provider/src/controllers/submissionController.ts
+++ b/packages/data-provider/src/controllers/submissionController.ts
@@ -18,9 +18,9 @@ import {
submissionActiveByOrganizationRequestSchema,
submissionByIdRequestSchema,
submissionCommitRequestSchema,
- submissionDeleteEntityNameRequestSchema,
submissionDeleteRequestSchema,
submissionDetailsRequestSchema,
+ submissionRecordDeleteRequestSchema,
submissionsByCategoryRequestSchema,
uploadSingleEntitySubmissionDataRequestSchema,
uploadSubmissionRequestSchema,
@@ -30,8 +30,8 @@ import {
BATCH_ERROR_TYPE,
BatchError,
type PaginatedResponse,
- SUBMISSION_ACTION_TYPE,
- type SubmissionSummary,
+ SUBMISSION_RECORD_ACTION_TYPE,
+ type SubmissionSummaryResponse,
} from '../utils/types.js';
const controller = ({
@@ -112,17 +112,22 @@ const controller = ({
next(error);
}
}),
- deleteEntityName: validateRequest(submissionDeleteEntityNameRequestSchema, async (req, res, next) => {
+ deleteByRecordIdOrFileId: validateRequest(submissionRecordDeleteRequestSchema, async (req, res, next) => {
try {
const submissionId = Number(req.params.submissionId);
- const actionType = SUBMISSION_ACTION_TYPE.parse(req.params.actionType.toUpperCase());
- const entityName = req.query.entityName;
- const index = req.query.index ? parseInt(req.query.index) : null;
+ const recordId = req.query.recordId ? parseInt(req.query.recordId) : null;
+ const fileId = req.query.fileId ? parseInt(req.query.fileId) : null;
const user = req.user;
+ if (!recordId && !fileId) {
+ throw new BadRequest('Either "recordId" or "fileId" query parameter must be provided for deletion.');
+ } else if (recordId && fileId) {
+ throw new BadRequest('Only one of "recordId" or "fileId" query parameter can be provided for deletion.');
+ }
+
logger.info(
LOG_MODULE,
- `Request Delete '${entityName ? entityName : 'all'}' records on '{${actionType}}' Active Submission '${submissionId}'`,
+ `Request Delete records with ${recordId ? `ID '${recordId}'` : ''}${fileId ? ` file ID '${fileId}'` : ''} on Submission '${submissionId}'`,
);
const submission = await submissionService.getSubmissionById(submissionId);
@@ -136,15 +141,10 @@ const controller = ({
const username = user?.username || '';
- const deleteSubmissionEntityResult = await submissionService.deleteActiveSubmissionEntity(
- submissionId,
- username,
- {
- actionType,
- entityName,
- index,
- },
- );
+ const deleteSubmissionEntityResult = await submissionService.deleteByRecordIdOrFileId(submissionId, username, {
+ recordId,
+ fileId,
+ });
if (isEmpty(deleteSubmissionEntityResult)) {
throw new NotFound('Active Submission not found');
@@ -232,7 +232,7 @@ const controller = ({
}),
getSubmissionsByCategory: validateRequest(
submissionsByCategoryRequestSchema,
- async (req, res: Response>, next) => {
+ async (req, res: Response>, next) => {
try {
const categoryId = await resolveCategoryId(baseDependencies, req.params.categoryId);
if (categoryId === undefined) {
@@ -258,7 +258,7 @@ const controller = ({
{ onlyActive, username, organization },
);
- const response: PaginatedResponse = {
+ const response: PaginatedResponse = {
pagination: {
currentPage: page,
pageSize: pageSize,
@@ -295,8 +295,9 @@ const controller = ({
try {
const submissionId = Number(req.params.submissionId);
const entityNames = asArray(req.query.entityNames || []);
+ const fileId = req.query.fileId ? parseInt(req.query.fileId) : undefined;
- const actionTypes = parseSubmissionActionTypes(req.query.actionTypes || SUBMISSION_ACTION_TYPE.options);
+ const actionTypes = parseSubmissionActionTypes(req.query.actionTypes || SUBMISSION_RECORD_ACTION_TYPE.options);
// query params
const page = parseInt(String(req.query.page)) || DEFAULT_PAGE;
@@ -307,13 +308,9 @@ const controller = ({
const submission = await submissionService.getSubmissionDetailsById({
submissionId,
paginationOptions: { page, pageSize },
- filterOptions: { entityNames, actionTypes },
+ filterOptions: { entityNames, actionTypes, fileId },
});
- if (isEmpty(submission)) {
- throw new NotFound('Submission not found');
- }
-
return res.status(200).json(submission);
} catch (error) {
next(error);
diff --git a/packages/data-provider/src/core/provider.ts b/packages/data-provider/src/core/provider.ts
index 37bd5c70..65588a1e 100644
--- a/packages/data-provider/src/core/provider.ts
+++ b/packages/data-provider/src/core/provider.ts
@@ -13,6 +13,8 @@ import auditRepository from '../repository/auditRepository.js';
import categoryRepository from '../repository/categoryRepository.js';
import migrationRepository from '../repository/dictionaryMigrationRepository.js';
import dictionaryRepository from '../repository/dictionaryRepository.js';
+import submissionFilesRepository from '../repository/submissionFilesRepository.js';
+import submissionRecordsRepository from '../repository/submissionRecordsRepository.js';
import submittedDataRepository from '../repository/submittedRepository.js';
import auditRouter from '../routers/auditRouter.js';
import categoryRouter from '../routers/categoryRouter.js';
@@ -104,6 +106,8 @@ const provider = (configData: AppConfig, options?: ProviderOptions) => {
category: categoryRepository(baseDeps),
dictionary: dictionaryRepository(baseDeps),
migration: migrationRepository(baseDeps),
+ submissionFiles: submissionFilesRepository(baseDeps),
+ submissionRecords: submissionRecordsRepository(baseDeps),
submission: submissionRepository(baseDeps),
submittedData: submittedDataRepository(baseDeps),
},
diff --git a/packages/data-provider/src/repository/activeSubmissionRepository.ts b/packages/data-provider/src/repository/activeSubmissionRepository.ts
index e9e1f6b5..a5434886 100644
--- a/packages/data-provider/src/repository/activeSubmissionRepository.ts
+++ b/packages/data-provider/src/repository/activeSubmissionRepository.ts
@@ -1,7 +1,7 @@
import type { ExtractTablesWithRelations, SQL } from 'drizzle-orm';
import type { PgTransaction } from 'drizzle-orm/pg-core';
import type { PostgresJsQueryResultHKT } from 'drizzle-orm/postgres-js';
-import { and, count, eq, inArray, sql } from 'drizzle-orm/sql';
+import { and, count, eq, inArray } from 'drizzle-orm/sql';
import { type NewSubmission, type Submission, submissions } from '@overture-stack/lyric-data-model/models';
@@ -11,18 +11,16 @@ import { inProcessSubmissionStatus, openSubmissionStatus } from '../utils/submis
import type {
BooleanTrueObject,
PaginationOptions,
- SubmissionDataDetailsRepositoryRecord,
- SubmissionDataSummary,
- SubmissionDataSummaryRepositoryRecord,
- SubmissionErrorsSummary,
+ PartialColumns,
+ SubmissionWithDictionaryAndCategoryRepositoryRecord,
} from '../utils/types.js';
const activeSubmissionRepository = (dependencies: BaseDependencies) => {
const LOG_MODULE = 'ACTIVE_SUBMISSION_REPOSITORY';
const { db, logger } = dependencies;
- // Submission columns for lightweight queries to exclude `data` and `errors` columns to improve performance
- const submissionColumns: BooleanTrueObject = {
+ // Submission columns for lightweight queries to exclude foreign ID fields
+ const submissionColumns = {
id: true,
status: true,
organization: true,
@@ -30,14 +28,7 @@ const activeSubmissionRepository = (dependencies: BaseDependencies) => {
createdBy: true,
updatedAt: true,
updatedBy: true,
- };
-
- // Submission columns for full detail queries including `data` and `errors` columns
- const submissionColumnsWithData: BooleanTrueObject = {
- ...submissionColumns,
- data: true,
- errors: true,
- };
+ } as const satisfies PartialColumns>;
const submissionDictionaryRelationColumns = {
dictionary: {
@@ -67,116 +58,6 @@ const activeSubmissionRepository = (dependencies: BaseDependencies) => {
dictionaryCategory: Omit & { alias?: string };
};
- /**
- * A query to generate a summarized JSON object of the 'data' column
- * Returns a JSON object of type SubmissionDataSummary
- */
- const dataSummaryQuery = sql`
-jsonb_build_object(
- 'inserts',
- (
- SELECT jsonb_object_agg(
- i.key,
- jsonb_build_object(
- 'batchName', i.value->>'batchName',
- 'recordsCount',
- CASE
- WHEN jsonb_typeof(i.value->'records') = 'array'
- THEN jsonb_array_length(i.value->'records')
- ELSE 0
- END
- )
- )
- FROM jsonb_each(${submissions.data}->'inserts') AS i(key, value)
- ),
-
- 'updates',
- (
- SELECT jsonb_object_agg(
- u.key,
- jsonb_build_object(
- 'recordsCount',
- CASE
- WHEN jsonb_typeof(u.value) = 'array'
- THEN jsonb_array_length(u.value)
- ELSE 0
- END
- )
- )
- FROM jsonb_each(${submissions.data}->'updates') AS u(key, value)
- ),
-
- 'deletes',
- (
- SELECT jsonb_object_agg(
- d.key,
- jsonb_build_object(
- 'recordsCount',
- CASE
- WHEN jsonb_typeof(d.value) = 'array'
- THEN jsonb_array_length(d.value)
- ELSE 0
- END
- )
- )
- FROM jsonb_each(${submissions.data}->'deletes') AS d(key, value)
- )
-)`.as('data');
-
- /**
- * A query to generate a summarized JSON object of the 'errors' column
- * Returns a json object of type SubmissionErrorsSummary
- */
- const errorsSummaryQuery = sql`jsonb_build_object(
- 'inserts',
- (
- SELECT jsonb_object_agg(
- i.key,
- jsonb_build_object(
- 'recordsCount',
- CASE
- WHEN jsonb_typeof(i.value) = 'array'
- THEN jsonb_array_length(i.value)
- ELSE 0
- END
- )
- )
- FROM jsonb_each(${submissions.errors}->'inserts') AS i(key, value)
- ),
-
- 'updates',
- (
- SELECT jsonb_object_agg(
- u.key,
- jsonb_build_object(
- 'recordsCount',
- CASE
- WHEN jsonb_typeof(u.value) = 'array'
- THEN jsonb_array_length(u.value)
- ELSE 0
- END
- )
- )
- FROM jsonb_each(${submissions.errors}->'updates') AS u(key, value)
- ),
-
- 'deletes',
- (
- SELECT jsonb_object_agg(
- d.key,
- jsonb_build_object(
- 'recordsCount',
- CASE
- WHEN jsonb_typeof(d.value) = 'array'
- THEN jsonb_array_length(d.value)
- ELSE 0
- END
- )
- )
- FROM jsonb_each(${submissions.errors}->'deletes') AS d(key, value)
- )
-)`.as('errors');
-
/**
* SQL condition used to filter submissions that are in an active state.
* Example usage:
@@ -212,45 +93,17 @@ jsonb_build_object(
}
},
- /**
- * Returns the entire active submission, including all data.
- */
- getActiveSubmissionDetails: async ({
- categoryId,
- organization,
- username,
- }: {
- categoryId: number;
- username: string;
- organization: string;
- }): Promise | undefined> => {
- try {
- const dbResponse = await db.query.submissions.findFirst({
- where: and(
- eq(submissions.dictionaryCategoryId, categoryId),
- eq(submissions.createdBy, username),
- eq(submissions.organization, organization),
- activeStatusesCondition,
- ),
- columns: submissionColumnsWithData,
- with: submissionDictionaryRelationColumns,
- });
- return dbResponse;
- } catch (error) {
- logger.error(LOG_MODULE, `Failed getting active submission data`, error);
- throw new ServiceUnavailable();
- }
- },
-
/**
* Finds the current Active Submission by parameters
+ * Returns general information about the Submission, including its dictionary and category relations,
+ * omitting its submissionFiles or submissionRecords relations.
* @param {Object} params
* @param {number} params.categoryId Category ID
* @param {string} params.username Name of the user
* @param {string} params.organization Organization name
* @returns
*/
- getActiveSubmissionSummary: async ({
+ getActiveSubmission: async ({
categoryId,
username,
organization,
@@ -258,7 +111,7 @@ jsonb_build_object(
categoryId: number;
username: string;
organization: string;
- }): Promise => {
+ }): Promise => {
try {
const result = await db.query.submissions.findFirst({
where: and(
@@ -269,7 +122,6 @@ jsonb_build_object(
),
columns: submissionColumns,
with: submissionDictionaryRelationColumns,
- extras: { data: dataSummaryQuery, errors: errorsSummaryQuery },
});
return result ? withAliasNormalized(result) : undefined;
} catch (error) {
@@ -280,16 +132,19 @@ jsonb_build_object(
/**
* Finds a Submission by ID
+ * Returns general information about the Submission, including its dictionary and category relations,
+ * omitting its submissionFiles or submissionRecords relations.
* @param {number} submissionId Submission ID
* @returns The Submission found
*/
- getSubmissionById: async (submissionId: number): Promise => {
+ getSubmissionById: async (
+ submissionId: number,
+ ): Promise => {
try {
const result = await db.query.submissions.findFirst({
where: and(eq(submissions.id, submissionId)),
columns: submissionColumns,
with: submissionDictionaryRelationColumns,
- extras: { data: dataSummaryQuery, errors: errorsSummaryQuery },
});
return result ? withAliasNormalized(result) : undefined;
} catch (error) {
@@ -298,28 +153,6 @@ jsonb_build_object(
}
},
- /**
- * Retun the Submission with data details by ID
- * This includes the `data` and `errors` columns
- * @param {number} submissionId Submission ID
- * @returns The Submission found
- */
- getSubmissionDetailsById: async (
- submissionId: number,
- ): Promise => {
- try {
- const result = await db.query.submissions.findFirst({
- where: and(eq(submissions.id, submissionId)),
- columns: submissionColumnsWithData,
- with: submissionDictionaryRelationColumns,
- });
- return result ? withAliasNormalized(result) : undefined;
- } catch (error) {
- logger.error(LOG_MODULE, `Failed getting Submission details with id '${submissionId}'`, error);
- throw new ServiceUnavailable();
- }
- },
-
/**
* Update a Submission record in database
* @param {number} submissionId Submission ID to update
@@ -368,7 +201,7 @@ jsonb_build_object(
username?: string;
organization?: string;
},
- ): Promise => {
+ ): Promise => {
const { page, pageSize } = paginationOptions;
try {
const results = await db.query.submissions.findMany({
@@ -379,7 +212,6 @@ jsonb_build_object(
filterOptions.organization ? eq(submissions.organization, filterOptions.organization) : undefined,
),
columns: submissionColumns,
- extras: { data: dataSummaryQuery, errors: errorsSummaryQuery },
with: submissionDictionaryRelationColumns,
orderBy: (submissions, { desc }) => desc(submissions.createdAt),
limit: pageSize,
diff --git a/packages/data-provider/src/repository/submissionFilesRepository.ts b/packages/data-provider/src/repository/submissionFilesRepository.ts
new file mode 100644
index 00000000..bed127d2
--- /dev/null
+++ b/packages/data-provider/src/repository/submissionFilesRepository.ts
@@ -0,0 +1,102 @@
+import type { ExtractTablesWithRelations } from 'drizzle-orm';
+import type { PgTransaction } from 'drizzle-orm/pg-core';
+import type { PostgresJsQueryResultHKT } from 'drizzle-orm/postgres-js';
+import { eq } from 'drizzle-orm/sql';
+
+import { type NewSubmissionFile, type SubmissionFile, submissionFiles } from '@overture-stack/lyric-data-model/models';
+
+import { BaseDependencies } from '../config/config.js';
+import { ServiceUnavailable } from '../utils/errors.js';
+
+const submissionFilesRepository = (dependencies: BaseDependencies) => {
+ const LOG_MODULE = 'SUBMISSION_FILES_REPOSITORY';
+ const { db, logger } = dependencies;
+
+ return {
+ save: async (
+ input: NewSubmissionFile,
+ tx?: PgTransaction>,
+ ): Promise => {
+ try {
+ const [savedSubmissionFile] = await (tx || db)
+ .insert(submissionFiles)
+ .values(input)
+ .returning({ id: submissionFiles.id });
+ if (!savedSubmissionFile) {
+ throw new Error('Failed to insert Submission File, no row returned');
+ }
+ logger.info(LOG_MODULE, `New Submission File saved successfully`);
+ return savedSubmissionFile.id;
+ } catch (error) {
+ logger.error(LOG_MODULE, `Failed saving Submission File`, error);
+ throw new ServiceUnavailable();
+ }
+ },
+
+ getById: async (fileId: number): Promise => {
+ try {
+ return await db.query.submissionFiles.findFirst({
+ where: eq(submissionFiles.id, fileId),
+ });
+ } catch (error) {
+ logger.error(LOG_MODULE, `Failed getting Submission File by id '${fileId}'`, error);
+ throw new ServiceUnavailable();
+ }
+ },
+
+ getBySubmissionId: async (submissionId: number): Promise => {
+ try {
+ return await db.query.submissionFiles.findMany({
+ where: eq(submissionFiles.submissionId, submissionId),
+ });
+ } catch (error) {
+ logger.error(LOG_MODULE, `Failed getting Submission Files by submissionId '${submissionId}'`, error);
+ throw new ServiceUnavailable();
+ }
+ },
+
+ deleteById: async (
+ fileId: number,
+ tx?: PgTransaction>,
+ ): Promise => {
+ try {
+ const deletedFiles = await (tx || db)
+ .delete(submissionFiles)
+ .where(eq(submissionFiles.id, fileId))
+ .returning({ id: submissionFiles.id });
+ logger.info(LOG_MODULE, `Deleted Submission Files with id '${fileId}'`);
+ return deletedFiles[0]?.id;
+ } catch (error) {
+ logger.error(LOG_MODULE, `Failed deleting Submission File by fileId '${fileId}'`, error);
+ throw new ServiceUnavailable();
+ }
+ },
+
+ /**
+ * Deletes the files associated with a specific submission ID.
+ * This function does not delete cascadeingly related submission records, it will throw an error if
+ * foreign key constraints are violated.
+ * @param submissionId
+ * @param tx
+ * @returns
+ */
+ deleteBySubmissionId: async (
+ submissionId: number,
+ tx?: PgTransaction>,
+ ): Promise => {
+ try {
+ const deletedFiles = await (tx || db)
+ .delete(submissionFiles)
+ .where(eq(submissionFiles.submissionId, submissionId))
+ .returning({ id: submissionFiles.id });
+ logger.info(LOG_MODULE, `Deleted '${deletedFiles.length}' Submission Files for submissionId '${submissionId}'`);
+ return deletedFiles.map((file) => file.id);
+ } catch (error) {
+ logger.error(LOG_MODULE, `Failed deleting Submission Files by submissionId '${submissionId}'`, error);
+ throw new ServiceUnavailable();
+ }
+ },
+ };
+};
+
+export default submissionFilesRepository;
diff --git a/packages/data-provider/src/repository/submissionRecordsRepository.ts b/packages/data-provider/src/repository/submissionRecordsRepository.ts
new file mode 100644
index 00000000..82c106a4
--- /dev/null
+++ b/packages/data-provider/src/repository/submissionRecordsRepository.ts
@@ -0,0 +1,361 @@
+import { type ExtractTablesWithRelations } from 'drizzle-orm';
+import type { PgTransaction } from 'drizzle-orm/pg-core';
+import type { PostgresJsQueryResultHKT } from 'drizzle-orm/postgres-js';
+import { and, count, eq, inArray } from 'drizzle-orm/sql';
+
+import {
+ type NewSubmissionRecord,
+ submissionFiles,
+ type SubmissionRecord,
+ type SubmissionRecordError,
+ submissionRecords,
+} from '@overture-stack/lyric-data-model/models';
+
+import { BaseDependencies } from '../config/config.js';
+import { ServiceUnavailable } from '../utils/errors.js';
+import type { PaginationOptions, SubmissionRecordActionType, SubmissionRecordState } from '../utils/types.js';
+
+// This is the information stored about each individual submission record in the database, including it's entity name.
+export type SubmissionRecordWithEntityName = SubmissionRecord & { entityName: string };
+
+// Raw data returned from the database
+export type RecordsSummaryRepository = {
+ actionType: SubmissionRecordActionType;
+ entityName: string;
+ totalRecords: number;
+ batchName?: string;
+ errors: number;
+};
+
+const submissionRecordsRepository = (dependencies: BaseDependencies) => {
+ const LOG_MODULE = 'SUBMISSION_RECORDS_REPOSITORY';
+ const { db, logger } = dependencies;
+
+ const getByFileIds = async (
+ fileIds: number[],
+ paginationOptions?: PaginationOptions,
+ filterOptions?: { actionTypes?: SubmissionRecordActionType[]; states?: SubmissionRecordState[] },
+ ): Promise => {
+ const query = db
+ .select({
+ id: submissionRecords.id,
+ actionType: submissionRecords.actionType,
+ state: submissionRecords.state,
+ fileId: submissionRecords.fileId,
+ data: submissionRecords.data,
+ errors: submissionRecords.errors,
+ entityName: submissionFiles.entityName,
+ })
+ .from(submissionRecords)
+ .innerJoin(submissionFiles, eq(submissionRecords.fileId, submissionFiles.id))
+ .where(
+ and(
+ inArray(submissionRecords.fileId, fileIds),
+ filterOptions?.actionTypes ? inArray(submissionRecords.actionType, filterOptions.actionTypes) : undefined,
+ filterOptions?.states?.length ? inArray(submissionRecords.state, filterOptions.states) : undefined,
+ ),
+ )
+ .orderBy(submissionRecords.id);
+
+ if (paginationOptions) {
+ query.limit(paginationOptions.pageSize).offset((paginationOptions.page - 1) * paginationOptions.pageSize);
+ }
+
+ return await query;
+ };
+
+ const saveMany = async (
+ inputs: NewSubmissionRecord[],
+ tx?: PgTransaction>,
+ ): Promise => {
+ if (!inputs.length) {
+ return [];
+ }
+ try {
+ // TODO: Insert in batches if inputs.length > 1000 to avoid exceeding the maximum number of parameters in a single query
+ const savedSubmissionRecords = await (tx || db)
+ .insert(submissionRecords)
+ .values(inputs)
+ .returning({ id: submissionRecords.id });
+ logger.info(LOG_MODULE, `Saved '${savedSubmissionRecords.length}' Submission Record records successfully`);
+ return savedSubmissionRecords.map((record) => record.id);
+ } catch (error) {
+ logger.error(LOG_MODULE, `Failed saving '${inputs.length}' Submission Record records`, error);
+ throw new ServiceUnavailable();
+ }
+ };
+
+ const deleteByFileIds = async (
+ fileIds: number[],
+ tx?: PgTransaction>,
+ ): Promise => {
+ if (!fileIds.length) {
+ return 0;
+ }
+ try {
+ const deletedRecords = await (tx || db)
+ .delete(submissionRecords)
+ .where(inArray(submissionRecords.fileId, fileIds))
+ .returning({ id: submissionRecords.id });
+ logger.info(LOG_MODULE, `Deleted '${deletedRecords.length}' Submission Record records by fileIds`);
+ return deletedRecords.length;
+ } catch (error) {
+ logger.error(LOG_MODULE, `Failed deleting Submission Records by fileIds`, error);
+ throw new ServiceUnavailable();
+ }
+ };
+
+ return {
+ saveMany,
+
+ saveManyForFile: async (
+ fileId: number,
+ records: Omit[],
+ tx?: PgTransaction>,
+ ): Promise => {
+ // TODO: Batch insert records
+ const inputs: NewSubmissionRecord[] = records.map((record) => ({ ...record, fileId }));
+ return await saveMany(inputs, tx);
+ },
+
+ getById: async (id: number): Promise => {
+ try {
+ const query = await db
+ .select({
+ id: submissionRecords.id,
+ actionType: submissionRecords.actionType,
+ state: submissionRecords.state,
+ fileId: submissionRecords.fileId,
+ data: submissionRecords.data,
+ errors: submissionRecords.errors,
+ entityName: submissionFiles.entityName,
+ })
+ .from(submissionRecords)
+ .innerJoin(submissionFiles, eq(submissionRecords.fileId, submissionFiles.id))
+ .where(eq(submissionRecords.id, id))
+ .limit(1);
+
+ if (query.length === 0) {
+ return undefined;
+ }
+ return query[0];
+ } catch (error) {
+ logger.error(LOG_MODULE, `Failed getting Submission Record by id '${id}'`, error);
+ throw new ServiceUnavailable();
+ }
+ },
+
+ getByFileIds,
+
+ getBySubmissionId: async (
+ submissionId: number,
+ paginationOptions?: PaginationOptions,
+ filterOptions?: {
+ actionTypes?: SubmissionRecordActionType[];
+ states?: SubmissionRecordState[];
+ entityNames?: string[];
+ fileId?: number;
+ },
+ ): Promise => {
+ try {
+ const submissionFileIds = await db
+ .select({ id: submissionFiles.id, entityName: submissionFiles.entityName })
+ .from(submissionFiles)
+ .where(
+ and(
+ eq(submissionFiles.submissionId, submissionId),
+ filterOptions?.entityNames?.length
+ ? inArray(submissionFiles.entityName, filterOptions.entityNames)
+ : undefined,
+ filterOptions?.fileId ? eq(submissionFiles.id, filterOptions.fileId) : undefined,
+ ),
+ );
+
+ if (submissionFileIds.length === 0) {
+ logger.info(
+ LOG_MODULE,
+ `No submission files found for submissionId '${submissionId}' with the provided filter options.`,
+ );
+ return [];
+ }
+
+ return await getByFileIds(
+ submissionFileIds.map((file) => file.id),
+ paginationOptions,
+ {
+ actionTypes: filterOptions?.actionTypes,
+ states: filterOptions?.states,
+ },
+ );
+ } catch (error) {
+ logger.error(LOG_MODULE, `Failed getting Submission Records by submissionId '${submissionId}'`, error);
+ throw new ServiceUnavailable();
+ }
+ },
+
+ getRecordsSummaryBySubmissionId: async (submissionId: number): Promise => {
+ try {
+ const submissionFileRecords = await db
+ .select({
+ actionType: submissionRecords.actionType,
+ batchName: submissionFiles.fileName,
+ entityName: submissionFiles.entityName,
+ totalRecords: count(),
+ errors: count(submissionRecords.errors),
+ })
+ .from(submissionRecords)
+ .innerJoin(submissionFiles, eq(submissionRecords.fileId, submissionFiles.id))
+ .where(eq(submissionFiles.submissionId, submissionId))
+ .groupBy(submissionRecords.actionType, submissionFiles.fileName, submissionFiles.entityName);
+
+ return submissionFileRecords;
+ } catch (error) {
+ logger.error(LOG_MODULE, `Failed getting Submission Records summary by submissionId '${submissionId}'`, error);
+ throw new ServiceUnavailable();
+ }
+ },
+
+ /**
+ * This function updates the validation state of submission records based on the provided parameters.
+ * It can update records to 'VALID', 'RECEIVED', or 'INVALID' states, and also set errors for invalid records.
+ * @param params
+ * @param tx
+ * @returns
+ */
+ updateValidationState: async (
+ params: {
+ validRecordIds?: number[];
+ receivedRecordIds?: number[];
+ invalidRecords?: Array<{ id: number; errors?: SubmissionRecordError[] }>;
+ },
+ tx?: PgTransaction>,
+ ): Promise => {
+ const executor = tx || db;
+
+ const validRecordIds = params.validRecordIds ?? [];
+ const receivedRecordIds = params.receivedRecordIds ?? [];
+ const invalidRecords = params.invalidRecords ?? [];
+ if (!validRecordIds.length && !receivedRecordIds.length && !invalidRecords.length) {
+ return [];
+ }
+
+ try {
+ const updatedIds: number[] = [];
+
+ if (validRecordIds.length) {
+ const validUpdates = await executor
+ .update(submissionRecords)
+ .set({ state: 'VALID', errors: null })
+ .where(inArray(submissionRecords.id, validRecordIds))
+ .returning({ id: submissionRecords.id });
+ updatedIds.push(...validUpdates.map((record) => record.id));
+ }
+
+ if (receivedRecordIds.length) {
+ const receivedUpdates = await executor
+ .update(submissionRecords)
+ .set({ state: 'RECEIVED', errors: null })
+ .where(inArray(submissionRecords.id, receivedRecordIds))
+ .returning({ id: submissionRecords.id });
+ updatedIds.push(...receivedUpdates.map((record) => record.id));
+ }
+
+ if (invalidRecords.length) {
+ const invalidUpdates = await Promise.all(
+ invalidRecords.map(async ({ id, errors }) => {
+ const [updatedRecord] = await executor
+ .update(submissionRecords)
+ .set({ state: 'INVALID', errors: errors ?? null })
+ .where(eq(submissionRecords.id, id))
+ .returning({ id: submissionRecords.id });
+ return updatedRecord?.id;
+ }),
+ );
+ updatedIds.push(...invalidUpdates.filter((id): id is number => id !== undefined));
+ }
+
+ logger.info(
+ LOG_MODULE,
+ `Updated Submission Record states: VALID='${validRecordIds.length}', RECEIVED='${receivedRecordIds.length}', INVALID='${invalidRecords.length}'`,
+ );
+ return [...new Set(updatedIds)];
+ } catch (error) {
+ logger.error(LOG_MODULE, `Failed updating Submission Record validation state`, error);
+ throw new ServiceUnavailable();
+ }
+ },
+
+ countBySubmissionId: async (
+ submissionId: number,
+ ): Promise> => {
+ try {
+ return await db
+ .select({ actionType: submissionRecords.actionType, total: count() })
+ .from(submissionRecords)
+ .innerJoin(submissionFiles, eq(submissionRecords.fileId, submissionFiles.id))
+ .where(eq(submissionFiles.submissionId, submissionId))
+ .groupBy(submissionRecords.actionType);
+ } catch (error) {
+ logger.error(
+ LOG_MODULE,
+ `Failed counting Submission Records by action for submissionId '${submissionId}'`,
+ error,
+ );
+ throw new ServiceUnavailable();
+ }
+ },
+
+ countInvalidBySubmissionId: async (
+ submissionId: number,
+ ): Promise> => {
+ try {
+ return await db
+ .select({ actionType: submissionRecords.actionType, total: count() })
+ .from(submissionRecords)
+ .innerJoin(submissionFiles, eq(submissionRecords.fileId, submissionFiles.id))
+ .where(and(eq(submissionFiles.submissionId, submissionId), eq(submissionRecords.state, 'INVALID')))
+ .groupBy(submissionRecords.actionType);
+ } catch (error) {
+ logger.error(
+ LOG_MODULE,
+ `Failed counting invalid Submission Records by action for submissionId '${submissionId}'`,
+ error,
+ );
+ throw new ServiceUnavailable();
+ }
+ },
+
+ deleteByIds: async (
+ ids: number[],
+ tx?: PgTransaction>,
+ ): Promise => {
+ try {
+ return await (tx || db).delete(submissionRecords).where(inArray(submissionRecords.id, ids));
+ } catch (error) {
+ logger.error(LOG_MODULE, `Failed deleting Submission Record by ids '${ids}'`, error);
+ throw new ServiceUnavailable();
+ }
+ },
+
+ deleteByFileIds,
+
+ deleteBySubmissionId: async (
+ submissionId: number,
+ tx?: PgTransaction>,
+ ): Promise => {
+ try {
+ const submissionFileIds = await (tx || db)
+ .select({ id: submissionFiles.id })
+ .from(submissionFiles)
+ .where(eq(submissionFiles.submissionId, submissionId));
+ const fileIds = submissionFileIds.map((file) => file.id);
+ return await deleteByFileIds(fileIds, tx);
+ } catch (error) {
+ logger.error(LOG_MODULE, `Failed deleting Submission Records by submissionId '${submissionId}'`, error);
+ throw new ServiceUnavailable();
+ }
+ },
+ };
+};
+
+export default submissionRecordsRepository;
diff --git a/packages/data-provider/src/routers/submissionRouter.ts b/packages/data-provider/src/routers/submissionRouter.ts
index 02ddcb86..a522d142 100644
--- a/packages/data-provider/src/routers/submissionRouter.ts
+++ b/packages/data-provider/src/routers/submissionRouter.ts
@@ -71,11 +71,11 @@ const router = ({
router.get('/:submissionId', submissionController.getSubmissionById);
- router.get('/:submissionId/details', submissionController.getSubmissionDetailsById);
-
router.delete('/:submissionId', submissionController.delete);
- router.delete('/:submissionId/:actionType', submissionController.deleteEntityName);
+ router.get('/:submissionId/data', submissionController.getSubmissionDetailsById);
+
+ router.delete('/:submissionId/data', submissionController.deleteByRecordIdOrFileId);
router.get('/category/:categoryId', submissionController.getSubmissionsByCategory);
diff --git a/packages/data-provider/src/services/submission/submissionProcessor.ts b/packages/data-provider/src/services/submission/submissionProcessor.ts
index e4ec10ba..b0ef49c0 100644
--- a/packages/data-provider/src/services/submission/submissionProcessor.ts
+++ b/packages/data-provider/src/services/submission/submissionProcessor.ts
@@ -1,4 +1,3 @@
-import bytes from 'bytes';
import * as _ from 'lodash-es';
import type { DataRecord, DictionaryValidationRecordErrorDetails, Schema } from '@overture-stack/lectern-client';
@@ -6,9 +5,7 @@ import type {
DataDiff,
NewSubmittedData,
SubmissionDeleteData,
- SubmissionErrors,
SubmissionInsertData,
- SubmissionRecordErrorDetails,
SubmissionUpdateData,
SubmittedData,
} from '@overture-stack/lyric-data-model/models';
@@ -17,27 +14,30 @@ import { BaseDependencies } from '../../config/config.js';
import createSubmissionRepository from '../../repository/activeSubmissionRepository.js';
import createCategoryRepository from '../../repository/categoryRepository.js';
import createDictionaryRepository from '../../repository/dictionaryRepository.js';
+import createSubmissionFilesRepository from '../../repository/submissionFilesRepository.js';
+import createSubmissionRecordsRepository from '../../repository/submissionRecordsRepository.js';
import createSubmittedDataRepository from '../../repository/submittedRepository.js';
import { getDictionarySchemaRelations, type SchemaChildNode } from '../../utils/dictionarySchemaRelations.js';
import { BadRequest } from '../../utils/errors.js';
+import { formatByteSize, genericSubmissionFileName, getSizeInBytes } from '../../utils/fileUtils.js';
import { convertRecordToString } from '../../utils/formatUtils.js';
import { parseRecordsToInsert } from '../../utils/recordsParser.js';
import {
+ extractRecordIdsFromSubmissionErrors,
extractSchemaDataFromMergedDataRecords,
type FileParseResult,
- filterDeletesFromUpdates,
filterRelationsForPrimaryIdUpdate,
- findEditSubmittedData,
findInvalidRecordErrorsBySchemaName,
+ findUpdateDeleteConflicts,
groupSchemaErrorsByEntity,
isSubmissionActive,
mapGroupedUpdateSubmissionData,
mergeAndReferenceEntityData,
- mergeDeleteRecords,
- mergeInsertsRecords,
+ mergeSubmissionErrors,
mergeUpdatesBySystemId,
parseToSchema,
segregateFieldChangeRecords,
+ type SubmissionErrors,
submissionInsertDataFromFiles,
validateSchemas,
} from '../../utils/submissionUtils.js';
@@ -57,7 +57,6 @@ import {
type ResultOnCommit,
type SchemasDictionary,
SUBMISSION_STATUS,
- type ValidateFilesParams,
} from '../../utils/types.js';
import createSubmittedDataRelationsSearch from '../submittedData/searchDataRelations.js';
@@ -68,6 +67,8 @@ const createSubmissionProcessor = (dependencies: BaseDependencies) => {
const submissionRepository = createSubmissionRepository(dependencies);
const submittedDataRepository = createSubmittedDataRepository(dependencies);
const submittedDataRelationsSearch = createSubmittedDataRelationsSearch(dependencies);
+ const submissionRecordsRepository = createSubmissionRecordsRepository(dependencies);
+ const submissionFilesRepository = createSubmissionFilesRepository(dependencies);
const { logger } = dependencies;
/**
@@ -223,7 +224,7 @@ const createSubmissionProcessor = (dependencies: BaseDependencies) => {
return Object.entries(idFieldChangeRecord).reduce<
Promise<{
- inserts: Record;
+ inserts: Record;
deletes: Record;
}>
>(
@@ -233,7 +234,7 @@ const createSubmissionProcessor = (dependencies: BaseDependencies) => {
// iterate each record on this entity
const result = await updRecord.reduce<
Promise<{
- inserts: DataRecord[];
+ inserts: SubmissionInsertData[];
deletes: SubmissionDeleteData[];
}>
>(
@@ -248,12 +249,11 @@ const createSubmissionProcessor = (dependencies: BaseDependencies) => {
const deleteRecord: SubmissionDeleteData = {
systemId: foundSubmittedData.systemId,
data: foundSubmittedData.data,
- entityName: foundSubmittedData.entityName,
isValid: foundSubmittedData.isValid,
organization: foundSubmittedData.organization,
};
- const insertDataRecord: DataRecord = { ...foundSubmittedData.data, ...u.new };
+ const insertDataRecord: SubmissionInsertData = { ...foundSubmittedData.data, ...u.new };
acc2.inserts.push(insertDataRecord);
acc2.deletes.push(deleteRecord);
@@ -263,7 +263,7 @@ const createSubmissionProcessor = (dependencies: BaseDependencies) => {
);
acc.deletes[entityName] = result.deletes;
- acc.inserts[entityName] = { batchName: entityName, records: result.inserts };
+ acc.inserts[entityName] = result.inserts;
return acc;
},
@@ -430,7 +430,7 @@ const createSubmissionProcessor = (dependencies: BaseDependencies) => {
// iterate if there are any record to be deleted
dataToValidate?.deletes?.forEach((item) => {
- const { data, entityName, isValid, organization, systemId } = item;
+ const { data, isValid, organization, systemId } = item;
deletesToProcess.push({
submissionId: submission.id,
@@ -441,7 +441,7 @@ const createSubmissionProcessor = (dependencies: BaseDependencies) => {
resultCommit.deletes.push({
data,
- entityName,
+ entityName: '', // TODO: need to fetch the entityName
isValid,
organization,
systemId,
@@ -503,10 +503,10 @@ const createSubmissionProcessor = (dependencies: BaseDependencies) => {
const performDataValidation = async (submissionId: number): Promise => {
const { getActiveDictionaryByCategory } = categoryRepository;
const { getSubmittedDataByCategoryIdAndOrganization } = submittedDataRepository;
- const { getSubmissionDetailsById } = submissionRepository;
+ const { getSubmissionById } = submissionRepository;
// Get Active Submission from database
- const activeSubmission = await getSubmissionDetailsById(submissionId);
+ const activeSubmission = await getSubmissionById(submissionId);
if (!activeSubmission) {
throw new Error(`Submission '${submissionId}' not found`);
@@ -523,10 +523,31 @@ const createSubmissionProcessor = (dependencies: BaseDependencies) => {
throw new BadRequest(`Dictionary in category '${activeSubmission.dictionaryCategory.id}' not found`);
}
+ const submissionRecords = await submissionRecordsRepository.getBySubmissionId(submissionId);
+
+ // Detect records where the same systemId has both an UPDATE and a DELETE staged, before
+ // running dictionary validation. Both sides of a conflict are rejected explicitly instead
+ // of letting one action silently win.
+ const conflictErrors = findUpdateDeleteConflicts(submissionRecords);
+ const conflictingRecordIds = extractRecordIdsFromSubmissionErrors(conflictErrors);
+
+ if (conflictingRecordIds.size > 0) {
+ logger.error(
+ LOG_MODULE,
+ `Detected '${conflictingRecordIds.size}' Submission Record(s) with conflicting UPDATE/DELETE actions on the same systemId in Submission '${submissionId}'`,
+ JSON.stringify(conflictErrors),
+ );
+ }
+
+ // Exclude conflicting records from validation; neither side of a conflict should be applied
+ const nonConflictingSubmissionRecords = conflictingRecordIds.size
+ ? submissionRecords.filter((record) => !conflictingRecordIds.has(record.id))
+ : submissionRecords;
+
// Merge Submitted Data with Active Submission keepping reference of each record ID
const dataMergedByEntityName = mergeAndReferenceEntityData({
submissionId,
- submissionData: activeSubmission.data,
+ submissionData: nonConflictingSubmissionRecords,
submittedData,
});
@@ -537,44 +558,12 @@ const createSubmissionProcessor = (dependencies: BaseDependencies) => {
const resultValidation = validateSchemas(currentDictionary, crossSchemasDataToValidate);
// Collect errors of the Active Submission
- const submissionSchemaErrors = groupSchemaErrorsByEntity({
+ const schemaValidationErrors = groupSchemaErrorsByEntity({
resultValidation,
dataValidated: dataMergedByEntityName,
});
- // Check for records to be updated that its systemId was not found in the Submitted Data collection.
- // Any error found will cause the submission to be marked as 'invalid'
- Object.entries(activeSubmission.data.updates ?? {}).forEach(([entityName, recordsToUpdate]) => {
- recordsToUpdate.forEach((submissionEditData, index) => {
- const found = findEditSubmittedData(entityName, submissionEditData.systemId, dataMergedByEntityName);
-
- if (found) {
- return;
- }
-
- logger.error(
- LOG_MODULE,
- `Record with systemId '${submissionEditData.systemId}' not found in entity '${entityName}'`,
- );
-
- if (!submissionSchemaErrors.updates) {
- submissionSchemaErrors.updates = {};
- }
-
- if (!submissionSchemaErrors.updates[entityName]) {
- submissionSchemaErrors.updates[entityName] = [];
- }
-
- const unrecodgnizedValueError: SubmissionRecordErrorDetails = {
- fieldName: 'systemId',
- fieldValue: submissionEditData.systemId,
- index,
- reason: 'UNRECOGNIZED_VALUE',
- };
-
- submissionSchemaErrors.updates[entityName].push(unrecodgnizedValueError);
- });
- });
+ const submissionSchemaErrors = mergeSubmissionErrors(conflictErrors, schemaValidationErrors);
if (_.isEmpty(submissionSchemaErrors)) {
logger.info(LOG_MODULE, `No error found on data submission`);
@@ -618,7 +607,7 @@ const createSubmissionProcessor = (dependencies: BaseDependencies) => {
},
): Promise => {
const { getDictionary } = dictionaryRepository;
- const { getSubmissionDetailsById, update } = submissionRepository;
+ const { getSubmissionById, update } = submissionRepository;
try {
// Parse file data
@@ -626,7 +615,7 @@ const createSubmissionProcessor = (dependencies: BaseDependencies) => {
const filesDataProcessed = await compareUpdatedData(recordsParsed, schema.name);
- const submission = await getSubmissionDetailsById(submissionId);
+ const submission = await getSubmissionById(submissionId);
if (!submission) {
throw new Error(`Submission '${submissionId}' not found`);
}
@@ -677,7 +666,7 @@ const createSubmissionProcessor = (dependencies: BaseDependencies) => {
// Aggegates all Update changes on Submission
// Note: We do not include records involving primary ID fields changes in here. We would rather do a DELETE and an INSERT
const updatedActiveSubmissionData: Record = mergeUpdatesBySystemId(
- submission.data.updates ?? {},
+ // formattedSubmissionRecordsToUpdate,
totalDependants,
nonIdFieldChangeRecord,
);
@@ -685,24 +674,69 @@ const createSubmissionProcessor = (dependencies: BaseDependencies) => {
// Creates insert and delete records based on primary ID field change records.
const additions = await handleIdFieldChanges(idFieldChangeRecord);
- // Merge Active Submission Inserts with Edit generated new Inserts
- const mergedInserts = mergeInsertsRecords(submission.data.inserts ?? {}, additions.inserts);
+ // Updating the Submission with the new data and 'VALIDATING' status before validation starts
+ await dependencies.db.transaction(async (tx) => {
+ await update(
+ submission.id,
+ {
+ updatedBy: username,
+ status: 'VALIDATING',
+ },
+ tx,
+ );
- // Merge Active Submission Deletes with Edit generated new Deletes
- const mergedDeletes = mergeDeleteRecords(submission.data.deletes ?? {}, additions.deletes);
+ const entityNames: Set = new Set([
+ ...Object.keys(additions.inserts),
+ ...Object.keys(additions.deletes),
+ ...Object.keys(updatedActiveSubmissionData),
+ ]);
+
+ for (const entityName of entityNames) {
+ const savedFileId = await submissionFilesRepository.save(
+ {
+ entityName: entityName,
+ fileName: genericSubmissionFileName(),
+ fileSize: getSizeInBytes(JSON.stringify(recordsParsed)),
+ submissionId: submission.id,
+ },
+ tx,
+ );
- // filter out delete records found on update records
- const filteredDeletes = filterDeletesFromUpdates(mergedDeletes, updatedActiveSubmissionData);
+ if (updatedActiveSubmissionData[entityName]) {
+ await submissionRecordsRepository.saveManyForFile(
+ savedFileId,
+ updatedActiveSubmissionData[entityName].map((record) => ({
+ actionType: 'UPDATE',
+ data: record,
+ state: 'RECEIVED',
+ })),
+ tx,
+ );
+ }
- // Updating the Submission with the new data and 'VALIDATING' status before validation starts
- await update(submission.id, {
- data: {
- inserts: mergedInserts,
- deletes: filteredDeletes,
- updates: updatedActiveSubmissionData,
- },
- updatedBy: username,
- status: 'VALIDATING',
+ if (additions.inserts[entityName]) {
+ await submissionRecordsRepository.saveManyForFile(
+ savedFileId,
+ additions.inserts[entityName].map((record) => ({
+ actionType: 'INSERT',
+ data: record,
+ state: 'RECEIVED',
+ })),
+ tx,
+ );
+ }
+ if (additions.deletes[entityName]) {
+ await submissionRecordsRepository.saveManyForFile(
+ savedFileId,
+ additions.deletes[entityName]?.map((record) => ({
+ actionType: 'DELETE',
+ data: record,
+ state: 'RECEIVED',
+ })),
+ tx,
+ );
+ }
+ }
});
// Perform Schema Data validation in a worker thread
@@ -739,35 +773,52 @@ const createSubmissionProcessor = (dependencies: BaseDependencies) => {
submissionId: number;
username: string;
}) => {
- const { getSubmissionDetailsById, update } = submissionRepository;
+ const { getSubmissionById, update } = submissionRepository;
try {
// Get Active Submission from database
- const activeSubmission = await getSubmissionDetailsById(submissionId);
+ const activeSubmission = await getSubmissionById(submissionId);
if (!activeSubmission) {
- throw new Error(`Submission '${activeSubmission}' not found`);
+ throw new Error(`Submission '${submissionId}' not found`);
}
if (!isSubmissionActive(activeSubmission.status)) {
throw new Error(`Submission '${activeSubmission.id}' is not active`);
}
- const insertRecords = parseRecordsToInsert(records, schemasDictionary);
-
- // Merge Active Submission insert records with incoming TSV file data processed
- const insertActiveSubmissionData = mergeInsertsRecords(activeSubmission.data.inserts ?? {}, insertRecords);
-
// Updating the Submission with the new data and 'VALIDATING' status before validation starts
await update(activeSubmission.id, {
- data: {
- inserts: insertActiveSubmissionData,
- deletes: activeSubmission.data.deletes,
- updates: activeSubmission.data.updates,
- },
updatedBy: username,
status: 'VALIDATING',
});
+ const insertRecords = parseRecordsToInsert(records, schemasDictionary);
+
+ await Promise.all(
+ Object.entries(insertRecords).map(([entityName, entityRecords]) =>
+ dependencies.db.transaction(async (tx) => {
+ const savedFileId = await submissionFilesRepository.save(
+ {
+ entityName,
+ fileName: genericSubmissionFileName(),
+ fileSize: getSizeInBytes(JSON.stringify(entityRecords)),
+ submissionId,
+ },
+ tx,
+ );
+ await submissionRecordsRepository.saveManyForFile(
+ savedFileId,
+ entityRecords.map((record) => ({
+ actionType: 'INSERT',
+ data: record,
+ state: 'RECEIVED',
+ })),
+ tx,
+ );
+ }),
+ ),
+ );
+
// Perform Schema Data validation in a worker thread
dependencies.workerPool.dataValidation({ submissionId: activeSubmission.id });
} catch (error) {
@@ -782,7 +833,9 @@ const createSubmissionProcessor = (dependencies: BaseDependencies) => {
/**
* Update Active Submission in database
- * Updates the status of the Submission to 'VALID' if there is no errors, otherwise updates it to 'INVALID'
+ * Updates the Submission status to 'VALID' if there is no errors, otherwise updates it to 'INVALID'
+ * Updates all the records of the submission with the validation state, marking records with errors as 'INVALID'
+ * and records without errors as 'VALID'
* @param {Object} input
* @param {number} input.dictionaryId The Dictionary ID of the Submission
* @param {number} input.idActiveSubmission ID of the Submission
@@ -795,43 +848,72 @@ const createSubmissionProcessor = (dependencies: BaseDependencies) => {
schemaErrors: SubmissionErrors;
}): Promise => {
const { dictionaryId, idActiveSubmission, schemaErrors } = input;
- const { update } = submissionRepository;
const newStatusSubmission =
Object.keys(schemaErrors).length > 0 ? SUBMISSION_STATUS.INVALID : SUBMISSION_STATUS.VALID;
- // Update with new data
- const updatedActiveSubmissionId = await update(idActiveSubmission, {
- status: newStatusSubmission,
- dictionaryId: dictionaryId,
- errors: schemaErrors,
+
+ await dependencies.db.transaction(async (tx) => {
+ // Update with new data
+ const updatedActiveSubmissionId = await submissionRepository.update(
+ idActiveSubmission,
+ {
+ status: newStatusSubmission,
+ dictionaryId: dictionaryId,
+ },
+ tx,
+ );
+
+ const invalidRecords = Object.values(schemaErrors).flatMap((entityErrors) =>
+ Object.values(entityErrors).flatMap((recordErrors) =>
+ recordErrors.map(({ recordId, errors }) => ({
+ id: recordId,
+ errors,
+ })),
+ ),
+ );
+
+ const submissionRecords = await submissionRecordsRepository.getBySubmissionId(idActiveSubmission);
+ const recordsWithoutError = submissionRecords
+ .filter((record) => !invalidRecords.some((invalidRecord) => invalidRecord.id === record.id))
+ .map((record) => record.id);
+
+ // Update records with validation state, marking records with errors as 'INVALID' and records without errors as 'VALID'
+ await submissionRecordsRepository.updateValidationState(
+ {
+ invalidRecords,
+ validRecordIds: recordsWithoutError,
+ },
+ tx,
+ );
+ logger.info(
+ LOG_MODULE,
+ `Updated Active submission '${updatedActiveSubmissionId}' with status '${newStatusSubmission}'`,
+ );
+ return updatedActiveSubmissionId;
});
- logger.info(
- LOG_MODULE,
- `Updated Active submission '${updatedActiveSubmissionId}' with status '${newStatusSubmission}'`,
- );
- return updatedActiveSubmissionId;
+ return 0;
};
- const logFileResults = (fileResults: FileParseResult[]) => {
- for (const result of fileResults) {
- if (result.status === 'error') {
- logger.error(LOG_MODULE, `Failed to parse file`, {
- fileName: result.fileName,
- entityName: result.entityName,
- error: result.streamError,
- });
- } else if (result.status === 'invalid') {
- // Log field names and line numbers only — not field values (OWASP A03).
- logger.warn(LOG_MODULE, `File parsed with schema validation issues`, {
- fileName: result.fileName,
- entityName: result.entityName,
- errorCount: result.parseErrors.length,
- issues: result.parseErrors.slice(0, 10).map((e) => ({
- line: e.recordIndex,
- fields: e.recordErrors.map((re) => re.fieldName),
- })),
- });
- }
+ const logFileResult = (result: FileParseResult) => {
+ if (result.status === 'error') {
+ logger.error(LOG_MODULE, `Failed to parse file`, {
+ fileName: result.fileName,
+ fileSize: formatByteSize(result.fileSize, 'MB', 2),
+ entityName: result.entityName,
+ error: result.streamError,
+ });
+ } else if (result.status === 'invalid') {
+ // Log field names and line numbers only — not field values (OWASP A03).
+ logger.warn(LOG_MODULE, `File parsed with schema validation issues`, {
+ fileName: result.fileName,
+ fileSize: formatByteSize(result.fileSize, 'MB', 2),
+ entityName: result.entityName,
+ errorCount: result.parseErrors.length,
+ issues: result.parseErrors.slice(0, 10).map((e) => ({
+ line: e.recordIndex,
+ fields: e.recordErrors.map((re) => re.fieldName),
+ })),
+ });
}
};
@@ -844,65 +926,72 @@ const createSubmissionProcessor = (dependencies: BaseDependencies) => {
*/
const addFilesToSubmissionAsync = async (
fileSchemaMap: FileSchemaMap,
- params: ValidateFilesParams,
+ submissionId: number,
+ username: string,
): Promise => {
- const fileSummaries = Object.entries(fileSchemaMap)
- .flatMap(([_, { files, schema }]) =>
- files.map(
- (file) => `'${file.originalname}' (${bytes.format(file.size, { decimalPlaces: 2 })}, entity: ${schema.name})`,
- ),
- )
- .join(', ');
- logger.info(LOG_MODULE, `Processing files: ${fileSummaries}`);
-
- const { categoryId, organization, username } = params;
- let fileResults: FileParseResult[] = [];
+ const fileResult: FileParseResult[] = [];
try {
- // Verify an active submission exists before doing any parsing work.
- const activeSubmission = await submissionRepository.getActiveSubmissionDetails({
- categoryId,
- username,
- organization,
- });
- if (!activeSubmission) {
- throw new BadRequest(`No active submission found for category '${categoryId}' organization '${organization}'`);
- }
+ await dependencies.db.transaction(async (tx) => {
+ // Updating the Submission with the new data and 'VALIDATING' status before validation starts
+ await submissionRepository.update(
+ submissionId,
+ {
+ updatedBy: username,
+ status: 'VALIDATING',
+ },
+ tx,
+ );
- // Parse file data — each file is isolated; a failure on one does not block others.
- const { data: filesDataProcessed, fileResults: parsed } = await submissionInsertDataFromFiles(fileSchemaMap);
- fileResults = parsed;
- logFileResults(fileResults);
+ // Parse file data — each file is isolated; a failure on one does not block others.
+ const parsingFileDataResult = await submissionInsertDataFromFiles(fileSchemaMap);
- // Merge Active Submission data with incoming TSV file data processed
- const insertActiveSubmissionData = mergeInsertsRecords(activeSubmission.data.inserts ?? {}, filesDataProcessed);
+ for (const fileProcessed of parsingFileDataResult) {
+ logFileResult(fileProcessed.fileResult);
+ const {
+ data,
+ fileResult: { entityName, fileName, fileSize, status },
+ } = fileProcessed;
+ fileResult.push(fileProcessed.fileResult);
- // Updating the Submission with the new data and 'VALIDATING' status before validation starts
- await submissionRepository.update(activeSubmission.id, {
- data: {
- inserts: insertActiveSubmissionData,
- deletes: activeSubmission.data.deletes,
- updates: activeSubmission.data.updates,
- },
- updatedBy: username,
- status: 'VALIDATING',
+ if (status === 'ok') {
+ const fileId = await submissionFilesRepository.save(
+ {
+ entityName,
+ fileName,
+ fileSize,
+ submissionId,
+ },
+ tx,
+ );
+
+ await submissionRecordsRepository.saveManyForFile(
+ fileId,
+ data.map((record) => ({
+ actionType: 'INSERT',
+ data: record,
+ state: 'RECEIVED',
+ })),
+ tx,
+ );
+ }
+ }
});
// Perform Schema Data validation in a worker thread
- dependencies.workerPool.dataValidation({ submissionId: activeSubmission.id });
+ dependencies.workerPool.dataValidation({ submissionId });
} catch (error) {
logger.error(LOG_MODULE, `Error processing submitted files`, {
- files: fileSummaries,
error: error instanceof Error ? error.message : String(error),
errorType: error instanceof Error ? error.name : 'unknown',
});
}
logger.info(
LOG_MODULE,
- `Finished addFilesToSubmissionAsync for active submission in category "${params.categoryId}" for organization "${params.organization}" submitted by user "${params.username}"`,
+ `Finished addFilesToSubmissionAsync for active submission with ID "${submissionId}" submitted by user "${username}"`,
);
- return fileResults;
+ return fileResult;
};
return {
diff --git a/packages/data-provider/src/services/submission/submissionService.ts b/packages/data-provider/src/services/submission/submissionService.ts
index d453b0d1..8986e987 100644
--- a/packages/data-provider/src/services/submission/submissionService.ts
+++ b/packages/data-provider/src/services/submission/submissionService.ts
@@ -1,22 +1,25 @@
import * as _ from 'lodash-es';
import { Dictionary as SchemasDictionary } from '@overture-stack/lectern-client';
-import { type NewSubmission, type SubmissionRecordErrorDetails } from '@overture-stack/lyric-data-model/models';
+import { type NewSubmission } from '@overture-stack/lyric-data-model/models';
import { BaseDependencies } from '../../config/config.js';
import createSubmissionRepository from '../../repository/activeSubmissionRepository.js';
import createCategoryRepository from '../../repository/categoryRepository.js';
+import createDictionaryRepository from '../../repository/dictionaryRepository.js';
+import createSubmissionFilesRepository from '../../repository/submissionFilesRepository.js';
+import createSubmissionRecordsRepository, {
+ type SubmissionRecordWithEntityName,
+} from '../../repository/submissionRecordsRepository.js';
import { getSchemaByName } from '../../utils/dictionaryUtils.js';
import { BadRequest, InternalServerError, StatusConflict } from '../../utils/errors.js';
import type { PaginatedResult } from '../../utils/result.js';
import type { FilenameEntityPair } from '../../utils/schemas.js';
-import { filterAndPaginateSubmissionData, type FlattenedSubmissionData } from '../../utils/submissionResponseParser.js';
+import { buildDataSummary, createSubmissionSummaryResponse } from '../../utils/submissionResponseParser.js';
import {
checkEntityFieldNames,
- createSubmissionSummaryResponse,
type FileParseResult,
isSubmissionActive,
- removeItemsFromSubmission,
resolveFileEntities,
} from '../../utils/submissionUtils.js';
import {
@@ -25,10 +28,9 @@ import {
type DeleteSubmissionResult,
type EntityData,
type PaginationOptions,
- SUBMISSION_ACTION_TYPE,
SUBMISSION_STATUS,
- type SubmissionActionType,
- SubmissionSummary,
+ type SubmissionRecordActionType,
+ type SubmissionSummaryResponse,
type SubmitDataResult,
type SubmitFileResult,
} from '../../utils/types.js';
@@ -43,6 +45,9 @@ const submissionService = (dependencies: BaseDependencies) => {
const categoryRepository = createCategoryRepository(dependencies);
const submissionProcessor = submissionProcessorFactory.create(dependencies);
const submissionRepository = createSubmissionRepository(dependencies);
+ const submissionRecordsRepository = createSubmissionRecordsRepository(dependencies);
+ const dictionaryRepository = createDictionaryRepository(dependencies);
+ const submissionFilesRepository = createSubmissionFilesRepository(dependencies);
/**
* Runs Schema validation asynchronously in a worker thread and moves the Active Submission to Submitted Data
@@ -84,11 +89,8 @@ const submissionService = (dependencies: BaseDependencies) => {
await submissionRepository.update(submissionId, { status: SUBMISSION_STATUS.COMMITTING, updatedBy: username });
// Get entities to process
- const entitiesToProcess = new Set([
- ...Object.keys(submission.data?.inserts ?? {}),
- ...Object.keys(submission.data?.updates ?? {}),
- ...Object.keys(submission.data?.deletes ?? {}),
- ]);
+ const filesOnSubmission = await submissionFilesRepository.getBySubmissionId(submissionId);
+ const entitiesToProcess = new Set(filesOnSubmission.map((file) => file.entityName));
// Execute commit submission in worker pool
const commitData: CommitWorkerInput = {
@@ -147,24 +149,28 @@ const submissionService = (dependencies: BaseDependencies) => {
};
/**
- * Function to remove an entity from an Active Submission by given Submission ID
+ * Function to remove specific records from an Active Submission
+ * If fileID is provided, all records associated with that file will be removed from the Submission
+ * If recordID is provided, only that specific record will be removed from the Submission
+ * If both fileID and recordID are provided, only the recordID will be removed from the Submission
+ * If neither fileID nor recordID are provided, an error will be thrown
+ * The function will check if the Submission is Active and if the record or file belongs to the Submission
* It validates resulting Active Submission running cross schema validation along with the existing Submitted Data
* Returns the resulting ID of the Active Submission
- * @param {number} submissionId
- * @param {string} entityName
- * @param {string} username
+ * @param {number} submissionId - Submission ID
+ * @param {string} username - User name performing the action
+ * @param {object} filter - Filter to identify the entity to be removed
* @returns { Promise}
*/
- const deleteActiveSubmissionEntity = async (
+ const deleteByRecordIdOrFileId = async (
submissionId: number,
username: string,
filter: {
- actionType: SubmissionActionType;
- entityName: string;
- index: number | null;
+ recordId: number | null;
+ fileId: number | null;
},
): Promise => {
- const submission = await submissionRepository.getSubmissionDetailsById(submissionId);
+ const submission = await submissionRepository.getSubmissionById(submissionId);
if (!submission) {
throw new BadRequest(`Submission '${submissionId}' not found`);
}
@@ -173,35 +179,43 @@ const submissionService = (dependencies: BaseDependencies) => {
throw new StatusConflict('Submission is not active. Only Active Submission can be modified');
}
- if (
- SUBMISSION_ACTION_TYPE.Values.INSERTS.includes(filter.actionType) &&
- !_.has(submission.data.inserts, filter.entityName)
- ) {
- throw new BadRequest(`Entity '${filter.entityName}' not found on '${filter.actionType}' Submission`);
- }
+ const filesOnSubmission = await submissionFilesRepository.getBySubmissionId(submissionId);
- if (
- SUBMISSION_ACTION_TYPE.Values.UPDATES.includes(filter.actionType) &&
- !_.has(submission.data.updates, filter.entityName)
- ) {
- throw new BadRequest(`Entity '${filter.entityName}' not found on '${filter.actionType}' Submission`);
+ if (filesOnSubmission.length === 0) {
+ throw new BadRequest(`Submission '${submissionId}' has no records or files to delete`);
}
- if (
- SUBMISSION_ACTION_TYPE.Values.DELETES.includes(filter.actionType) &&
- !_.has(submission.data.deletes, filter.entityName)
- ) {
- throw new BadRequest(`Entity '${filter.entityName}' not found on '${filter.actionType}' Submission`);
- }
+ // Remove record by ID from the Submission
+ if (filter.recordId) {
+ const recordFoundInDB = await submissionRecordsRepository.getById(filter.recordId);
+ if (!recordFoundInDB) {
+ throw new BadRequest(`Record with ID '${filter.recordId}' not found in Submission '${submissionId}'`);
+ }
- // Remove entity from the Submission
- const updatedActiveSubmissionData = removeItemsFromSubmission(submission.data, {
- ...filter,
- });
+ const fileReference = filesOnSubmission.find((file) => file.id === recordFoundInDB.fileId);
+ if (fileReference?.submissionId !== submissionId) {
+ throw new BadRequest(`Record with ID '${filter.recordId}' does not belong to Submission '${submissionId}'`);
+ }
+
+ await submissionRecordsRepository.deleteByIds([filter.recordId]);
+ } else if (filter.fileId != null) {
+ const fileId = filter.fileId;
+ // Verify the requested FileId belongs to the Submission before deleting
+ const fileReference = filesOnSubmission.find((f) => f.id === fileId);
+ if (!fileReference) {
+ throw new BadRequest(`File with ID '${fileId}' not found in Submission '${submissionId}'`);
+ }
+
+ await dependencies.db.transaction(async (tx) => {
+ await submissionRecordsRepository.deleteByFileIds([fileId], tx);
+ await submissionFilesRepository.deleteById(fileId, tx);
+ });
+ } else {
+ throw new BadRequest('Either recordId or fileId must be provided to delete a record or file from the Submission');
+ }
// Updating the Submission with the new data and 'VALIDATING' status before validation starts
await submissionRepository.update(submission.id, {
- data: updatedActiveSubmissionData,
updatedBy: username,
status: 'VALIDATING',
});
@@ -209,7 +223,10 @@ const submissionService = (dependencies: BaseDependencies) => {
// Perform Schema Data validation in a worker thread
dependencies.workerPool.dataValidation({ submissionId: submission.id });
- logger.info(LOG_MODULE, `Submission '${submission.id}' updated after removing entity '${filter.entityName}'`);
+ logger.info(
+ LOG_MODULE,
+ `Submission '${submission.id}' updated after removing entity with recordId '${filter.recordId}' and fileId '${filter.fileId}'`,
+ );
return {
status: ACTIVE_SUBMISSION_STATUS.PROCESSING,
@@ -238,7 +255,7 @@ const submissionService = (dependencies: BaseDependencies) => {
username?: string;
organization?: string;
},
- ): Promise> => {
+ ): Promise> => {
const recordsPaginated = await submissionRepository.getSubmissionsByCategory(
categoryId,
paginationOptions,
@@ -253,17 +270,30 @@ const submissionService = (dependencies: BaseDependencies) => {
};
}
- const totalRecords = await submissionRepository.getTotalSubmissionsByCategory(categoryId, filterOptions);
+ const totalSubmissions = await submissionRepository.getTotalSubmissionsByCategory(categoryId, filterOptions);
+ const result = await Promise.all(
+ recordsPaginated.map(async (response) => {
+ const submissionRecordsSummary = await submissionRecordsRepository.getRecordsSummaryBySubmissionId(response.id);
+ const formattedDataSummary = buildDataSummary(submissionRecordsSummary);
+
+ return createSubmissionSummaryResponse({
+ ...response,
+ data: formattedDataSummary,
+ });
+ }),
+ );
+
return {
metadata: {
- totalRecords,
+ totalRecords: totalSubmissions,
},
- result: recordsPaginated.map((response) => createSubmissionSummaryResponse(response)),
+ result,
};
};
/**
* Get Submission by Submission ID
+ * Returns the submission general information and includes the summary of the data and errors
* @param {number} submissionId A Submission ID
* @returns One Submission
*/
@@ -273,7 +303,13 @@ const submissionService = (dependencies: BaseDependencies) => {
return;
}
- return createSubmissionSummaryResponse(submission);
+ const submissionDataSummary = await submissionRecordsRepository.getRecordsSummaryBySubmissionId(submissionId);
+ const formattedDataSummary = buildDataSummary(submissionDataSummary);
+
+ return createSubmissionSummaryResponse({
+ ...submission,
+ data: formattedDataSummary,
+ });
};
/**
@@ -294,20 +330,31 @@ const submissionService = (dependencies: BaseDependencies) => {
}: {
submissionId: number;
paginationOptions: PaginationOptions;
- filterOptions: { entityNames: string[]; actionTypes: SubmissionActionType[] };
- }): Promise<{ data: FlattenedSubmissionData[]; errors?: SubmissionRecordErrorDetails[] }> => {
- const submission = await submissionRepository.getSubmissionDetailsById(submissionId);
+ filterOptions: { entityNames: string[]; actionTypes: SubmissionRecordActionType[]; fileId?: number };
+ }): Promise => {
+ const submission = await submissionRepository.getSubmissionById(submissionId);
if (!submission) {
throw new BadRequest(`Submission '${submissionId}' not found`);
}
- const submissionEntityNames = [
- ...Object.keys(submission.data.inserts ?? {}),
- ...Object.keys(submission.data.updates ?? {}),
- ...Object.keys(submission.data.deletes ?? {}),
- ];
+ const dictionary = await dictionaryRepository.getDictionary(
+ submission.dictionary.name,
+ submission.dictionary.version,
+ );
+
+ if (!dictionary) {
+ throw new InternalServerError(
+ `Dictionary '${submission.dictionary.name}' version '${submission.dictionary.version}' not found`,
+ );
+ }
+
+ const schemasDictionary: SchemasDictionary = {
+ name: dictionary.name,
+ version: dictionary.version,
+ schemas: dictionary.dictionary,
+ };
- const missingEntityNames = filterOptions.entityNames.filter((name) => !submissionEntityNames.includes(name));
+ const missingEntityNames = filterOptions.entityNames.filter((name) => !getSchemaByName(name, schemasDictionary));
if (filterOptions.entityNames.length > 0 && missingEntityNames.length > 0) {
throw new BadRequest(
@@ -315,12 +362,13 @@ const submissionService = (dependencies: BaseDependencies) => {
);
}
- return filterAndPaginateSubmissionData({
- data: submission.data,
- errors: submission.errors || {},
- filterOptions,
+ const submissionRecords = await submissionRecordsRepository.getBySubmissionId(
+ submissionId,
paginationOptions,
- });
+ filterOptions,
+ );
+
+ return submissionRecords;
};
/**
@@ -339,8 +387,8 @@ const submissionService = (dependencies: BaseDependencies) => {
categoryId: number;
username: string;
organization: string;
- }): Promise => {
- const submission = await submissionRepository.getActiveSubmissionSummary({
+ }): Promise => {
+ const submission = await submissionRepository.getActiveSubmission({
organization,
username,
categoryId,
@@ -349,7 +397,13 @@ const submissionService = (dependencies: BaseDependencies) => {
return;
}
- return createSubmissionSummaryResponse(submission);
+ const submissionDataSummary = await submissionRecordsRepository.getRecordsSummaryBySubmissionId(submission.id);
+ const formattedDataSummary = buildDataSummary(submissionDataSummary);
+
+ return createSubmissionSummaryResponse({
+ ...submission,
+ data: formattedDataSummary,
+ });
};
/**
@@ -369,7 +423,7 @@ const submissionService = (dependencies: BaseDependencies) => {
const { categoryId, username, organization } = params;
const { getActiveDictionaryByCategory } = categoryRepository;
- const activeSubmission = await submissionRepository.getActiveSubmissionSummary({
+ const activeSubmission = await submissionRepository.getActiveSubmission({
categoryId,
username,
organization,
@@ -390,10 +444,8 @@ const submissionService = (dependencies: BaseDependencies) => {
const newSubmissionInput: NewSubmission = {
createdBy: username,
- data: {},
dictionaryCategoryId: categoryId,
dictionaryId: currentDictionary.id,
- errors: {},
organization: organization,
status: SUBMISSION_STATUS.OPEN,
};
@@ -587,11 +639,7 @@ const submissionService = (dependencies: BaseDependencies) => {
// Parsing always starts immediately. When sync=true (default) the response waits for results;
// when sync=false it runs in the background and fileResults will be empty in the response.
// Schema validation always runs in a background worker thread regardless of this flag.
- const parsePromise = submissionProcessor.addFilesToSubmissionAsync(checkedEntities, {
- categoryId,
- organization,
- username,
- });
+ const parsePromise = submissionProcessor.addFilesToSubmissionAsync(checkedEntities, activeSubmissionId, username);
const fileResults: FileParseResult[] = sync ? await parsePromise : [];
if (batchErrors.length === 0) {
@@ -618,7 +666,7 @@ const submissionService = (dependencies: BaseDependencies) => {
return {
commitSubmission,
deleteActiveSubmissionById,
- deleteActiveSubmissionEntity,
+ deleteByRecordIdOrFileId,
getSubmissionsByCategory,
getSubmissionById,
getSubmissionDetailsById,
diff --git a/packages/data-provider/src/services/submittedData/submmittedData.ts b/packages/data-provider/src/services/submittedData/submmittedData.ts
index fa95240a..c2bcadd8 100644
--- a/packages/data-provider/src/services/submittedData/submmittedData.ts
+++ b/packages/data-provider/src/services/submittedData/submmittedData.ts
@@ -6,12 +6,15 @@ import { SQON } from '@overture-stack/sqon-builder';
import { BaseDependencies } from '../../config/config.js';
import submissionRepository from '../../repository/activeSubmissionRepository.js';
import categoryRepository from '../../repository/categoryRepository.js';
+import createSubmissionFilesRepository from '../../repository/submissionFilesRepository.js';
+import createSubmissionRecordsRepository from '../../repository/submissionRecordsRepository.js';
import submittedRepository from '../../repository/submittedRepository.js';
import { convertSqonToQuery } from '../../utils/convertSqonToQuery.js';
import { getDictionarySchemaRelations } from '../../utils/dictionarySchemaRelations.js';
import { InternalServerError, StatusConflict } from '../../utils/errors.js';
+import { genericSubmissionFileName, getSizeInBytes } from '../../utils/fileUtils.js';
import type { PaginatedResult } from '../../utils/result.js';
-import { filterUpdatesFromDeletes, mergeDeleteRecords } from '../../utils/submissionUtils.js';
+import { resolveDeleteStagingConflicts } from '../../utils/submissionUtils.js';
import {
fetchDataErrorResponse,
getEntityNamesFromFilterOptions,
@@ -39,6 +42,8 @@ const submittedData = (dependencies: BaseDependencies) => {
const LOG_MODULE = 'SUBMITTED_DATA_SERVICE';
const submittedDataRepo = submittedRepository(dependencies);
const submissionProcessor = submissionProcessorFactory.create(dependencies);
+ const submissionRecordsRepository = createSubmissionRecordsRepository(dependencies);
+ const submissionFilesRepository = createSubmissionFilesRepository(dependencies);
const { logger } = dependencies;
const { convertRecordsToCompoundDocuments } = viewMode(dependencies);
@@ -56,7 +61,7 @@ const submittedData = (dependencies: BaseDependencies) => {
}> => {
const { getSubmittedDataBySystemId } = submittedDataRepo;
const { getActiveDictionaryByCategory } = categoryRepository(dependencies);
- const { getSubmissionDetailsById, update } = submissionRepository(dependencies);
+ const { update: udpateSubmission } = submissionRepository(dependencies);
const { getOrCreateActiveSubmission } = submissionService(dependencies);
// get SubmittedData by SystemId
@@ -125,44 +130,96 @@ const submittedData = (dependencies: BaseDependencies) => {
throw error;
}
- const activeSubmission = await getSubmissionDetailsById(activeSubmissionId);
+ // Check what the Active Submission already has pending for these systemIds before staging
+ // anything new: a pending UPDATE is a conflict (reject, consistent with how the same
+ // conflict is handled at validation time), a pending DELETE is a duplicate (skip it).
+ const existingSubmissionRecords = await submissionRecordsRepository.getBySubmissionId(
+ activeSubmissionId,
+ undefined,
+ {
+ actionTypes: ['UPDATE', 'DELETE'],
+ },
+ );
+
+ const { filteredRecordsToDeleteMap, conflictingSystemIds, duplicateSystemIds } = resolveDeleteStagingConflicts(
+ recordsToDeleteMap,
+ existingSubmissionRecords,
+ );
- if (!activeSubmission) {
+ if (conflictingSystemIds.length > 0) {
+ logger.error(
+ LOG_MODULE,
+ `Cannot delete system ID(s) '${conflictingSystemIds.join(', ')}' on Submission '${activeSubmissionId}': a pending update already exists for the same system ID`,
+ );
return {
status: ACTIVE_SUBMISSION_STATUS.INVALID_SUBMISSION,
- description: 'Active Submission not found',
+ description: `System ID(s) '${conflictingSystemIds.join(', ')}' already have a pending update staged on Submission '${activeSubmissionId}'. Resolve the conflicting update before deleting.`,
inProcessEntities: [],
};
}
- // Merge current Active Submission delete entities with unique records to delete based on systemId
- const mergedSubmissionDeletes = mergeDeleteRecords(activeSubmission.data.deletes || {}, recordsToDeleteMap);
+ if (duplicateSystemIds.length > 0) {
+ logger.info(
+ LOG_MODULE,
+ `System ID(s) '${duplicateSystemIds.join(', ')}' are already staged for deletion on Submission '${activeSubmissionId}', skipping duplicate`,
+ );
+ }
+
+ const entitiesToProcess = Object.keys(filteredRecordsToDeleteMap);
- const entitiesToProcess = Object.keys(mergedSubmissionDeletes);
+ if (entitiesToProcess.length === 0) {
+ return {
+ status: ACTIVE_SUBMISSION_STATUS.PROCESSING,
+ description: 'All requested records are already staged for deletion on the Active Submission',
+ submissionId: activeSubmissionId.toString(),
+ inProcessEntities: [],
+ };
+ }
- // filter out update records found matching systemID on delete records
- const filteredUpdates = filterUpdatesFromDeletes(activeSubmission.data.updates ?? {}, mergedSubmissionDeletes);
+ await dependencies.db.transaction(async (tx) => {
+ // Updating the Submission with the new data and 'VALIDATING' status before validation starts
+ await udpateSubmission(
+ activeSubmissionId,
+ {
+ updatedBy: username,
+ status: 'VALIDATING',
+ },
+ tx,
+ );
- // Updating the Submission with the new data and 'VALIDATING' status before validation starts
- await update(activeSubmission.id, {
- data: {
- inserts: activeSubmission.data.inserts,
- updates: filteredUpdates,
- deletes: mergedSubmissionDeletes,
- },
- updatedBy: username,
- status: 'VALIDATING',
+ await Promise.all(
+ Object.entries(filteredRecordsToDeleteMap).map(async ([entityName, entityRecords]) => {
+ const savedFileId = await submissionFilesRepository.save(
+ {
+ entityName,
+ fileName: genericSubmissionFileName(),
+ fileSize: getSizeInBytes(JSON.stringify(entityRecords)),
+ submissionId: activeSubmissionId,
+ },
+ tx,
+ );
+ await submissionRecordsRepository.saveManyForFile(
+ savedFileId,
+ entityRecords.map((record) => ({
+ actionType: 'DELETE',
+ data: record,
+ state: 'RECEIVED',
+ })),
+ tx,
+ );
+ }),
+ );
});
// Perform Schema Data validation in a worker thread
- dependencies.workerPool.dataValidation({ submissionId: activeSubmission.id });
+ dependencies.workerPool.dataValidation({ submissionId: activeSubmissionId });
logger.info(LOG_MODULE, `Added '${entitiesToProcess.length}' records to be deleted on the Active Submission`);
return {
status: ACTIVE_SUBMISSION_STATUS.PROCESSING,
description: 'Submission data is being processed',
- submissionId: activeSubmission.id.toString(),
+ submissionId: activeSubmissionId.toString(),
inProcessEntities: entitiesToProcess,
};
};
diff --git a/packages/data-provider/src/utils/auditUtils.ts b/packages/data-provider/src/utils/auditUtils.ts
index aaad3324..c69f50d4 100644
--- a/packages/data-provider/src/utils/auditUtils.ts
+++ b/packages/data-provider/src/utils/auditUtils.ts
@@ -5,7 +5,7 @@ import {
AuditAction,
AuditDataResponse,
AuditRepositoryRecord,
- SUBMISSION_ACTION_TYPE,
+ SUBMISSION_RECORD_ACTION_TYPE,
} from './types.js';
/**
@@ -18,7 +18,7 @@ export const isAuditEventValid = (value: unknown): boolean =>
typeof value === 'string' && AUDIT_ACTION.safeParse(value.toUpperCase()).success;
export const isSubmissionActionTypeValid = (value: unknown): boolean =>
- typeof value === 'string' && SUBMISSION_ACTION_TYPE.safeParse(value.toUpperCase()).success;
+ typeof value === 'string' && SUBMISSION_RECORD_ACTION_TYPE.safeParse(value.toUpperCase()).success;
/**
* Convert a value string into it's Audit event type if it matches.
diff --git a/packages/data-provider/src/utils/fileUtils.ts b/packages/data-provider/src/utils/fileUtils.ts
index 7998d170..9e46826f 100644
--- a/packages/data-provider/src/utils/fileUtils.ts
+++ b/packages/data-provider/src/utils/fileUtils.ts
@@ -13,7 +13,7 @@ import {
} from '@overture-stack/lectern-client';
import { getSubmittedFileType } from '../services/submission/submissionFile.js';
-import { failure, success, type Result } from './result.js';
+import { failure, type Result, success } from './result.js';
import { BATCH_ERROR_TYPE, type BatchError } from './types.js';
export const SUPPORTED_FILE_EXTENSIONS = z.enum(['tsv', 'csv']);
@@ -125,6 +125,13 @@ function formatForExcelCompatibility(data: string) {
.trim();
}
+/**
+ * Generates a generic file name for submission files with `.json` extension.
+ * Based on the current date and time in ISO format.
+ * @returns
+ */
+export const genericSubmissionFileName = () => `submission-${new Date().toISOString()}.json`;
+
export function getSizeInBytes(size: string | number): number {
// Parse the string value into an integer in bytes.
// If value is a number it is assumed is in bytes.
diff --git a/packages/data-provider/src/utils/recordsParser.ts b/packages/data-provider/src/utils/recordsParser.ts
index 295fed97..5317cb2d 100644
--- a/packages/data-provider/src/utils/recordsParser.ts
+++ b/packages/data-provider/src/utils/recordsParser.ts
@@ -1,9 +1,7 @@
import { type DataRecord, parse, type Schema } from '@overture-stack/lectern-client';
-import type { SubmissionInsertData } from '@overture-stack/lyric-data-model/models';
import { getSchemaByName } from './dictionaryUtils.js';
import { convertRecordToString, notEmpty } from './formatUtils.js';
-import { createBatchResponse } from './submissionResponseParser.js';
import type { EntityData, SchemasDictionary } from './types.js';
/**
@@ -37,7 +35,7 @@ export const convertToTypedRecords = (dataRecords: Record[], sc
export const parseRecordsToInsert = (
records: EntityData,
schemasDictionary: SchemasDictionary,
-): Record => {
+): Record => {
return Object.fromEntries(
Object.entries(records)
.map(([schemaName, dataRecords]) => {
@@ -53,7 +51,7 @@ export const parseRecordsToInsert = (
return null;
}
- return [schemaName, createBatchResponse(schemaName, parsedRecords)];
+ return [schemaName, parsedRecords];
})
.filter(notEmpty),
);
diff --git a/packages/data-provider/src/utils/schemas.ts b/packages/data-provider/src/utils/schemas.ts
index 3a7c2b02..66edfb49 100644
--- a/packages/data-provider/src/utils/schemas.ts
+++ b/packages/data-provider/src/utils/schemas.ts
@@ -35,7 +35,10 @@ const categoryIdSchema = zod
const categoryAliasSchema = zod
.string()
.trim()
- .refine((value) => value === '' || isValidCategoryAlias(value), 'alias must contain only letters, numbers, hyphens, and underscores');
+ .refine(
+ (value) => value === '' || isValidCategoryAlias(value),
+ 'alias must contain only letters, numbers, hyphens, and underscores',
+ );
const endDateSchema = zod
.string()
@@ -67,17 +70,6 @@ const pageSizeSchema = zod.string().superRefine((value, ctx) => {
}
});
-const indexIntegerSchema = zod.string().superRefine((value, ctx) => {
- const parsed = parseInt(value);
- if (isNaN(parsed)) {
- ctx.addIssue({
- code: zod.ZodIssueCode.invalid_type,
- expected: 'number',
- received: 'nan',
- });
- }
-});
-
const positiveInteger = zod.string().superRefine((value, ctx) => {
const parsed = parseInt(value);
if (isNaN(parsed)) {
@@ -342,6 +334,7 @@ export const submissionByIdRequestSchema: RequestValidation