Skip to content

feat: store source_artefact_id and add flat file upload to /v1/publication (#797) - #833

Merged
junaidiqbalmoj merged 18 commits into
masterfrom
feature/797-store-source-artefact-id
Jul 16, 2026
Merged

feat: store source_artefact_id and add flat file upload to /v1/publication (#797)#833
junaidiqbalmoj merged 18 commits into
masterfrom
feature/797-store-source-artefact-id

Conversation

@junaidiqbalmoj

@junaidiqbalmoj junaidiqbalmoj commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Replaces file_extension column on artefact table with source_artefact_id, storing the original uploaded file name end-to-end (manual upload, non-strategic upload, and API)
  • Adds multipart/form-data flat file upload path to POST /v1/publication — triggered when no type form field is present, isFlatFile: true, hearing_list not required
  • Extends the JSON path of /v1/publication to accept an optional source_artefact_id body field
  • Backfills existing artefact rows: source_artefact_id = <artefact_id><file_extension>

Test plan

  • Unit tests pass: yarn test (25 new tests across validation.test.ts and service.test.ts)
  • E2E tests added in e2e-tests/tests/api/flat-file-ingestion.spec.ts — run with yarn test:e2e:all
  • Manual upload via web UI: upload a file, check Summary of Publications download uses original file name
  • POST /v1/publication JSON path: send request with source_artefact_id field, verify stored in DB
  • POST /v1/publication flat file path (Postman): send multipart/form-data with metadata fields + file part (no type field), expect 201 with artefact_id
  • Verify hearing_list is not required on the flat file path
  • DB migration applies cleanly: yarn db:migrate

Closes #797

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added multipart flat-file ingestion to the publication endpoint, with flat-file specific validation and request/response handling.
    • Flat-file download/display and related metadata now use the original uploaded filename where available.
  • Bug Fixes

    • Improved handling of invalid multipart payloads and missing multipart file data, with clearer error messages and consistent success/no-match behaviour.
  • Database / Data Migration

    • Introduced source_artefact_id to retain original filenames for artefacts, and backfilled existing rows accordingly (replacing the prior extension-based approach).

junaidiqbalmoj and others added 8 commits July 7, 2026 19:05
…ension

Replaces the file_extension column on the artefact table with source_artefact_id
which stores the original uploaded file name. Downloads now use the original name
instead of the artefact UUID. Migration backfills existing rows and drops the old column.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…quest body

When provided, the value is stored as source_artefact_id on the artefact row
instead of the default "upload.json".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…v1/publication

Blob is still stored as upload.json; only the DB column uses the supplied value
or empty string when source_artefact_id is absent from the request body.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds multipart/form-data flat file ingestion path to the publication
endpoint. A request without a `type` form field is treated as a flat
file upload (vs PDDA HTML which requires `type`). Metadata fields are
sent as form parts; the file is stored in blob storage with
isFlatFile=true. Extracts shared validation into validateCommonFields
to avoid duplicating the nine common field checks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lobIngestion

Covers all branches of the two new functions added for flat file upload:
validation of common fields without hearing_list, source_artefact_id
handling, isFlatFile flag, no_match path, error logging, and locale
mapping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mirrors the existing blob-ingestion.spec.ts structure. Three journey
tests cover authentication validation (missing/invalid/malformed
tokens), payload validation (missing file, missing required fields,
invalid enums, hearing_list not required), and successful ingestion
with both MANUAL_UPLOAD and SNL provenance. All tests are @nightly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR replaces artefact file-extension storage with source artefact IDs, adds flat-file multipart ingestion to POST /v1/publication, and updates schema, storage, upload, retrieval, public-page, test-support, E2E, SJP, and ticket documentation paths.

Changes

Source artefact ID migration and flat-file ingestion

Layer / File(s) Summary
Schema and migration
libs/postgres-prisma/prisma/schema/base.prisma, apps/postgres/prisma/migrations/...
Replaces fileExtension with sourceArtefactId and backfills existing rows before removing the legacy column.
Ingestion contracts, validation, and processing
libs/api/src/blob-ingestion/...
Adds flat-file request typing and validation, updates blob ingestion metadata, and adds flat-file processing.
Publication API routing
apps/api/src/routes/v1/publication*
Routes multipart requests without type to flat-file handling while preserving JSON and PDDA HTML paths.
Storage, repository queries, and retrieval
libs/publication/src/..., libs/api/src/blob-ingestion/file-storage*, libs/admin-pages/src/manual-upload/file-storage*
Derives blob extensions and download names from source artefact IDs and replaces the artefact update helper.
Web uploads and public pages
apps/web/src/pages/..., libs/public-pages/src/flat-file/*
Stores original or converted filenames and uses source artefact IDs for display and download responses.
Test support, E2E, and SJP integration
libs/test-support/..., e2e-tests/*, libs/list-types/common/src/sjp/*
Propagates source artefact IDs through test uploads, covers flat-file flows, and routes SJP retrieval through publication helpers.
Ticket documentation
docs/tickets/797/*
Adds implementation, review, task, and acceptance-criteria documentation.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant PublicationAPI
  participant IngestionService
  participant BlobStorage
  participant ArtefactRepository
  Client->>PublicationAPI: Multipart flat-file upload
  PublicationAPI->>IngestionService: File buffer and metadata
  IngestionService->>BlobStorage: Save uploaded file
  IngestionService->>ArtefactRepository: Store source artefact ID
  ArtefactRepository-->>IngestionService: Updated artefact
  IngestionService-->>PublicationAPI: Ingestion result
  PublicationAPI-->>Client: HTTP response
Loading

Possibly related PRs

  • hmcts/cath-service#765: Extends the existing blob-ingestion service flow with source artefact ID persistence and flat-file processing.
  • hmcts/cath-service#141: Relates to the flat-file viewing and download flow updated to use source artefact IDs.
  • hmcts/cath-service#345: Relates to the PDDA HTML multipart routing discriminator preserved by this change.
🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR misses linked requirements: flat-file uploads are stored by artefact ID, and JSON ingestion stores null rather than the requested empty string. Store the flat-file blob under source_artefact_id or the requested fallback filename, and persist an empty string when the JSON field is omitted.
Out of Scope Changes check ⚠️ Warning The createArtefact lastReceivedDate change appears unrelated to the linked source_artefact_id and flat-file upload scope. Remove or justify the lastReceivedDate refactor unless it is required by the ticket.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states the source_artefact_id refactor and new /v1/publication flat-file upload path.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/797-store-source-artefact-id

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

🎭 Playwright E2E Test Results

84 tests   52 ✅  6m 16s ⏱️
33 suites  32 💤
 1 files     0 ❌

Results for commit 9f47396.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Preview Deployment Successful 🚀

Your preview environment is ready:

The environment will be automatically cleaned up when this PR is closed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/web/src/pages/(admin)/non-strategic-upload-summary/index.ts (1)

130-155: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

updateSourceArtefactId is not called when an Excel file has no converter.

When isExcelFile && selectedListType?.isNonStrategic is true but neither canConvertById nor canConvertByName is true, the inner if block (lines 139–155) is skipped and the else branch (line 156) is not entered either. This leaves sourceArtefactId null in the database.

The blob was already saved at line 123 as ${artefactId}${path.extname(uploadData.fileName)} (e.g., ${artefactId}.xlsx), but downstream getSourceArtefactId will fall back to ${artefactId}.pdf, causing getFileBuffer to download a non-existent blob.

🐛 Proposed fix: add else clause for no-converter path
      if (canConvertById || canConvertByName) {
        jsonData = canConvertById
          ? await convertExcelForListType(listTypeId, uploadData.file)
          : await convertExcelForListTypeName(listTypeName!, uploadData.file);
        await saveUploadedFile(artefactId, `${artefactId}.json`, Buffer.from(JSON.stringify(jsonData)));
        await updateSourceArtefactId(artefactId, `${artefactId}.json`);

        // Extract and store artefact search data from converted JSON
        try {
          await extractAndStoreArtefactSearch(artefactId, listTypeId, jsonData);
        } catch (error) {
          console.error("[Non-Strategic Upload] Failed to extract artefact search data from converted Excel", {
            artefactId,
            error: error instanceof Error ? error.message : String(error)
          });
        }
+     } else {
+       await updateSourceArtefactId(artefactId, uploadData.fileName);
      }
    } else {
libs/publication/src/repository/queries.ts (1)

169-186: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Delete the JSON blob when sourceArtefactId is empty. deleteArtefacts() currently falls back to .pdf for a blank sourceArtefactId, but JSON artefacts are stored as ${artefactId}.json, so the real blob is left orphaned. Use the artefact type to choose the default extension here.

🧹 Nitpick comments (10)
libs/api/src/blob-ingestion/validation.ts (1)

146-164: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid duplicate findAllListTypes() database query.

validateCommonFields calls findAllListTypes() at line 49, and validateBlobRequest calls it again at line 154. Each JSON ingestion validation triggers two database queries for the same list type data.

Return listTypes from validateCommonFields so validateBlobRequest can reuse the already-fetched result.

♻️ Proposed refactor
 async function validateCommonFields(request: FlatFileIngestionRequest, payloadSize: number): Promise<BlobValidationResult & { listTypes: Awaited<ReturnType<typeof findAllListTypes>> }> {
   const errors: ValidationError[] = [];

   // ... existing validation code ...

   const listTypes = await findAllListTypes();

   // ... existing list type lookup code ...

   return {
     isValid: errors.length === 0,
     errors,
     locationExists,
     listTypeId: listTypeId ? Number.parseInt(listTypeId, 10) : undefined,
     resolvedLocationId,
+    listTypes
   };
 }

 export async function validateBlobRequest(request: BlobIngestionRequest, rawBodySize: number): Promise<BlobValidationResult> {
   const result = await validateCommonFields(request, rawBodySize);
   const errors = [...result.errors];

   if (!request.hearing_list) {
     errors.push({ field: "hearing_list", message: "hearing_list is required" });
   }

-  const listTypes = await findAllListTypes();
+  const listTypes = result.listTypes;
   const listTypeId = result.listTypeId;

   if (listTypeId && request.hearing_list && errors.length === 0) {
     try {
       const listTypesInfo = listTypes.map((lt) => ({
         id: lt.id,
         name: lt.name,
         friendlyName: lt.friendlyName
       }));
       const validationResult = await validateListTypeJson(listTypeId.toString(), request.hearing_list, listTypesInfo);

       if (!validationResult.isValid) {
         for (const error of validationResult.errors) {
           errors.push({
             field: "hearing_list",
             message: (error as { message?: string }).message || "Invalid hearing_list structure"
           });
         }
       }
     } catch (_error) {
       errors.push({
         field: "hearing_list",
         message: "Failed to validate hearing_list against schema"
       });
     }
   }

-  return {
-    ...result,
-    isValid: errors.length === 0,
-    errors
-  };
+  const { listTypes: _, ...resultWithoutListTypes } = result;
+  return {
+    ...resultWithoutListTypes,
+    isValid: errors.length === 0,
+    errors
+  };
 }
apps/web/src/pages/(admin)/manual-upload-summary/index.test.ts (1)

83-83: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add test coverage for updateSourceArtefactId calls.

updateSourceArtefactId is mocked at line 83 but not imported (line 109 omits it), so no test verifies it is called with (artefactId, uploadData.fileName). This is the core behaviour introduced by this PR — the original filename should be persisted to sourceArtefactId. At minimum, the success-path tests (e.g., lines 458–495, 817–857) should assert updateSourceArtefactId was called with the correct arguments.

Additionally, saveUploadedFile is mocked to return string values (e.g., mockResolvedValue(".pdf") at lines 460, 634, 666, etc.) but the actual function now returns Promise<void>. These stale return values should be updated to mockResolvedValue(undefined) to match the current contract.

♻️ Proposed fix: import updateSourceArtefactId and add assertions
 import { createArtefact, extractAndStoreArtefactSearch, processPublication } from "`@hmcts/publication`";
+import { updateSourceArtefactId } from "`@hmcts/publication`";

Then in the success-path test (e.g., lines 458–495):

       expect(saveUploadedFile).toHaveBeenCalledWith("test-artefact-id-123", "test-hearing-list.pdf", mockUploadData.file);
+      expect(updateSourceArtefactId).toHaveBeenCalledWith("test-artefact-id-123", "test-hearing-list.pdf");
       expect(createArtefact).toHaveBeenCalledWith(

And update stale saveUploadedFile mocks:

-      vi.mocked(saveUploadedFile).mockResolvedValue(".pdf");
+      vi.mocked(saveUploadedFile).mockResolvedValue(undefined);
apps/web/src/pages/(admin)/non-strategic-upload-summary/index.test.ts (1)

95-101: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add test coverage for updateSourceArtefactId and fix stale saveUploadedFile mock.

updateSourceArtefactId is mocked at line 98 but not imported (line 106 omits it), so no test verifies it is called. The Excel conversion test (lines 503–533) should assert updateSourceArtefactId was called with ("artefact-id-123", "artefact-id-123.json"), and the non-Excel success test (lines 297–353) should assert it was called with ("artefact-id-123", "test.xlsx").

Additionally, the saveUploadedFile mock at line 91 returns Promise.resolve(".xlsx"), but the actual function now returns Promise<void>. This should be updated to Promise.resolve() to match the current contract.

♻️ Proposed fix
 import { createArtefact, extractAndStoreArtefactSearch, processPublication } from "`@hmcts/publication`";
+import { updateSourceArtefactId } from "`@hmcts/publication`";

Update the mock factory:

-    saveUploadedFile: vi.fn(() => Promise.resolve(".xlsx"))
+    saveUploadedFile: vi.fn(() => Promise.resolve())

Add assertion in the Excel conversion test (after line 531):

       expect(extractAndStoreArtefactSearch).toHaveBeenCalledWith("artefact-id-123", 7, { cases: [] });
+      expect(updateSourceArtefactId).toHaveBeenCalledWith("artefact-id-123", "artefact-id-123.json");
       expect(res.redirect).toHaveBeenCalledWith("/non-strategic-upload-success");

Add assertion in the non-Excel success test (after line 339):

       expect(saveUploadedFile).toHaveBeenCalledWith("artefact-id-123", "test.xlsx", mockUploadData.file);
+      expect(updateSourceArtefactId).toHaveBeenCalledWith("artefact-id-123", "test.xlsx");
       expect(processPublication).toHaveBeenCalledWith(
apps/api/src/routes/v1/publication.ts (2)

34-50: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract shared field validation to reduce type-guard duplication.

isFlatFileIngestionRequest and isBlobIngestionRequest (lines 15–32) share 8 identical field checks. Extracting a common helper would ensure both guards stay in sync when new required metadata fields are added.

♻️ Suggested refactor
+function hasRequiredMetadataFields(req: Record<string, unknown>): boolean {
+  return (
+    typeof req.court_id === "string" &&
+    typeof req.provenance === "string" &&
+    typeof req.content_date === "string" &&
+    typeof req.list_type === "string" &&
+    typeof req.sensitivity === "string" &&
+    typeof req.language === "string" &&
+    typeof req.display_from === "string" &&
+    typeof req.display_to === "string"
+  );
+}
+
 function isBlobIngestionRequest(body: unknown): body is BlobIngestionRequest {
   if (typeof body !== "object" || body === null) {
     return false;
   }
-  const req = body as Record<string, unknown>;
-  return (
-    typeof req.court_id === "string" &&
-    typeof req.provenance === "string" &&
-    typeof req.content_date === "string" &&
-    typeof req.list_type === "string" &&
-    typeof req.sensitivity === "string" &&
-    typeof req.language === "string" &&
-    typeof req.display_from === "string" &&
-    typeof req.display_to === "string" &&
-    req.hearing_list !== undefined
-  );
+  const req = body as Record<string, unknown>;
+  return hasRequiredMetadataFields(req) && req.hearing_list !== undefined;
 }

 function isFlatFileIngestionRequest(body: unknown): body is FlatFileIngestionRequest {
   if (typeof body !== "object" || body === null) {
     return false;
   }
   const req = body as Record<string, unknown>;
-  return (
-    typeof req.court_id === "string" &&
-    typeof req.provenance === "string" &&
-    typeof req.content_date === "string" &&
-    typeof req.list_type === "string" &&
-    typeof req.sensitivity === "string" &&
-    typeof req.language === "string" &&
-    typeof req.display_from === "string" &&
-    typeof req.display_to === "string"
-  );
+  return hasRequiredMetadataFields(req);
 }

71-87: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting shared response mapping.

Both handleJsonBlobIngestion and handleFlatFileUpload contain identical status-code mapping logic (no_match→200, "Validation failed"→400, other→500, success→201). A shared helper would centralise this contract and prevent drift.

♻️ Suggested refactor
+function sendIngestionResponse(res: Response, result: BlobIngestionResponse): Response {
+  if (!result.success) {
+    if ("no_match" in result && result.no_match) {
+      return res.status(200).json(result);
+    }
+    if (result.message === "Validation failed") {
+      return res.status(400).json(result);
+    }
+    return res.status(500).json(result);
+  }
+  return res.status(201).json(result);
+}

Also applies to: 148-159

libs/public-pages/src/flat-file/flat-file-service.ts (1)

26-48: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Eliminate redundant getSourceArtefactId DB query in both functions.

getFlatFileForDisplay and getFileForDownload each call getFileBuffer (which internally calls getSourceArtefactId) and then call getSourceArtefactId again separately. This results in 2 DB queries for the same data on every successful flat-file access.

Adding an optional sourceArtefactId parameter to getFileBuffer allows the caller to pass a pre-fetched value, avoiding the duplicate query.

♻️ Proposed fix for `getFileBuffer` in file-retrieval.ts
-export async function getFileBuffer(artefactId: string): Promise<Buffer | null> {
-  const sourceArtefactId = await getSourceArtefactId(artefactId);
+export async function getFileBuffer(artefactId: string, sourceArtefactId?: string): Promise<Buffer | null> {
+  const sourceId = sourceArtefactId ?? await getSourceArtefactId(artefactId);
   const extension = path.extname(sourceId) || ".pdf";
   return downloadBlob(`${artefactId}${extension}`, CONTAINER.ARTEFACT);
♻️ Proposed fix for `getFlatFileForDisplay` in flat-file-service.ts
   const sourceArtefactId = await getSourceArtefactId(artefact.artefactId);
-  const fileBuffer = await getFileBuffer(artefact.artefactId);
+  const fileBuffer = await getFileBuffer(artefact.artefactId, sourceArtefactId);

   if (!fileBuffer) {
     return { error: "FILE_NOT_FOUND" as const };
   }

   const location = await getLocationById(Number.parseInt(artefact.locationId, 10));
   const listType = await findListTypeById(artefact.listTypeId);

   const courtName = locale === "cy" ? location?.welshName || location?.name || "Unknown" : location?.name || "Unknown";
   const listTypeName = locale === "cy" ? listType?.welshFriendlyName || "Unknown" : listType?.friendlyName || "Unknown";

-  const sourceArtefactId = await getSourceArtefactId(artefact.artefactId);
-
   return {
     success: true,
     artefactId: artefact.artefactId,
     courtName,
     listTypeName,
     contentDate: artefact.contentDate,
     language: artefact.language,
     sourceArtefactId
   };
♻️ Proposed fix for `getFileForDownload` in flat-file-service.ts
   const sourceArtefactId = await getSourceArtefactId(artefact.artefactId);
-  const fileBuffer = await getFileBuffer(artefact.artefactId);
+  const fileBuffer = await getFileBuffer(artefact.artefactId, sourceArtefactId);

   if (!fileBuffer) {
     return { error: "FILE_NOT_FOUND" as const };
   }

-  const sourceArtefactId = await getSourceArtefactId(artefact.artefactId);
-
   return {
     success: true,
     fileBuffer,
     contentType: getContentType(path.extname(sourceArtefactId) || ".pdf"),
     fileName: getFileName(sourceArtefactId)
   };

Also applies to: 67-80

libs/api/src/blob-ingestion/repository/service.test.ts (1)

683-701: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No assertion on processPublication's payload for flat files.

Existing tests check locale/locationId via objectContaining, but none assert what jsonData/flatFilePath is passed for flat-file ingestion. Given the concern raised in service.ts (Lines 229-251) about processPublication receiving an empty jsonData object instead of flatFilePath, a test pinning the exact call payload would catch regressions here.

libs/publication/src/repository/queries.ts (1)

180-185: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

"statusCode" in error can throw on non-object rejections.

If deleteBlob ever rejects with a non-object value (e.g. a string), "statusCode" in error throws a TypeError inside this unhandled/fire-and-forget .catch callback, turning a benign 404 case into an unhandled rejection. Guard with a type check first.

🛡️ Suggested fix
-      if (!("statusCode" in error) || (error as { statusCode: number }).statusCode !== 404) {
+      if (!(error instanceof Object) || !("statusCode" in error) || (error as { statusCode: number }).statusCode !== 404) {
         console.error(`Failed to delete PDF blob for artefact ${artefact.artefactId}:`, error);
       }
libs/publication/src/repository/queries.test.ts (1)

589-622: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the empty-sourceArtefactId case.

Current tests cover "upload.json", null, and an explicit flat-file name, but not sourceArtefactId: "" — the default stored for JSON artefacts when source_artefact_id is omitted. This is the scenario flagged in queries.ts's deleteArtefacts (Lines 169-186) where the wrong blob extension (.pdf instead of .json) would be targeted for deletion.

libs/admin-pages/src/manual-upload/file-storage.ts (1)

4-7: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider consolidating duplicated saveUploadedFile into a shared package.

This function is byte-for-byte identical to the one in libs/api/src/blob-ingestion/file-storage.ts. Both copies were updated identically in this PR. Extracting it into a shared @hmcts/* package would prevent future divergence and reduce maintenance burden.

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c1695f28-8570-49dc-89e6-d034dd96c74c

📥 Commits

Reviewing files that changed from the base of the PR and between c9009cf and 524da82.

📒 Files selected for processing (34)
  • apps/api/src/routes/v1/publication.test.ts
  • apps/api/src/routes/v1/publication.ts
  • apps/postgres/prisma/migrations/20260707000000_replace_file_extension_with_source_artefact_id/migration.sql
  • apps/web/src/pages/(admin)/manual-upload-summary/index.test.ts
  • apps/web/src/pages/(admin)/manual-upload-summary/index.ts
  • apps/web/src/pages/(admin)/non-strategic-upload-summary/index.test.ts
  • apps/web/src/pages/(admin)/non-strategic-upload-summary/index.ts
  • apps/web/src/pages/(public)/hearing-lists/[locationId]/[artefactId]/index.test.ts
  • apps/web/src/pages/(public)/hearing-lists/[locationId]/[artefactId]/index.ts
  • docs/tickets/797/plan.md
  • docs/tickets/797/review.md
  • docs/tickets/797/tasks.md
  • docs/tickets/797/ticket.md
  • e2e-tests/tests/api/flat-file-ingestion.spec.ts
  • e2e-tests/tests/flat-file-viewing.spec.ts
  • e2e-tests/utils/test-support-api.ts
  • libs/admin-pages/src/manual-upload/file-storage.test.ts
  • libs/admin-pages/src/manual-upload/file-storage.ts
  • libs/api/src/blob-ingestion/file-storage.test.ts
  • libs/api/src/blob-ingestion/file-storage.ts
  • libs/api/src/blob-ingestion/repository/model.ts
  • libs/api/src/blob-ingestion/repository/service.test.ts
  • libs/api/src/blob-ingestion/repository/service.ts
  • libs/api/src/blob-ingestion/validation.test.ts
  • libs/api/src/blob-ingestion/validation.ts
  • libs/postgres-prisma/prisma/schema/base.prisma
  • libs/public-pages/src/flat-file/flat-file-service.test.ts
  • libs/public-pages/src/flat-file/flat-file-service.ts
  • libs/publication/src/file-storage/file-retrieval.test.ts
  • libs/publication/src/file-storage/file-retrieval.ts
  • libs/publication/src/index.ts
  • libs/publication/src/repository/queries.test.ts
  • libs/publication/src/repository/queries.ts
  • libs/test-support/src/routes/test-support/flat-files.ts

Comment thread libs/api/src/blob-ingestion/repository/service.ts
Comment on lines +229 to +251
if (!noMatch) {
processPublication({
artefactId,
locationId,
listTypeId: validation.listTypeId,
contentDate: new Date(request.content_date),
locale: request.language === "WELSH" ? "cy" : "en",
jsonData: {} as CauseListData,
provenance: PROVENANCE_MAP[request.provenance] || request.provenance,
sensitivity: request.sensitivity,
language: request.language,
displayFrom: new Date(request.display_from),
displayTo: new Date(request.display_to),
isUpdate,
logPrefix: "[flat-file-ingestion]"
}).catch((error) => {
console.error("[flat-file-ingestion] Failed to process publication:", {
artefactId,
courtId: request.court_id,
error: error instanceof Error ? error.message : String(error)
});
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant files and inspect the implementation around the cited lines.
git ls-files 'libs/api/src/blob-ingestion/**' 'libs/api/src/**/service.test.ts' | sed -n '1,200p'

echo
echo '--- outline: service.ts ---'
ast-grep outline libs/api/src/blob-ingestion/repository/service.ts --view expanded

echo
echo '--- outline: processPublication implementation file(s) if reachable by search ---'
rg -n "function processPublication|const processPublication|processPublication\\(" libs/api/src -g '!**/dist/**' -g '!**/build/**'

echo
echo '--- tests mentioning flat-file ingestion / processPublication / flatFilePath / jsonData ---'
rg -n "flat-file-ingestion|flatFilePath|jsonData: \\{\\} as CauseListData|processPublication|sendThirdPartyPublications|generatePublicationPdf" libs/api/src -g '*test.ts' -g '*spec.ts'

Repository: hmcts/cath-service

Length of output: 3489


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the surrounding implementation for the flat-file path and the publication call.
sed -n '157,260p' libs/api/src/blob-ingestion/repository/service.ts

echo
echo '--- service.test.ts around flat-file expectations ---'
sed -n '300,520p' libs/api/src/blob-ingestion/repository/service.test.ts

echo
echo '--- search for publication package source in repo ---'
git ls-files | rg '^libs/.*/publication|^packages/.*/publication|publication/src|`@hmcts/publication`'

echo
echo '--- locate processPublication / generatePublicationPdf / sendThirdPartyPublications definitions ---'
rg -n "export function processPublication|function processPublication|generatePublicationPdf|sendThirdPartyPublications|extractAndStoreArtefactSearch" . -g '!**/dist/**' -g '!**/build/**'

Repository: hmcts/cath-service

Length of output: 28757


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the publication processing flow and tests for empty jsonData / flatFilePath handling.
sed -n '440,530p' libs/publication/src/processing/service.ts

echo
echo '--- generatePublicationPdf / processPublication tests around jsonData and flatFilePath ---'
sed -n '900,1110p' libs/publication/src/processing/service.test.ts

Repository: hmcts/cath-service

Length of output: 9681


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect PDF generation to see whether empty jsonData can fail or produce output.
sed -n '275,440p' libs/publication/src/processing/service.ts

echo
echo '--- generatePublicationPdf tests around empty/invalid jsonData ---'
rg -n "empty|invalid|jsonData.*undefined|jsonData.*\\{\\}|generatePublicationPdf\\(" libs/publication/src/processing/service.test.ts

Repository: hmcts/cath-service

Length of output: 8419


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect how third-party fulfilment treats jsonData and flatFilePath.
sed -n '1,220p' libs/legacy-third-party-fulfilment/src/service.ts

echo
echo '--- tests for flatFilePath / jsonData in third-party fulfilment ---'
sed -n '1,180p' libs/legacy-third-party-fulfilment/src/service.test.ts

Repository: hmcts/cath-service

Length of output: 11619


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect pushWithRetry to determine how flatFilePath is used.
sed -n '1,240p' libs/legacy-third-party-fulfilment/src/push/retry.ts

echo
echo '--- tests around pushWithRetry file-path handling ---'
sed -n '1,220p' libs/legacy-third-party-fulfilment/src/push/retry.test.ts

Repository: hmcts/cath-service

Length of output: 7361


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect how executePush uses pdfPath / flatFilePath in the outgoing request.
sed -n '1,260p' libs/legacy-third-party-fulfilment/src/push/http-client.ts

echo
echo '--- tests around executePush file-path payloads ---'
sed -n '1,260p' libs/legacy-third-party-fulfilment/src/push/http-client.test.ts

Repository: hmcts/cath-service

Length of output: 13425


Use flatFilePath for flat-file pushes. processPublication only guards PDF/search generation on jsonData; it still forwards flatFilePath to Courtel, and the push layer uses that path to build the multipart file body. Passing {} here is the wrong shape for flat-file ingestion, and fileName should be forwarded instead.

Suggested fix
-        jsonData: {} as CauseListData,
+        flatFilePath: fileName,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!noMatch) {
processPublication({
artefactId,
locationId,
listTypeId: validation.listTypeId,
contentDate: new Date(request.content_date),
locale: request.language === "WELSH" ? "cy" : "en",
jsonData: {} as CauseListData,
provenance: PROVENANCE_MAP[request.provenance] || request.provenance,
sensitivity: request.sensitivity,
language: request.language,
displayFrom: new Date(request.display_from),
displayTo: new Date(request.display_to),
isUpdate,
logPrefix: "[flat-file-ingestion]"
}).catch((error) => {
console.error("[flat-file-ingestion] Failed to process publication:", {
artefactId,
courtId: request.court_id,
error: error instanceof Error ? error.message : String(error)
});
});
}
if (!noMatch) {
processPublication({
artefactId,
locationId,
listTypeId: validation.listTypeId,
contentDate: new Date(request.content_date),
locale: request.language === "WELSH" ? "cy" : "en",
flatFilePath: fileName,
provenance: PROVENANCE_MAP[request.provenance] || request.provenance,
sensitivity: request.sensitivity,
language: request.language,
displayFrom: new Date(request.display_from),
displayTo: new Date(request.display_to),
isUpdate,
logPrefix: "[flat-file-ingestion]"
}).catch((error) => {
console.error("[flat-file-ingestion] Failed to process publication:", {
artefactId,
courtId: request.court_id,
error: error instanceof Error ? error.message : String(error)
});
});
}

Comment on lines +73 to +79
const sourceArtefactId = await getSourceArtefactId(artefact.artefactId);

return {
success: true,
fileBuffer,
contentType: getContentType(fileExtension),
fileName: getFileName(artefact.artefactId, fileExtension)
contentType: getContentType(path.extname(sourceArtefactId) || null),
fileName: getFileName(sourceArtefactId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use ".pdf" fallback instead of null for content-type derivation.

getFileForDownload uses path.extname(sourceArtefactId) || null, but getFileExtension and getFileBuffer both use || ".pdf". When sourceArtefactId has no extension, getContentType(null) returns "application/octet-stream", while getContentTypeFromExtension("") returns "application/pdf". This causes the download content type to diverge from the blob name (<artefactId>.pdf) and the hearing-lists page controller's PDF detection logic.

🐛 Proposed fix
-    contentType: getContentType(path.extname(sourceArtefactId) || null),
+    contentType: getContentType(path.extname(sourceArtefactId) || ".pdf"),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const sourceArtefactId = await getSourceArtefactId(artefact.artefactId);
return {
success: true,
fileBuffer,
contentType: getContentType(fileExtension),
fileName: getFileName(artefact.artefactId, fileExtension)
contentType: getContentType(path.extname(sourceArtefactId) || null),
fileName: getFileName(sourceArtefactId)
const sourceArtefactId = await getSourceArtefactId(artefact.artefactId);
return {
success: true,
fileBuffer,
contentType: getContentType(path.extname(sourceArtefactId) || ".pdf"),
fileName: getFileName(sourceArtefactId)

…ategic uploads

For Excel uploads, source_artefact_id now records the original uploaded
file name (e.g. hearing-list.xlsx) instead of the synthetic JSON blob
name. The Excel file is no longer saved to blob storage — only the
converted JSON is stored, since the Excel itself has no value after
conversion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Preview Deployment Successful 🚀

Your preview environment is ready:

The environment will be automatically cleaned up when this PR is closed.

…tension

Non-strategic Excel uploads store the original .xlsx filename in source_artefact_id
but the blob in storage is the converted .json. getFileBuffer now falls back to
<artefactId>.json when the primary extension-based lookup returns nothing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Preview Deployment Successful 🚀

Your preview environment is ready:

The environment will be automatically cleaned up when this PR is closed.

# Conflicts:
#	apps/web/src/pages/(admin)/non-strategic-upload-summary/index.ts
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Preview Deployment Successful 🚀

Your preview environment is ready:

The environment will be automatically cleaned up when this PR is closed.

junaidiqbalmoj and others added 3 commits July 13, 2026 09:50
Merged import sets from both branches — keeps getSourceArtefactId (HEAD) alongside canAccessPublicationData, getFileExtension, and resolveListType (master). Fixes listTypeInfo variable name and removes duplicate null check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
}
}

export async function processFlatFileBlobIngestion(request: FlatFileIngestionRequest, file: Buffer, fileSize: number): Promise<BlobIngestionResponse> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new method for flat file is very similar to the previous method processBlobIngestion(). Can we move some of the common areas to new methods?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks Kian, I didn't realised. refactoring is done.

@github-actions

Copy link
Copy Markdown
Contributor

Preview Deployment Successful 🚀

Your preview environment is ready:

The environment will be automatically cleaned up when this PR is closed.

…duplication

Pulls four repeated patterns — validation failure logging, missing list-type error handling, artefact param construction, and fire-and-forget processPublication — into dedicated functions shared by processBlobIngestion and processFlatFileBlobIngestion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Preview Deployment Successful 🚀

Your preview environment is ready:

The environment will be automatically cleaned up when this PR is closed.

junaidiqbalmoj and others added 2 commits July 14, 2026 15:07
…SJP storage

- Store artefact blobs using artefactId only (no file extension) for both
  frontend and /v1/publication API uploads; add backward-compat fallback
  in download and delete paths for legacy blobs
- Store null instead of empty string in source_artefact_id when not provided
  via the API (use || null to handle empty string from multipart fields)
- Widen updateSourceArtefactId signature to accept string | null
- Fix SJP service to read JSON from blob storage instead of local temp dir
- Fix flat file ingestion to skip PDF generation (was passing {} as jsonData)
- Set lastReceivedDate explicitly on artefact create instead of relying on
  DB default

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

Preview Deployment Successful 🚀

Your preview environment is ready:

The environment will be automatically cleaned up when this PR is closed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/pages/(admin)/non-strategic-upload-summary/index.ts (1)

132-149: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Handle missing converters to prevent empty artefacts.

If a non-strategic Excel file is uploaded but no converter is found for its list type, the execution skips both the if body and the outer else block. This results in the file not being saved to blob storage and the source_artefact_id not being updated, leaving an orphaned artefact record in the database.

Throw an error to explicitly handle this scenario and prevent silent failures.

🐛 Proposed fix
       if (listTypeName && hasConverterForListTypeName(listTypeName)) {
         jsonData = await convertExcelForListTypeName(listTypeName, uploadData.file);
         // Store converted JSON in blob — original Excel is not stored (no value after conversion)
         await saveUploadedFile(artefactId, artefactId, Buffer.from(JSON.stringify(jsonData)));
         // Track the original uploaded Excel file name, not the synthetic JSON blob name
         await updateSourceArtefactId(artefactId, uploadData.fileName);
 
         // Extract and store artefact search data from converted JSON
         try {
           await extractAndStoreArtefactSearch(artefactId, listTypeId, jsonData);
         } catch (error) {
           console.error("[Non-Strategic Upload] Failed to extract artefact search data from converted Excel", {
             artefactId,
             error: error instanceof Error ? error.message : String(error)
           });
         }
+      } else {
+        throw new Error(`No converter found for list type: ${listTypeName}`);
       }

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4186f70d-05e4-4919-9fb4-10a14079326c

📥 Commits

Reviewing files that changed from the base of the PR and between 551171a and 9f47396.

📒 Files selected for processing (17)
  • apps/web/src/pages/(admin)/manual-upload-summary/index.test.ts
  • apps/web/src/pages/(admin)/manual-upload-summary/index.ts
  • apps/web/src/pages/(admin)/non-strategic-upload-summary/index.test.ts
  • apps/web/src/pages/(admin)/non-strategic-upload-summary/index.ts
  • libs/admin-pages/src/manual-upload/file-storage.test.ts
  • libs/admin-pages/src/manual-upload/file-storage.ts
  • libs/api/src/blob-ingestion/file-storage.test.ts
  • libs/api/src/blob-ingestion/file-storage.ts
  • libs/api/src/blob-ingestion/repository/service.test.ts
  • libs/api/src/blob-ingestion/repository/service.ts
  • libs/list-types/common/src/sjp/sjp-service.test.ts
  • libs/list-types/common/src/sjp/sjp-service.ts
  • libs/publication/src/file-storage/file-retrieval.test.ts
  • libs/publication/src/file-storage/file-retrieval.ts
  • libs/publication/src/index.ts
  • libs/publication/src/repository/queries.test.ts
  • libs/publication/src/repository/queries.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • libs/publication/src/index.ts
  • apps/web/src/pages/(admin)/manual-upload-summary/index.ts
  • libs/publication/src/file-storage/file-retrieval.ts
  • libs/publication/src/repository/queries.ts
  • apps/web/src/pages/(admin)/manual-upload-summary/index.test.ts
  • libs/publication/src/repository/queries.test.ts
  • libs/publication/src/file-storage/file-retrieval.test.ts

@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

Preview Deployment Successful 🚀

Your preview environment is ready:

The environment will be automatically cleaned up when this PR is closed.

@junaidiqbalmoj
junaidiqbalmoj merged commit c709e9f into master Jul 16, 2026
31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Store source artefact ID for manual uploads and API publications, and add flat file upload support to /v1/publication endpoint

2 participants