diff --git a/apps/web/package.json b/apps/web/package.json index d84c7c16a..561545b24 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,7 @@ "@hmcts/list-types-common": "workspace:*", "@hmcts/location": "workspace:*", "@hmcts/london-administrative-court-daily-cause-list": "workspace:*", + "@hmcts/magistrates-standard-list": "workspace:*", "@hmcts/public-pages": "workspace:*", "@hmcts/rcj-standard-daily-cause-list": "workspace:*", "@hmcts/send-daily-hearing-list": "workspace:*", diff --git a/apps/web/src/app.ts b/apps/web/src/app.ts index 2f6599492..d186d7add 100644 --- a/apps/web/src/app.ts +++ b/apps/web/src/app.ts @@ -20,6 +20,7 @@ import { moduleRoot as grcWeeklyHearingListModuleRoot } from "@hmcts/grc-weekly- import { moduleRoot as listTypesCommonModuleRoot } from "@hmcts/list-types-common/config"; import { apiRoutes as locationApiRoutes } from "@hmcts/location/config"; import { moduleRoot as londonAdminModuleRoot } from "@hmcts/london-administrative-court-daily-cause-list/config"; +import { moduleRoot as magistratesStandardListModuleRoot } from "@hmcts/magistrates-standard-list/config"; import { apiRoutes as publicPagesApiRoutes, fileUploadRoutes as publicPagesFileUploadRoutes, @@ -118,6 +119,7 @@ export async function createApp(): Promise { londonAdminModuleRoot, civilAppealModuleRoot, adminCourtModuleRoot, + magistratesStandardListModuleRoot, crownDailyListModuleRoot, crownFirmListModuleRoot, crownWarnedListModuleRoot, diff --git a/apps/web/src/assets/css/list-types/magistrates-standard-list.scss b/apps/web/src/assets/css/list-types/magistrates-standard-list.scss new file mode 100644 index 000000000..9a79a71cc --- /dev/null +++ b/apps/web/src/assets/css/list-types/magistrates-standard-list.scss @@ -0,0 +1,29 @@ +.linked-cases-heading { + font-weight: 700; +} + +.offence-summary { + display: inline; +} + +.add-border-bottom { + border-bottom: 1px solid #b1b4b6; +} + +.restriction-list-section { + background-color: #f3f2f1; + padding: 5px 10px; +} + +.restriction-list-section .govuk-warning-text__icon { + margin-top: 5px; +} + +.govuk-accordion__controls { + text-align: right; +} + +.govuk-details .govuk-table__cell, +.govuk-details .govuk-table__header { + border-bottom: none; +} diff --git a/apps/web/src/assets/css/web.scss b/apps/web/src/assets/css/web.scss index d623a3013..7fda579ad 100644 --- a/apps/web/src/assets/css/web.scss +++ b/apps/web/src/assets/css/web.scss @@ -8,6 +8,7 @@ @use "./user-management.scss"; @use "./verified-pages.scss" as verified; @use "./session-timeout.scss"; +@use "./list-types/magistrates-standard-list.scss"; @use "./list-types/sjp-filters.scss"; @use "./list-types/sjp-public-list.scss"; diff --git a/apps/web/src/pages/(list-types)/magistrates-standard-list/index.test.ts b/apps/web/src/pages/(list-types)/magistrates-standard-list/index.test.ts new file mode 100644 index 000000000..9553349d2 --- /dev/null +++ b/apps/web/src/pages/(list-types)/magistrates-standard-list/index.test.ts @@ -0,0 +1,381 @@ +import { renderMagistratesStandardListData, validateMagistratesStandardList } from "@hmcts/magistrates-standard-list"; +import { prisma } from "@hmcts/postgres-prisma"; +import { canAccessPublicationData, getArtefactById, getPublicationJson } from "@hmcts/publication"; +import type { Request, Response } from "express"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { GET } from "./index.js"; + +vi.mock("@hmcts/postgres-prisma", () => ({ + prisma: { + artefact: { + findUnique: vi.fn() + }, + listType: { + findUnique: vi.fn() + } + } +})); +vi.mock("@hmcts/publication", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getArtefactById: vi.fn(), + getPublicationJson: vi.fn(), + canAccessPublicationData: vi.fn() + }; +}); +vi.mock("@hmcts/magistrates-standard-list"); + +describe("magistrates-standard-list controller", () => { + let req: Partial; + let res: Partial; + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + const mockListType = { + id: 28, + allowedProvenance: ["MANUAL_UPLOAD"], + isNonStrategic: false + }; + + beforeEach(() => { + vi.clearAllMocks(); + req = { + query: {} + }; + res = { + locals: { locale: "en" }, + status: vi.fn().mockReturnThis(), + render: vi.fn() + }; + vi.mocked(prisma.listType.findUnique).mockResolvedValue(mockListType as any); + vi.mocked(canAccessPublicationData).mockReturnValue(true); + }); + + afterAll(() => { + consoleErrorSpy.mockRestore(); + }); + + describe("GET", () => { + it("should return 400 when artefactId is missing", async () => { + req.query = {}; + + await GET(req as Request, res as Response); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: expect.any(String), + errorMessage: expect.any(String) + }) + ); + }); + + it("should return 404 when artefact is not found", async () => { + req.query = { artefactId: "nonexistent-id" }; + vi.mocked(getArtefactById).mockResolvedValue(null); + + await GET(req as Request, res as Response); + + expect(getArtefactById).toHaveBeenCalledWith("nonexistent-id"); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.render).toHaveBeenCalledWith("errors/common", expect.any(Object)); + }); + + it("should return 403 when access is denied", async () => { + req.query = { artefactId: "test-id" }; + const mockArtefact = { + artefactId: "test-id", + locationId: "1", + listTypeId: 28, + contentDate: new Date("2025-01-13"), + sensitivity: "PUBLIC", + language: "ENGLISH", + displayFrom: new Date("2025-01-13"), + displayTo: new Date("2025-01-20"), + lastReceivedDate: new Date("2025-01-13"), + isFlatFile: false, + provenance: "MANUAL_UPLOAD", + supersededCount: 0, + noMatch: false + } as any; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact); + vi.mocked(canAccessPublicationData).mockReturnValue(false); + + await GET(req as Request, res as Response); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.render).toHaveBeenCalledWith("errors/403", expect.any(Object)); + }); + + it("should return 404 when JSON blob is not found", async () => { + req.query = { artefactId: "test-id" }; + const mockArtefact = { + artefactId: "test-id", + locationId: "1", + listTypeId: 28, + contentDate: new Date("2025-01-13"), + sensitivity: "PUBLIC", + language: "ENGLISH", + displayFrom: new Date("2025-01-13"), + displayTo: new Date("2025-01-20"), + lastReceivedDate: new Date("2025-01-13"), + isFlatFile: false, + provenance: "MANUAL_UPLOAD", + supersededCount: 0, + noMatch: false + } as any; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact); + vi.mocked(getPublicationJson).mockResolvedValue(null); + + await GET(req as Request, res as Response); + + expect(consoleErrorSpy).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.render).toHaveBeenCalledWith("errors/common", expect.any(Object)); + }); + + it("should return 400 when JSON validation fails", async () => { + req.query = { artefactId: "test-id" }; + const mockArtefact = { + artefactId: "test-id", + locationId: "1", + listTypeId: 28, + contentDate: new Date("2025-01-13"), + sensitivity: "PUBLIC", + language: "ENGLISH", + displayFrom: new Date("2025-01-13"), + displayTo: new Date("2025-01-20"), + lastReceivedDate: new Date("2025-01-13"), + isFlatFile: false, + provenance: "MANUAL_UPLOAD", + supersededCount: 0, + noMatch: false + } as any; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact); + vi.mocked(getPublicationJson).mockResolvedValue({ invalid: "data" }); + vi.mocked(validateMagistratesStandardList).mockReturnValue({ + isValid: false, + errors: ["Validation error"], + schemaVersion: "1.0" + } as any); + + await GET(req as Request, res as Response); + + expect(validateMagistratesStandardList).toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith("[magistrates-standard-list] Validation errors:", ["Validation error"]); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.render).toHaveBeenCalledWith("errors/common", expect.any(Object)); + }); + + it("should successfully render with valid data in English", async () => { + req.query = { artefactId: "test-id" }; + res.locals = { locale: "en" }; + const mockArtefact = { + artefactId: "test-id", + locationId: "1", + listTypeId: 28, + contentDate: new Date("2025-01-13"), + sensitivity: "PUBLIC", + language: "ENGLISH", + displayFrom: new Date("2025-01-13"), + displayTo: new Date("2025-01-20"), + lastReceivedDate: new Date("2025-01-13"), + isFlatFile: false, + provenance: "MANUAL_UPLOAD", + supersededCount: 0, + noMatch: false + } as any; + const mockJsonData = { + document: { publicationDate: "2025-01-13T09:30:00.000Z" }, + venue: { venueAddress: { line: ["Court Address"] } }, + courtLists: [] + }; + const mockRenderedData = { + header: { + contentDate: "13 January 2025", + publishedDate: "13 January 2025", + publishedTime: "09:30", + venueAddress: "Court Address" + }, + listData: [] + }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact); + vi.mocked(getPublicationJson).mockResolvedValue(mockJsonData); + vi.mocked(validateMagistratesStandardList).mockReturnValue({ + isValid: true, + errors: [], + schemaVersion: "1.0" + } as any); + vi.mocked(renderMagistratesStandardListData).mockResolvedValue(mockRenderedData as any); + + await GET(req as Request, res as Response); + + expect(renderMagistratesStandardListData).toHaveBeenCalledWith(mockJsonData, { + locale: "en", + locationId: "1", + contentDate: mockArtefact.contentDate + }); + const renderCall = vi.mocked(res.render!).mock.calls[0]!; + expect(renderCall[0]).toBe("magistrates-standard-list"); + expect(renderCall[1]).toMatchObject({ + dataSource: "Manual Upload" + }); + expect(renderCall[1]).toHaveProperty("en"); + expect(renderCall[1]).toHaveProperty("cy"); + expect(renderCall[1]).toHaveProperty("header"); + expect(renderCall[1]).toHaveProperty("listData"); + expect(renderCall[1]).toHaveProperty("t"); + }); + + it("should successfully render with Welsh locale", async () => { + req.query = { artefactId: "test-id" }; + res.locals = { locale: "cy" }; + const mockArtefact = { + artefactId: "test-id", + locationId: "1", + listTypeId: 28, + contentDate: new Date("2025-01-13"), + sensitivity: "PUBLIC", + language: "WELSH", + displayFrom: new Date("2025-01-13"), + displayTo: new Date("2025-01-20"), + lastReceivedDate: new Date("2025-01-13"), + isFlatFile: false, + provenance: "MANUAL_UPLOAD", + supersededCount: 0, + noMatch: false + } as any; + const mockJsonData = { + document: { publicationDate: "2025-01-13T09:30:00.000Z" }, + venue: { venueAddress: { line: ["Cyfeiriad y Llys"] } }, + courtLists: [] + }; + const mockRenderedData = { + header: { + contentDate: "13 Ionawr 2025", + publishedDate: "13 Ionawr 2025", + publishedTime: "09:30", + venueAddress: "Cyfeiriad y Llys" + }, + listData: [] + }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact); + vi.mocked(getPublicationJson).mockResolvedValue(mockJsonData); + vi.mocked(validateMagistratesStandardList).mockReturnValue({ + isValid: true, + errors: [], + schemaVersion: "1.0" + } as any); + vi.mocked(renderMagistratesStandardListData).mockResolvedValue(mockRenderedData as any); + + await GET(req as Request, res as Response); + + expect(renderMagistratesStandardListData).toHaveBeenCalledWith(mockJsonData, { + locale: "cy", + locationId: "1", + contentDate: mockArtefact.contentDate + }); + expect(res.render).toHaveBeenCalledWith("magistrates-standard-list", expect.any(Object)); + }); + + it("should return 500 on unexpected error", async () => { + req.query = { artefactId: "test-id" }; + vi.mocked(getArtefactById).mockRejectedValue(new Error("Database error")); + + await GET(req as Request, res as Response); + + expect(consoleErrorSpy).toHaveBeenCalledWith("[magistrates-standard-list] Unexpected error:", expect.any(Error)); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.render).toHaveBeenCalledWith("errors/common", expect.any(Object)); + }); + + it("should use provenance label for data source", async () => { + req.query = { artefactId: "test-id" }; + const mockArtefact = { + artefactId: "test-id", + locationId: "1", + listTypeId: 28, + contentDate: new Date("2025-01-13"), + sensitivity: "PUBLIC", + language: "ENGLISH", + displayFrom: new Date("2025-01-13"), + displayTo: new Date("2025-01-20"), + lastReceivedDate: new Date("2025-01-13"), + isFlatFile: false, + provenance: "MANUAL_UPLOAD", + supersededCount: 0, + noMatch: false + } as any; + const mockJsonData = { + document: { publicationDate: "2025-01-13T09:30:00.000Z" }, + venue: { venueAddress: { line: ["Court Address"] } }, + courtLists: [] + }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact); + vi.mocked(getPublicationJson).mockResolvedValue(mockJsonData); + vi.mocked(validateMagistratesStandardList).mockReturnValue({ + isValid: true, + errors: [], + schemaVersion: "1.0" + } as any); + vi.mocked(renderMagistratesStandardListData).mockReturnValue({ + header: { contentDate: "13 January 2025", publishedDate: "13 January 2025", publishedTime: "09:30", venueAddress: "" }, + listData: [] + } as any); + + await GET(req as Request, res as Response); + + expect(res.render).toHaveBeenCalledWith( + "magistrates-standard-list", + expect.objectContaining({ + dataSource: "Manual Upload" + }) + ); + }); + + it("should use raw provenance when label not found", async () => { + req.query = { artefactId: "test-id" }; + const mockArtefact = { + artefactId: "test-id", + locationId: "1", + listTypeId: 28, + contentDate: new Date("2025-01-13"), + sensitivity: "PUBLIC", + language: "ENGLISH", + displayFrom: new Date("2025-01-13"), + displayTo: new Date("2025-01-20"), + lastReceivedDate: new Date("2025-01-13"), + isFlatFile: false, + provenance: "UNKNOWN_PROVENANCE", + supersededCount: 0, + noMatch: false + } as any; + const mockJsonData = { + document: { publicationDate: "2025-01-13T09:30:00.000Z" }, + venue: {}, + courtLists: [] + }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact); + vi.mocked(getPublicationJson).mockResolvedValue(mockJsonData); + vi.mocked(validateMagistratesStandardList).mockReturnValue({ + isValid: true, + errors: [], + schemaVersion: "1.0" + } as any); + vi.mocked(renderMagistratesStandardListData).mockReturnValue({ + header: { contentDate: "13 January 2025", publishedDate: "13 January 2025", publishedTime: "09:30", venueAddress: "" }, + listData: [] + } as any); + + await GET(req as Request, res as Response); + + expect(res.render).toHaveBeenCalledWith( + "magistrates-standard-list", + expect.objectContaining({ + dataSource: "UNKNOWN_PROVENANCE" + }) + ); + }); + }); +}); diff --git a/apps/web/src/pages/(list-types)/magistrates-standard-list/index.ts b/apps/web/src/pages/(list-types)/magistrates-standard-list/index.ts new file mode 100644 index 000000000..00181d163 --- /dev/null +++ b/apps/web/src/pages/(list-types)/magistrates-standard-list/index.ts @@ -0,0 +1,26 @@ +import { + magistratesStandardListCy as cy, + magistratesStandardListEn as en, + type MagistratesStandardList, + renderMagistratesStandardListData, + validateMagistratesStandardList +} from "@hmcts/magistrates-standard-list"; +import { createListTypeHandler, resolveDataSource } from "../list-type-handler.js"; + +export const GET = createListTypeHandler({ + en, + cy, + validate: validateMagistratesStandardList, + logPrefix: "magistrates-standard-list", + checkAccess: true, + render: async ({ artefact, jsonData, locale, res }) => { + const t = locale === "cy" ? cy : en; + const { header, listData } = await renderMagistratesStandardListData(jsonData, { + locale, + locationId: artefact.locationId, + contentDate: artefact.contentDate + }); + const dataSource = resolveDataSource(artefact.provenance); + res.render("magistrates-standard-list", { en, cy, t, title: t.title, header, listData, dataSource }); + } +}); diff --git a/apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk b/apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk new file mode 100644 index 000000000..ece096d14 --- /dev/null +++ b/apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk @@ -0,0 +1,204 @@ +{% extends "layouts/base-template.njk" %} + +{% block page_content %} +
+
+ +

{{ t.pageTitle }} {{ header.locationName }}

+ +

{{ t.factLinkText }} {{ t.factAdditionalText }}

+ +

{{ t.listDate }} {{ header.contentDate }}

+

{{ t.lastUpdated }} {{ header.publishedDate }} {{ t.publishedAt }} {{ header.publishedTime }}

+ {% if header.venueAddress.length > 0 %} +

{% for line in header.venueAddress %}{{ line }}{% if not loop.last %}
{% endif %}{% endfor %}

+ {% endif %} + +
+
+

{{ t.restrictionInformationHeading }}

+

{{ t.restrictionInformationP1 }}

+
+ + + Warning + {{ t.restrictionInformationBoldText }} + +
+

{{ t.restrictionInformationP2 }}

+

{{ t.restrictionInformationP3 }}

+

{{ t.restrictionInformationP4 }}

+
    +
  • {{ t.restrictionBulletPoint1 }}
  • +
  • {{ t.restrictionBulletPoint2 }}
  • +
+
+
+ +
+

{{ t.searchCases }}

+ +
+ + {% if listData.length === 0 %} +

{{ t.noHearings }}

+ {% endif %} + +
+ {% set courtRoomCount = 0 %} + {% for room in listData %} + {% set courtRoomCount = courtRoomCount + 1 %} + + +
+ {% set sittingCount = 0 %} + {% for sitting in room.sittings %} + {% set sittingCount = sittingCount + 1 %} +
+
+

+ + {{ t.sittingAt }}{{ sitting.sittingHeading }} + +

+
+
+ {% set hearingCount = 0 %} + {% for hearing in sitting.hearings %} + {% set hearingCount = hearingCount + 1 %} + {% set borderClass = '' %} + {% if hearingCount < sitting.hearings | length %} + {% set borderClass = 'add-border-bottom govuk-!-margin-bottom-5' %} + {% endif %} +
+
+
+

{{ t.name }}{{ hearing.partyInfo.name }}

+ {% if hearing.applicationParticulars %} +

{{ t.applicationParticulars }}{{ hearing.applicationParticulars }}

+ {% endif %} + {% if hearing.partyInfo.dob or hearing.partyInfo.age %} +

+ {{ t.dobAndAge }} + {% if hearing.partyInfo.dob %}{{ hearing.partyInfo.dob }}{% endif %} + {% if hearing.partyInfo.age %} {{ t.age }} {{ hearing.partyInfo.age }}{% endif %} +

+ {% endif %} +

{{ t.address }}{{ hearing.partyInfo.address }}

+

{{ t.prosecutingAuthority }}{{ hearing.prosecutingAuthority }}

+

{{ t.attendanceMethod }}{{ hearing.attendanceMethod }}

+ {% if hearing.reportingRestriction %} +

{{ t.reportingRestrictions }}{{ hearing.reportingRestrictionDetails }}

+ {% endif %} +
+
+

{{ t.reference }}{{ hearing.reference }}

+ {% if hearing.applicationType %} +

{{ t.applicationType }}{{ hearing.applicationType }}

+ {% endif %} +

{{ t.asn }}{{ hearing.partyInfo.asn }}

+

{{ t.pncId }}{{ hearing.partyInfo.pncId }}

+

{{ t.hearingType }}{{ hearing.hearingType }}

+

{{ t.panel }}{{ hearing.panel }}

+
+
+ + {% set offenceCount = 0 %} + {% for offence in hearing.offences %} + {% if offence.offenceTitle %} + {% set offenceCount = offenceCount + 1 %} +
+
+ + + {{ offenceCount }}. + {% if offence.offenceCode %}{{ offence.offenceCode }}{% endif %} + {% if offence.offenceCode and offence.offenceTitle %} - {% endif %} + {{ offence.offenceTitle }} + + +
+ + + {% if offence.offenceWording %} + + + + {% endif %} + {% if offence.offenceLegislation %} + + + + + {% endif %} + {% if offence.offenceMaxPenalty %} + + + + + {% endif %} + {% if offence.plea %} + + + + + {% endif %} + {% if offence.pleaDate %} + + + + + {% endif %} + {% if offence.convictionDate %} + + + + + {% endif %} + {% if offence.adjournedDate %} + + + + + {% endif %} + {% if offence.reportingRestriction %} + + + + + {% endif %} + +
{{ offence.offenceWording }}
{{ t.legislation }}{{ offence.offenceLegislation }}
{{ t.maxPenalty }}{{ offence.offenceMaxPenalty }}
{{ t.plea }}{{ offence.plea }}
{{ t.dateOfPlea }}{{ offence.pleaDate }}
{{ t.convictedOn }}{{ offence.convictionDate }}
{{ t.adjournedFrom }}{{ offence.adjournedDate }} - {{ t.adjournedText }}
{{ t.reportingRestrictions }}{{ offence.reportingRestrictionDetails }}
+
+
+
+ {% endif %} + {% endfor %} +
+ {% endfor %} +
+
+ {% endfor %} +
+ {% endfor %} + +

+ {{ t.dataSource }}: {{ dataSource }} +

+ +

+ {{ t.linkToTop }} +

+
+ +
+
+{% endblock %} diff --git a/docs/tickets/771/plan.md b/docs/tickets/771/plan.md new file mode 100644 index 000000000..2d691c8f8 --- /dev/null +++ b/docs/tickets/771/plan.md @@ -0,0 +1,414 @@ +# #771: Magistrates Standard List - Technical Plan + +## 1. Technical Approach + +The magistrates-standard-list module follows the `civil-and-family-daily-cause-list` reference implementation with one key structural difference: magistrates lists always include a statutory reporting restriction section in both HTML and PDF output. This section is not conditional — it is rendered on every page and in every PDF. + +The architecture splits cleanly: + +- **`libs/list-types/magistrates-standard-list/`** — the library containing all business logic: renderer, validator, types, locales, PDF generator, JSON schema, config, and exports +- **`apps/web/src/pages/(list-types)/magistrates-standard-list/`** — the page controller, Nunjucks template, and co-located en/cy content files + +The page controller delegates everything to `createListTypeHandler` from `list-type-handler.ts`, which handles the full request lifecycle (missing artefactId, artefact not found, access control, JSON retrieval, schema validation, and error rendering). This is identical to the `civil-and-family-daily-cause-list` controller pattern. + +### Dependency on common libraries + +- `@hmcts/list-types-common` — `configureNunjucks`, `savePdfToStorage`, `createPdfErrorResult`, `loadTranslations`, PDF style constants +- `@hmcts/pdf-generation` — `generatePdfFromHtml` +- `@hmcts/publication` — `validateJson`, `PROVENANCE_LABELS`, `ValidationResult` +- `@hmcts/daily-cause-list-common` — NOT reused. The magistrates-standard-list has a different JSON structure (defendant-centric, not court-room/session/sitting hierarchy) and requires its own renderer +- `@hmcts/location` — `getLocationById` for resolving the Welsh court name + +--- + +## 2. Implementation Details + +### 2.1 Exact file structure + +``` +libs/list-types/magistrates-standard-list/ +├── package.json +├── tsconfig.json +└── src/ + ├── config.ts # moduleRoot, assets, schemaPath exports + ├── config.test.ts # Verifies config exports + ├── index.ts # Business logic re-exports + ├── index.test.ts # Verifies all expected exports present + ├── models/ + │ └── types.ts # TypeScript types for the magistrates-standard-list JSON + ├── schemas/ + │ └── magistrates-standard-list.json # JSON Schema for validation + ├── validation/ + │ └── json-validator.ts # validateMagistratesStandardList() + │ └── json-validator.test.ts + ├── rendering/ + │ └── renderer.ts # renderMagistratesStandardListData() + │ └── renderer.test.ts + ├── locales/ + │ └── en.ts # Shared translations (errorTitle, errorMessage, etc.) + │ └── cy.ts + ├── pdf/ + │ └── pdf-generator.ts # generateMagistratesStandardListPdf() + │ └── pdf-generator.test.ts + │ └── pdf-template.njk # Self-contained HTML PDF template + +apps/web/src/pages/(list-types)/magistrates-standard-list/ +├── index.ts # GET handler using createListTypeHandler +├── index.test.ts # Controller tests +├── en.ts # Page-specific English content +├── cy.ts # Page-specific Welsh content +└── magistrates-standard-list.njk # Nunjucks template for HTML view +``` + +### 2.2 Reporting restriction handling in `civil-and-family-daily-cause-list` and how to replicate + +In `civil-and-family-daily-cause-list`, reporting restrictions are **per-case inline annotations**. In the HTML template, after each table row, there is a conditional extra row: + +```njk +{% if case.formattedReportingRestriction | length %} + + + {{ t.reportingRestrictions }}: {{ case.formattedReportingRestriction }} + + +{% endif %} +``` + +The renderer in `daily-cause-list-common` populates this via `formatReportingRestrictions(caseItem)` which joins the `reportingRestrictionDetail` array. + +For **magistrates-standard-list**, the reporting restriction handling is different. Based on the ticket's acceptance criteria, there is: + +1. A **static statutory notice section** — always displayed, listing the three legal provisions (Children Act s49, s39, and Criminal Procedure (Insanity) Act s4A). This is pure content from locale strings, rendered unconditionally in both HTML and PDF. +2. Potentially **per-case reporting restriction flags** from the JSON data, which should also be rendered in the hearings table rows (following the same pattern as civil-and-family if present in the schema). + +The static restriction section is rendered at the top of the page, after the header/venue block and before the hearings table. In the PDF, it appears in the same logical position. + +Template structure for the static section (HTML): + +```njk +
+

{{ t.restrictionInformationHeading }}

+

{{ t.restrictionInformationP1 }}

+

{{ t.restrictionInformationBoldText }}

+

{{ t.restrictionInformationP2 }}

+
    +
  • {{ t.restrictionBulletPoint1 }}
  • +
  • {{ t.restrictionBulletPoint2 }}
  • +
+

{{ t.restrictionInformationP3 }}

+
    +
  • {{ t.restrictionBulletPoint1 }}
  • +
  • {{ t.restrictionBulletPoint2 }}
  • +
+

{{ t.restrictionInformationP4 }}

+
+``` + +### 2.3 TypeScript types for the magistrates-standard-list JSON structure + +The magistrates-standard-list JSON schema (from pip-data-management) follows a flat defendant-centric structure within the court list hierarchy. Based on the ticket columns (defendant name, case number, offence, plea, results, results proviso) and the known pip-data-management schema, the types are: + +```typescript +// libs/list-types/magistrates-standard-list/src/models/types.ts + +export interface MagistratesDefendant { + name?: string; + caseNumber?: string; + offence?: string[]; + plea?: string; + results?: string; + resultsProviso?: string; + reportingRestrictionDetail?: string[]; + individualDetails?: { + title?: string; + individualForenames?: string; + individualMiddleName?: string; + individualSurname?: string; + }; +} + +export interface MagistratesHearing { + hearingTime?: string; + defendant?: MagistratesDefendant[]; +} + +export interface MagistratesSession { + sittings?: MagistratesSitting[]; + judiciary?: Array<{ johKnownAs: string; isPresiding?: boolean }>; +} + +export interface MagistratesSitting { + sittingStart?: string; + hearing?: MagistratesHearing[]; +} + +export interface MagistratesCourtRoom { + courtRoomName?: string; + session?: MagistratesSession[]; +} + +export interface MagistratesCourtHouse { + courtHouseName?: string; + courtHouseAddress?: { + line?: string[]; + town?: string; + county?: string; + postCode?: string; + }; + courtRoom?: MagistratesCourtRoom[]; +} + +export interface MagistratesCourtList { + courtHouse: MagistratesCourtHouse; +} + +export interface MagistratesStandardListData { + document: { + publicationDate: string; + documentName?: string; + version?: string; + }; + venue: { + venueName: string; + venueAddress: { + line: string[]; + town?: string; + county?: string; + postCode: string; + }; + venueContact?: { + venueTelephone?: string; + venueEmail?: string; + }; + }; + courtLists: MagistratesCourtList[]; +} + +export interface RenderOptions { + locationId: string; + contentDate: Date; + locale: string; +} +``` + +Note: The actual schema structure needs to be verified against the pip-data-management source (`magistrates_standard_list.json`). There is ambiguity about whether defendants appear directly under sittings or within a hearing wrapper — the schema should be consulted to confirm. See Open Questions. + +### 2.4 Renderer transformation + +The renderer in `libs/list-types/magistrates-standard-list/src/rendering/renderer.ts` is responsible for: + +1. Looking up the location name from the DB (`getLocationById`) and preferring Welsh if locale is `cy` +2. Formatting the content date and last updated timestamp +3. Flattening nested defendant/case data into a structure suitable for template iteration +4. Formatting per-case reporting restrictions (joining the `reportingRestrictionDetail` array) for any inline annotations in the table + +The renderer returns: + +```typescript +{ + header: { + locationName: string; + addressLines: string[]; + contentDate: string; // formatted, locale-aware + lastUpdated: string; // formatted, locale-aware + }; + listData: MagistratesStandardListData; // mutated with computed fields +} +``` + +Unlike the civil-and-family renderer, there is no `openJustice` object because the magistrates-standard-list uses a reporting restriction section instead. + +### 2.5 Template structure (HTML) + +`apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk`: + +``` +extends layouts/base-template.njk +block page_content + - H1: venue name + list date (using t.title) + - Venue address lines + - List date line + - Last updated line + - Reporting restriction section (static, unconditional, using t.restrictionInformationHeading etc.) + - Open Justice collapsible (govuk-details, using t.openJusticeTitle) + - Case search input + - govuk-accordion with court rooms and sessions + - Table per session: Time | Defendant name | Case number | Offence | Plea | Results + - Inline reporting restriction row per case if present + - "No hearings today" message if courtLists is empty + - Back to top link + - Data source attribution +``` + +### 2.6 Template structure (PDF) + +`libs/list-types/magistrates-standard-list/src/pdf/pdf-template.njk`: + +Self-contained HTML using inline CSS (from `pdfStyles`). Same logical layout as the HTML view: + +``` +- Header: venue name, address, list date, last updated +- Reporting restriction section (static) +- Table per court room/session: defendant data +- Footer: data source +``` + +The PDF generator uses `configureNunjucks(__dirname)` pointing to the `pdf/` directory, and `PDF_BASE_STYLES` from `@hmcts/list-types-common`. + +### 2.7 Locale files + +The library provides shared locales for error messages and page metadata. Page-specific content (heading text, column labels, reporting restriction paragraphs) lives co-located with the page controller in `apps/web/src/pages/(list-types)/magistrates-standard-list/en.ts` and `cy.ts`. + +**Lib locales** (`libs/list-types/magistrates-standard-list/src/locales/en.ts`): + +```typescript +export const en = { + errorTitle: "Publication not available", + errorMessage: "This publication cannot be viewed at the moment. ...", + error403Title: "Access Denied", + error403Message: "You do not have permission to view this publication." +}; +``` + +**Page locales** (`apps/web/src/pages/(list-types)/magistrates-standard-list/en.ts`): + +All content keys from the ticket's acceptance criteria table, plus the reporting restriction keys, column headers, and common error texts. The page locale objects are passed as `en` and `cy` to the template. + +### 2.8 Registration + +**`apps/web/src/app.ts`**: + +```typescript +import { moduleRoot as magistratesStandardListModuleRoot } from "@hmcts/magistrates-standard-list/config"; + +// Add to modulePaths array: +magistratesStandardListModuleRoot, +``` + +**`apps/web/package.json`** — add dependency: + +```json +"@hmcts/magistrates-standard-list": "workspace:*", +``` + +**Root `tsconfig.json`** — add path alias: + +```json +"@hmcts/magistrates-standard-list": ["libs/list-types/magistrates-standard-list/src"], +``` + +**`libs/publication/src/processing/service.ts`** — add PDF generator to registry: + +```typescript +import { generateMagistratesStandardListPdf, type MagistratesStandardListData } from "@hmcts/magistrates-standard-list"; + +MAGISTRATES_STANDARD_LIST: (p) => generateMagistratesStandardListPdf({ ...p, jsonData: p.jsonData as MagistratesStandardListData }), +``` + +**`libs/location/src/list-type-data.ts`** — add list type entry with the correct ID (the next available id after 27): + +```typescript +{ + id: 28, // Verify this is correct against the DB seed + name: "MAGISTRATES_STANDARD_LIST", + englishFriendlyName: "Magistrates Standard List", + welshFriendlyName: "Rhestr Safonol Llys Ynadon", + provenance: "CRIME_IDAM", + urlPath: "magistrates-standard-list", + isNonStrategic: false, + defaultSensitivity: "Public", + subJurisdictionIds: [7] +} +``` + +**`libs/publication/package.json`** — add dependency on `@hmcts/magistrates-standard-list`. + +**`libs/notifications/package.json`** — if email summary is added later. + +### 2.9 `package.json` for the lib + +The lib needs the same build scripts as `civil-and-family-daily-cause-list`: + +```json +"build": "tsc && yarn build:pdf-templates", +"build:pdf-templates": "mkdir -p dist/pdf && cp src/pdf/*.njk dist/pdf/", +``` + +No `build:nunjucks` script is needed because the HTML template lives in `apps/web/src/pages/`, not in the lib. + +--- + +## 3. Error Handling and Edge Cases + +- **Missing `artefactId`**: `createListTypeHandler` returns 400 before any DB lookup +- **Artefact not found**: 404 via `getArtefactById` returning null +- **Access denied**: 403 if `checkAccess: true` and `canAccessPublicationData` returns false. The access control behaviour for magistrates-standard-list (whether `checkAccess` should be true or false) needs confirming — `civil-and-family-daily-cause-list` uses `checkAccess: true`. Magistrates lists use `CRIME_IDAM` provenance, so the same access control logic should apply. +- **JSON blob not found**: 404 logged with artefact ID +- **JSON schema validation failure**: 400 with validation errors logged +- **Empty court list**: Template renders `t.noHearings` message instead of an empty accordion +- **Missing defendant name**: Graceful empty string, not a crash +- **Locale fallback**: Default to `"en"` if `res.locals.locale` is not set + +--- + +## 4. Acceptance Criteria Mapping + +| Criterion | Implementation | +|-----------|---------------| +| New lib at `libs/list-types/magistrates-standard-list/` | Lib scaffolded with all files listed in section 2.1 | +| Page at `GET /magistrates-standard-list?artefactId=` | Page controller at `apps/web/src/pages/(list-types)/magistrates-standard-list/index.ts` | +| Displays venue name, address, content date, last updated | `header` object from renderer, passed to template | +| Hearings table with correct columns | Template renders defendant-centric table using court list data | +| Reporting restriction section | Static section in HTML template using locale keys; same in PDF | +| Open Justice collapsible | `govuk-details` component with `t.openJusticeTitle` | +| Case search input | HTML input element with `id="case-search-input"` | +| Data source attribution | `dataSource` variable resolved via `PROVENANCE_LABELS` | +| Returns 400 if `artefactId` missing | `createListTypeHandler` handles this before any DB call | +| Returns 404 if artefact not found | `createListTypeHandler` handles this | +| Returns 403 if no access | `createListTypeHandler` with `checkAccess: true` | +| Returns 400 if JSON fails validation | `createListTypeHandler` validates against schema | +| PDF generated matching HTML structure | `pdf-template.njk` mirrors HTML layout | +| PDF includes reporting restriction | Static section in `pdf-template.njk` | +| PDF saved to storage | `savePdfToStorage` via `@hmcts/list-types-common` | +| Welsh via `?lng=cy` | `cy.ts` locale files, locale resolved from `res.locals.locale` | +| Module registered in `apps/web/src/app.ts` | `moduleRoot` import added to `modulePaths` | +| Path alias in `tsconfig.json` | Entry added to root `tsconfig.json` paths | +| Package added to `apps/web/package.json` | Workspace dependency added | +| Unit tests pass | Tests for renderer, validator, PDF generator, page controller, config | +| `yarn test` passes | All test suites green | + +--- + +## 5. Open Questions (CLARIFICATIONS NEEDED) + +### 5.1 Magistrates-standard-list JSON schema structure + +The exact field names and nesting depth for defendants within the JSON need to be confirmed against the pip-data-management source file (`src/main/resources/schemas/magistrates_standard_list.json`). Key questions: + +- Are defendants nested directly under `sitting`, or within a `hearing` wrapper under `sitting`? +- What is the exact field name for the defendant's name? (`name`, `individualDetails`, or a combined string?) +- Is `offence` a single string or an array? +- Is `hearingTime` a field on the sitting or on the defendant/case? +- Are there any required fields vs optional fields for the table columns (offence, plea, results, resultsProviso)? + +Without the source schema, the types in section 2.3 are best-effort approximations based on the ticket's column definitions. + +### 5.2 List type ID for MAGISTRATES_STANDARD_LIST + +The `list-type-data.ts` currently has IDs up to 27. The correct next ID (28) needs to be confirmed against the actual database state and any pending migrations. If there is already a DB record for this list type, the ID must match exactly. + +### 5.3 `checkAccess` flag for the page controller + +The ticket says to follow `civil-and-family-daily-cause-list` which uses `checkAccess: true`. Magistrates lists use `CRIME_IDAM` provenance (same as magistrates-public-list, id 4). Confirm whether access control should be applied the same way for the standard list. + +### 5.4 Open Justice section content + +The ticket specifies `openJusticeTitle` as a locale key but does not provide the full Open Justice paragraph text for magistrates-standard-list. Should it reuse the same Open Justice paragraphs as `civil-and-family-daily-cause-list`, or is there magistrates-specific text? This affects whether `venueContact` data is needed from the JSON. + +### 5.5 Reporting restriction section bullet point structure + +The ticket shows the same two bullet points listed under both s49 (P2) and s39 (P3). Confirm this is the intended rendering — two bullet point lists with identical items under two different statutory provisions — before implementing the template. + +### 5.6 `libs/publication` dependency + +Adding `MAGISTRATES_STANDARD_LIST` to the `PDF_GENERATOR_REGISTRY` in `libs/publication/src/processing/service.ts` requires adding `@hmcts/magistrates-standard-list` as a dependency in `libs/publication/package.json`. Confirm this is acceptable and will not create a circular dependency. diff --git a/docs/tickets/771/review.md b/docs/tickets/771/review.md new file mode 100644 index 000000000..ef8a5e437 --- /dev/null +++ b/docs/tickets/771/review.md @@ -0,0 +1,356 @@ +# Code Review: Issue #771 — Magistrates Standard List + +## Summary + +This implementation adds the `magistrates-standard-list` list type as a new lib (`libs/list-types/magistrates-standard-list/`) and a corresponding page controller in `apps/web/src/pages/(list-types)/magistrates-standard-list/`. It follows the established `civil-and-family-daily-cause-list` module pattern and is largely well-structured. The renderer, validator, PDF generator, controller, and tests are all present and all 33 tests pass. + +However, there are several issues that must be addressed before deployment: + +1. The `libs/publication/package.json` is missing the `@hmcts/magistrates-standard-list` dependency even though `service.ts` imports from it — this will cause a production build failure. +2. The `libs/list-types/magistrates-standard-list/package.json` is missing the `build:pdf-templates` script, which means the PDF template will not be copied to `dist/` during production builds. +3. The locale content deviates from the acceptance criteria in several key fields. +4. The HTML template is missing the Open Justice collapsible section and the Back to Top link, both of which are acceptance criteria. +5. Multiple custom inline styles in the HTML template violate GOV.UK Design System conventions. + +--- + +## CRITICAL Issues + +### 1. Missing dependency in `libs/publication/package.json` + +**File:** `/Users/kian.kwa/IdeaProjects/cath-service/libs/publication/package.json` + +**Problem:** `libs/publication/src/processing/service.ts` (line 10) imports directly from `@hmcts/magistrates-standard-list`, but `@hmcts/magistrates-standard-list` is not listed in `libs/publication/package.json` dependencies. The other list type packages (e.g., `@hmcts/civil-and-family-daily-cause-list`, `@hmcts/care-standards-tribunal-weekly-hearing-list`) are all explicitly listed. This package is absent. + +**Impact:** The production build for `libs/publication` will fail because the dependency cannot be resolved. PDF generation for the magistrates standard list will be broken at the build level in production. + +**Solution:** Add `"@hmcts/magistrates-standard-list": "workspace:*"` to the `dependencies` object in `libs/publication/package.json`. + +--- + +### 2. Missing `build:pdf-templates` script in `package.json` + +**File:** `/Users/kian.kwa/IdeaProjects/cath-service/libs/list-types/magistrates-standard-list/package.json` + +**Problem:** The `build` script is `"tsc"` only. The reference implementation (`civil-and-family-daily-cause-list`) uses `"build": "tsc && yarn build:nunjucks && yarn build:pdf-templates"` to copy `.njk` files to `dist/pdf/`. Without the copy step, `pdf-template.njk` will not exist at `dist/pdf/pdf-template.njk` in production, and `configureNunjucks(__dirname)` in `pdf-generator.ts` will resolve to `dist/pdf/` but find no template there. + +**Impact:** PDF generation will throw a template-not-found error in production. Development works only because the `default` export condition in `package.json` uses the `src/` path directly. + +**Solution:** Add the build script: +```json +"build": "tsc && yarn build:pdf-templates", +"build:pdf-templates": "mkdir -p dist/pdf && cp src/pdf/*.njk dist/pdf/" +``` + +--- + +### 3. Acceptance criteria locale keys not matching — `restrictionInformationHeading`, bullet points, and paragraph structure + +**File:** `/Users/kian.kwa/IdeaProjects/cath-service/libs/list-types/magistrates-standard-list/src/locales/en.ts` (lines 10–20) + +**Problem:** The ticket acceptance criteria (and the plan's template structure) specify the reporting restriction section should contain the statutory text from Children and Young Persons Act 1933 s.49, s.39, and the Criminal Procedure (Insanity) Act 1964 s.4A. The implementation uses entirely different text: generic advice about checking restrictions before publication rather than the statutory notices. The ticket specifies: + +- `restrictionInformationHeading`: "Restriction information" +- `restrictionBulletPoint1`: "the name, address or school of the young person" +- `restrictionBulletPoint2`: "any particulars calculated to lead to the identification of the young person" + +The implementation has: +- `restrictionInformationHeading`: "Restrictions on publishing or writing about these cases" (not matching) +- `restrictionBulletPoint1`: "the court directly" (not matching) +- `restrictionBulletPoint2`: "HM Courts and Tribunals Service on 0330 808 4407" (not matching) + +The Welsh locale (`cy.ts`) similarly does not match the specified Welsh keys. + +**Impact:** The page will not display the legally mandated statutory reporting restriction notices that the acceptance criteria require. This is a statutory compliance issue for a court publication service. + +**Solution:** Update the locale files to use the exact content specified in the ticket acceptance criteria for `restrictionInformationHeading`, `restrictionInformationP1` through `restrictionInformationP4`, `restrictionBulletPoint1`, `restrictionBulletPoint2`, and all corresponding Welsh values. + +--- + +## HIGH PRIORITY Issues + +### 4. Open Justice collapsible section is absent from the HTML template + +**File:** `/Users/kian.kwa/IdeaProjects/cath-service/apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk` + +**Problem:** The ticket acceptance criteria states "Open Justice collapsible section present". The plan specifies a `govuk-details` component with `t.openJusticeTitle`. The template has no Open Justice section at all. The locale files do not contain `openJusticeTitle` or any Open Justice content. The only `govuk-details` usage in the template is for individual offence disclosure, not a top-level Open Justice section. + +**Impact:** An acceptance criterion is unmet. The page differs from the specified design. + +**Solution:** Add a `govuk-details` component between the reporting restriction section and the case search input, using `t.openJusticeTitle` as the summary text. + +--- + +### 5. "Back to top" link is absent from the HTML template + +**File:** `/Users/kian.kwa/IdeaProjects/cath-service/apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk` + +**Problem:** The ticket acceptance criteria specifies `linkToTop` as a required content key ("Back to top" / "Yn ôl i'r brig"), and the plan confirms a back-to-top link at the bottom of the page. Neither the locale files nor the template contain this key or link. + +**Impact:** An acceptance criterion is unmet. + +**Solution:** Add `linkToTop` to both locale files and add an anchor link at the bottom of the template (before or after the data source attribution) linking to `#top` or `#main-content`. + +--- + +### 6. Inline styles in the HTML template violate GOV.UK Design System conventions + +**File:** `/Users/kian.kwa/IdeaProjects/cath-service/apps/web/src/pages/(list-types)/magistrates-standard-list/magistrates-standard-list.njk` (lines 3–13, and line 213) + +**Problem:** The template uses a ` + + + +
+

{{ t.pageTitle }} {{ header.locationName }}

+

{{ t.factLinkText }} {{ t.factAdditionalText }}

+
+

{{ t.listDate }} {{ header.contentDate }}

+

{{ t.lastUpdated }} {{ header.publishedDate }} {{ t.publishedAt }} {{ header.publishedTime }}

+
+ {% if header.venueAddress.length > 0 %} +

{% for line in header.venueAddress %}{{ line }}{% if not loop.last %}
{% endif %}{% endfor %}

+ {% endif %} +
+ +
+

{{ t.restrictionInformationHeading }}

+

{{ t.restrictionInformationP1 }}

+
+ + {{ t.restrictionInformationBoldText }} +
+

{{ t.restrictionInformationP2 }}

+

{{ t.restrictionInformationP3 }}

+

{{ t.restrictionInformationP4 }}

+
    +
  • {{ t.restrictionBulletPoint1 }}
  • +
  • {{ t.restrictionBulletPoint2 }}
  • +
+
+ + {% if listData.length === 0 %} +

{{ t.noHearings }}

+ {% endif %} + + {% for room in listData %} +
+ {% if room.courtHouseName %} +
{{ room.courtHouseName }}
+ {% endif %} + {% if room.lja %} +
{{ t.lja }}: {{ room.lja }}
+ {% endif %} +
{{ room.courtRoomName }}
+ + {% for sitting in room.sittings %} +
+
{{ t.sittingAt }}{{ sitting.sittingHeading }}
+ + {% for hearing in sitting.hearings %} +
+
+
+

{{ t.name }}{{ hearing.partyInfo.name }}

+ {% if hearing.applicationParticulars %} +

{{ t.applicationParticulars }}{{ hearing.applicationParticulars }}

+ {% endif %} + {% if hearing.partyInfo.dob or hearing.partyInfo.age %} +

+ {{ t.dobAndAge }} + {% if hearing.partyInfo.dob %}{{ hearing.partyInfo.dob }}{% endif %} + {% if hearing.partyInfo.age %} {{ t.age }} {{ hearing.partyInfo.age }}{% endif %} +

+ {% endif %} +

{{ t.address }}{{ hearing.partyInfo.address }}

+

{{ t.prosecutingAuthority }}{{ hearing.prosecutingAuthority }}

+

{{ t.attendanceMethod }}{{ hearing.attendanceMethod }}

+ {% if hearing.reportingRestriction %} +

{{ t.reportingRestrictions }}{{ hearing.reportingRestrictionDetails }}

+ {% endif %} +
+
+ {% if hearing.reference %} +

{{ t.reference }}{{ hearing.reference }}

+ {% endif %} + {% if hearing.applicationType %} +

{{ t.applicationType }}{{ hearing.applicationType }}

+ {% endif %} +

{{ t.asn }}{{ hearing.partyInfo.asn }}

+

{{ t.pncId }}{{ hearing.partyInfo.pncId }}

+

{{ t.hearingType }}{{ hearing.hearingType }}

+

{{ t.panel }}{{ hearing.panel }}

+
+
+ + {% set offenceCount = 0 %} + {% for offence in hearing.offences %} + {% if offence.offenceTitle %} + {% set offenceCount = offenceCount + 1 %} +
+

{{ offenceCount }}. {% if offence.offenceCode %}{{ offence.offenceCode }} - {% endif %}{{ offence.offenceTitle }}

+ {% if offence.offenceWording %}

{{ offence.offenceWording }}

{% endif %} + {% if offence.offenceLegislation %}

{{ t.legislation }}: {{ offence.offenceLegislation }}

{% endif %} + {% if offence.offenceMaxPenalty %}

{{ t.maxPenalty }}: {{ offence.offenceMaxPenalty }}

{% endif %} + {% if offence.plea %}

{{ t.plea }}: {{ offence.plea }}

{% endif %} + {% if offence.pleaDate %}

{{ t.dateOfPlea }}: {{ offence.pleaDate }}

{% endif %} + {% if offence.convictionDate %}

{{ t.convictedOn }}: {{ offence.convictionDate }}

{% endif %} + {% if offence.adjournedDate %}

{{ t.adjournedFrom }}: {{ offence.adjournedDate }} - {{ t.adjournedText }}

{% endif %} + {% if offence.reportingRestriction %}

{{ t.reportingRestrictions }} {{ offence.reportingRestrictionDetails }}

{% endif %} +
+ {% endif %} + {% endfor %} +
+ {% endfor %} +
+ {% endfor %} +
+ {% endfor %} + + + + diff --git a/libs/list-types/magistrates-standard-list/src/rendering/renderer.test.ts b/libs/list-types/magistrates-standard-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..3199a88e9 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/rendering/renderer.test.ts @@ -0,0 +1,669 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MagistratesStandardList } from "../models/types.js"; +import { renderMagistratesStandardListData } from "./renderer.js"; + +vi.mock("@hmcts/location", () => ({ + getLocationById: vi.fn() +})); + +import { getLocationById } from "@hmcts/location"; + +const MINIMAL_JSON: MagistratesStandardList = { + document: { + publicationDate: "2025-01-13T09:30:00.000Z" + }, + venue: { + venueAddress: { + line: ["THE LAW COURTS", "CROWN SQUARE"], + town: "Manchester", + county: "Greater Manchester", + postCode: "M3 3FL" + } + }, + courtLists: [] +}; + +const FULL_JSON: MagistratesStandardList = { + document: { + publicationDate: "2025-01-13T09:30:00.000Z" + }, + venue: { + venueAddress: { + line: ["THE LAW COURTS"], + town: "Manchester", + postCode: "M3 3FL" + } + }, + courtLists: [ + { + courtHouse: { + courtHouseName: "Manchester Magistrates Court", + lja: "Greater Manchester", + courtRoom: [ + { + courtRoomName: "Court 1", + session: [ + { + judiciary: [{ johKnownAs: "District Judge Smith", isPresiding: true }], + sittings: [ + { + sittingStart: "2025-01-13T10:00:00.000Z", + hearing: [ + { + hearingType: "First hearing", + panel: "ADULT", + channel: ["VIDEO HEARING"], + case: [ + { + caseUrn: "URN12345", + reportingRestriction: false, + party: [ + { + partyRole: "DEFENDANT", + individualDetails: { + individualForenames: "John", + individualSurname: "Smith", + dateOfBirth: "1990-05-15", + age: 34, + address: { + line: ["12 High Street"], + town: "Salford", + postCode: "M5 1AB" + }, + asn: "ASN123456", + pncId: "PNC789" + }, + offence: [ + { + offenceCode: "DD01", + offenceTitle: "Drink driving", + offenceWording: "Driving whilst over the legal alcohol limit", + offenceMaxPen: "6 months imprisonment", + plea: "GUILTY", + pleaDate: "2025-01-10T00:00:00.000Z", + offenceLegislation: "Road Traffic Act 1988" + } + ] + }, + { + partyRole: "PROSECUTING_AUTHORITY", + organisationDetails: { + organisationName: "Crown Prosecution Service" + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + } + ] +}; + +describe("renderMagistratesStandardListData", () => { + beforeEach(() => { + (getLocationById as ReturnType).mockResolvedValue({ name: "Manchester Magistrates Court", welshName: "Llys Ynadon Manceinion" }); + }); + + it("should return correct header structure with minimal JSON", async () => { + const result = await renderMagistratesStandardListData(MINIMAL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13") + }); + + expect(result.header).toHaveProperty("locationName"); + expect(result.header).toHaveProperty("contentDate"); + expect(result.header).toHaveProperty("publishedDate"); + expect(result.header).toHaveProperty("publishedTime"); + expect(result.header).toHaveProperty("venueAddress"); + }); + + it("should resolve location name from locationId", async () => { + const result = await renderMagistratesStandardListData(MINIMAL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13") + }); + + expect(result.header.locationName).toBe("Manchester Magistrates Court"); + }); + + it("should use Welsh location name for cy locale", async () => { + const result = await renderMagistratesStandardListData(MINIMAL_JSON, { + locale: "cy", + locationId: "123", + contentDate: new Date("2025-01-13") + }); + + expect(result.header.locationName).toBe("Llys Ynadon Manceinion"); + }); + + it("should fall back to empty string when location not found", async () => { + (getLocationById as ReturnType).mockResolvedValue(undefined); + + const result = await renderMagistratesStandardListData(MINIMAL_JSON, { + locale: "en", + locationId: "999", + contentDate: new Date("2025-01-13") + }); + + expect(result.header.locationName).toBe(""); + }); + + it("should format header dates and times correctly", async () => { + const result = await renderMagistratesStandardListData(MINIMAL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + expect(result.header.publishedDate).toContain("2025"); + expect(result.header.publishedTime).toMatch(/^\d{1,2}:\d{2}(am|pm)$/); + expect(result.header.venueAddress).toContain("Manchester"); + }); + + it("should return empty listData for empty courtLists", async () => { + const result = await renderMagistratesStandardListData(MINIMAL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13") + }); + + expect(result.listData).toHaveLength(0); + }); + + it("should correctly render a court room with one hearing", async () => { + const result = await renderMagistratesStandardListData(FULL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + expect(result.listData).toHaveLength(1); + const room = result.listData[0]; + expect(room.courtHouseName).toBe("Manchester Magistrates Court"); + expect(room.lja).toBe("Greater Manchester"); + expect(room.courtRoomName).toBe("Court 1: District Judge Smith"); + }); + + it("should include judiciary names in the court room name", async () => { + const result = await renderMagistratesStandardListData(FULL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + expect(result.listData[0].courtRoomName).toBe("Court 1: District Judge Smith"); + }); + + it("should format individual party name as Surname, Forename", async () => { + const result = await renderMagistratesStandardListData(FULL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + const hearing = result.listData[0].sittings[0].hearings[0]; + expect(hearing.partyInfo.name).toBe("Smith, John"); + }); + + it("should include gender in individual name when present", async () => { + const jsonWithGender: MagistratesStandardList = { + ...FULL_JSON, + courtLists: [ + { + courtHouse: { + courtHouseName: "Test Court", + courtRoom: [ + { + courtRoomName: "Court 1", + session: [ + { + sittings: [ + { + sittingStart: "2025-01-13T10:00:00.000Z", + hearing: [ + { + case: [ + { + caseUrn: "TEST001", + party: [ + { + partyRole: "DEFENDANT", + individualDetails: { + individualForenames: "Jane", + individualSurname: "Doe", + gender: "female" + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + } + ] + }; + + const result = await renderMagistratesStandardListData(jsonWithGender, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + expect(result.listData[0].sittings[0].hearings[0].partyInfo.name).toBe("Doe, Jane (female)"); + }); + + it("should mark in-custody defendants with an asterisk", async () => { + const jsonWithCustody: MagistratesStandardList = { + ...FULL_JSON, + courtLists: [ + { + courtHouse: { + courtHouseName: "Test Court", + courtRoom: [ + { + courtRoomName: "Court 1", + session: [ + { + sittings: [ + { + sittingStart: "2025-01-13T10:00:00.000Z", + hearing: [ + { + case: [ + { + caseUrn: "TEST002", + party: [ + { + partyRole: "DEFENDANT", + individualDetails: { + individualForenames: "Bob", + individualSurname: "Jones", + inCustody: true + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + } + ] + }; + + const result = await renderMagistratesStandardListData(jsonWithCustody, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + expect(result.listData[0].sittings[0].hearings[0].partyInfo.name).toBe("Jones, Bob*"); + }); + + it("should process offences correctly", async () => { + const result = await renderMagistratesStandardListData(FULL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + const offences = result.listData[0].sittings[0].hearings[0].offences; + expect(offences).toHaveLength(1); + expect(offences[0].offenceCode).toBe("DD01"); + expect(offences[0].offenceTitle).toBe("Drink driving"); + expect(offences[0].offenceLegislation).toBe("Road Traffic Act 1988"); + expect(offences[0].plea).toBe("GUILTY"); + expect(offences[0].pleaDate).toBe("10/01/2025"); + expect(offences[0].offenceMaxPenalty).toBe("6 months imprisonment"); + }); + + it("should include reporting restriction details on case", async () => { + const jsonWithRestriction: MagistratesStandardList = { + ...FULL_JSON, + courtLists: [ + { + courtHouse: { + courtHouseName: "Test Court", + courtRoom: [ + { + courtRoomName: "Court 1", + session: [ + { + sittings: [ + { + sittingStart: "2025-01-13T10:00:00.000Z", + hearing: [ + { + case: [ + { + caseUrn: "TEST003", + reportingRestriction: true, + reportingRestrictionDetails: ["Restriction A", "Restriction B"], + party: [ + { + partyRole: "DEFENDANT", + individualDetails: { + individualForenames: "Test", + individualSurname: "Person" + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + } + ] + }; + + const result = await renderMagistratesStandardListData(jsonWithRestriction, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + const hearing = result.listData[0].sittings[0].hearings[0]; + expect(hearing.reportingRestriction).toBe(true); + expect(hearing.reportingRestrictionDetails).toBe("Restriction A, Restriction B"); + }); + + it("should process applications with subject party", async () => { + const jsonWithApplication: MagistratesStandardList = { + document: { publicationDate: "2025-01-13T09:30:00.000Z" }, + venue: {}, + courtLists: [ + { + courtHouse: { + courtHouseName: "Test Court", + courtRoom: [ + { + courtRoomName: "Court 1", + session: [ + { + sittings: [ + { + sittingStart: "2025-01-13T10:00:00.000Z", + hearing: [ + { + application: [ + { + applicationReference: "APP001", + applicationType: "Restraining Order", + applicationParticulars: "Urgent application", + party: [ + { + subject: true, + organisationDetails: { + organisationName: "Respondent Ltd" + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + } + ] + }; + + const result = await renderMagistratesStandardListData(jsonWithApplication, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + expect(result.listData).toHaveLength(1); + const hearing = result.listData[0].sittings[0].hearings[0]; + expect(hearing.partyInfo.name).toBe("Respondent Ltd"); + expect(hearing.reference).toBe("APP001"); + expect(hearing.applicationType).toBe("Restraining Order"); + expect(hearing.applicationParticulars).toBe("Urgent application"); + }); + + it("should find prosecuting authority from parties", async () => { + const result = await renderMagistratesStandardListData(FULL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + const hearing = result.listData[0].sittings[0].hearings[0]; + expect(hearing.prosecutingAuthority).toBe("Crown Prosecution Service"); + }); + + it("should format attendance method from channels", async () => { + const result = await renderMagistratesStandardListData(FULL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + const hearing = result.listData[0].sittings[0].hearings[0]; + expect(hearing.attendanceMethod).toBe("VIDEO HEARING"); + }); + + it("should merge sittings when two hearings share the same sitting time", async () => { + const jsonWithTwoHearingsSameSitting: MagistratesStandardList = { + document: { publicationDate: "2025-01-13T09:30:00.000Z" }, + venue: {}, + courtLists: [ + { + courtHouse: { + courtHouseName: "Test Court", + courtRoom: [ + { + courtRoomName: "Court 1", + session: [ + { + sittings: [ + { + sittingStart: "2025-01-13T10:00:00.000Z", + hearing: [ + { + case: [ + { + caseUrn: "A001", + party: [{ partyRole: "DEFENDANT", individualDetails: { individualForenames: "Alice", individualSurname: "One" } }] + } + ] + }, + { + case: [ + { + caseUrn: "A002", + party: [{ partyRole: "DEFENDANT", individualDetails: { individualForenames: "Bob", individualSurname: "Two" } }] + } + ] + } + ] + } + ] + } + ] + } + ] + } + } + ] + }; + + const result = await renderMagistratesStandardListData(jsonWithTwoHearingsSameSitting, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + expect(result.listData[0].sittings).toHaveLength(1); + expect(result.listData[0].sittings[0].hearings).toHaveLength(2); + expect(result.listData[0].sittings[0].hearings[0].partyInfo.name).toBe("One, Alice"); + expect(result.listData[0].sittings[0].hearings[1].partyInfo.name).toBe("Two, Bob"); + }); + + it("should merge court rooms when same courtHouseName and courtRoomName appear in multiple sessions", async () => { + const jsonWithTwoSessions: MagistratesStandardList = { + document: { publicationDate: "2025-01-13T09:30:00.000Z" }, + venue: {}, + courtLists: [ + { + courtHouse: { + courtHouseName: "Test Court", + courtRoom: [ + { + courtRoomName: "Court 1", + session: [ + { + sittings: [ + { + sittingStart: "2025-01-13T10:00:00.000Z", + hearing: [ + { + case: [ + { + caseUrn: "S001", + party: [{ partyRole: "DEFENDANT", individualDetails: { individualForenames: "First", individualSurname: "Session" } }] + } + ] + } + ] + } + ] + }, + { + sittings: [ + { + sittingStart: "2025-01-13T14:00:00.000Z", + hearing: [ + { + case: [ + { + caseUrn: "S002", + party: [{ partyRole: "DEFENDANT", individualDetails: { individualForenames: "Second", individualSurname: "Session" } }] + } + ] + } + ] + } + ] + } + ] + } + ] + } + } + ] + }; + + const result = await renderMagistratesStandardListData(jsonWithTwoSessions, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + expect(result.listData).toHaveLength(1); + expect(result.listData[0].sittings).toHaveLength(2); + }); + + it("should return empty name for party with neither individual nor organisation details", async () => { + const jsonWithEmptyParty: MagistratesStandardList = { + document: { publicationDate: "2025-01-13T09:30:00.000Z" }, + venue: {}, + courtLists: [ + { + courtHouse: { + courtHouseName: "Test Court", + courtRoom: [ + { + courtRoomName: "Court 1", + session: [ + { + sittings: [ + { + sittingStart: "2025-01-13T10:00:00.000Z", + hearing: [ + { + case: [ + { + caseUrn: "E001", + party: [{ partyRole: "DEFENDANT" }] + } + ] + } + ] + } + ] + } + ] + } + ] + } + } + ] + }; + + const result = await renderMagistratesStandardListData(jsonWithEmptyParty, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + expect(result.listData[0].sittings[0].hearings[0].partyInfo.name).toBe(""); + }); + + it("should format time without minutes when on the hour", async () => { + const result = await renderMagistratesStandardListData(FULL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-13T00:00:00.000Z") + }); + + expect(result.listData[0].sittings[0].sittingHeading).toMatch(/^10am$/); + }); + + it("should use 2-digit day for content and published dates", async () => { + const result = await renderMagistratesStandardListData(MINIMAL_JSON, { + locale: "en", + locationId: "123", + contentDate: new Date("2025-01-03T00:00:00.000Z") + }); + + expect(result.header.contentDate).toMatch(/^03 /); + expect(result.header.publishedDate).toMatch(/^13 /); + }); +}); diff --git a/libs/list-types/magistrates-standard-list/src/rendering/renderer.ts b/libs/list-types/magistrates-standard-list/src/rendering/renderer.ts new file mode 100644 index 000000000..54d6d372b --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/rendering/renderer.ts @@ -0,0 +1,298 @@ +import { getLocationById } from "@hmcts/location"; +import type { + Application, + Case, + CourtList, + IndividualDetails, + Judiciary, + MagistratesStandardList, + OrganisationDetails, + Party, + RenderedCourtRoom, + RenderedHearing, + RenderedMagistratesStandardListHeader, + RenderedOffence, + RenderedPartyInfo, + RenderedSitting, + Sitting +} from "../models/types.js"; + +export interface RenderOptions { + locale: string; + locationId: string; + contentDate: Date; +} + +export interface RenderedMagistratesStandardListData { + header: RenderedMagistratesStandardListHeader; + listData: RenderedCourtRoom[]; +} + +export async function renderMagistratesStandardListData( + jsonData: MagistratesStandardList, + options: RenderOptions +): Promise { + const header = await buildHeader(jsonData, options); + const listData = processCourtLists(jsonData.courtLists, options.locale); + return { header, listData }; +} + +async function buildHeader(jsonData: MagistratesStandardList, options: RenderOptions): Promise { + const pubDateTime = jsonData.document.publicationDate; + const { date: publishedDate, time: publishedTime } = formatDateAndTime(pubDateTime, options.locale); + const location = await getLocationById(Number.parseInt(options.locationId, 10)); + const locationName = options.locale === "cy" && location?.welshName ? location.welshName : (location?.name ?? ""); + return { + locationName, + contentDate: formatDate(options.contentDate, options.locale), + publishedDate, + publishedTime, + venueAddress: formatAddressLines(jsonData.venue?.venueAddress) + }; +} + +function processCourtLists(courtLists: CourtList[], locale: string): RenderedCourtRoom[] { + const result: RenderedCourtRoom[] = []; + + for (const courtList of courtLists) { + const { courtHouse } = courtList; + for (const courtRoom of courtHouse.courtRoom) { + for (const session of courtRoom.session) { + const sittings: RenderedSitting[] = []; + for (const sitting of session.sittings) { + processSitting(sitting, sittings, locale); + } + + if (sittings.length === 0) continue; + + const courtRoomName = formatCourtRoomWithJudiciary(courtRoom.courtRoomName, session.judiciary ?? []); + const courtHouseName = courtHouse.courtHouseName ?? ""; + const existingIndex = result.findIndex((r) => r.courtRoomName === courtRoomName && r.courtHouseName === courtHouseName); + + if (existingIndex === -1) { + result.push({ + courtHouseName, + courtRoomName, + lja: courtHouse.lja ?? "", + sittings + }); + } else { + result[existingIndex].sittings.push(...sittings); + } + } + } + } + + return result; +} + +function processSitting(sitting: Sitting, sittings: RenderedSitting[], locale: string): void { + const sittingStartTime = formatSittingTime(sitting.sittingStart); + + for (const hearing of sitting.hearing) { + const attendanceMethod = (hearing.channel ?? []).filter(Boolean).join(", "); + const hearingType = hearing.hearingType ?? ""; + const panel = hearing.panel ?? ""; + + for (const caseItem of hearing.case ?? []) { + const baseHearingInfo = buildCaseHearingInfo(caseItem, sittingStartTime, attendanceMethod, hearingType, panel); + for (const party of caseItem.party ?? []) { + if (party.partyRole === "DEFENDANT") { + addHearingToSittings(sittings, baseHearingInfo, party, locale); + } + } + } + + for (const application of hearing.application ?? []) { + const baseHearingInfo = buildApplicationHearingInfo(application, sittingStartTime, attendanceMethod, hearingType, panel); + for (const party of application.party ?? []) { + if (party.subject === true) { + addHearingToSittings(sittings, baseHearingInfo, party, locale); + } + } + } + } +} + +function buildCaseHearingInfo(caseItem: Case, sittingStartTime: string, attendanceMethod: string, hearingType: string, panel: string) { + return { + sittingStartTime, + prosecutingAuthority: findProsecutingAuthority(caseItem.party ?? []), + attendanceMethod, + reference: caseItem.caseUrn ?? "", + applicationType: "", + caseSequenceIndicator: caseItem.caseSequenceIndicator ?? "", + hearingType, + panel, + applicationParticulars: "", + reportingRestriction: caseItem.reportingRestriction ?? false, + reportingRestrictionDetails: formatReportingRestrictionDetails(caseItem.reportingRestrictionDetails) + }; +} + +function buildApplicationHearingInfo(application: Application, sittingStartTime: string, attendanceMethod: string, hearingType: string, panel: string) { + return { + sittingStartTime, + prosecutingAuthority: findProsecutingAuthority(application.party ?? []), + attendanceMethod, + reference: application.applicationReference ?? "", + applicationType: application.applicationType ?? "", + caseSequenceIndicator: "", + hearingType, + panel, + applicationParticulars: application.applicationParticulars ?? "", + reportingRestriction: application.reportingRestriction ?? false, + reportingRestrictionDetails: formatReportingRestrictionDetails(application.reportingRestrictionDetails) + }; +} + +function addHearingToSittings(sittings: RenderedSitting[], hearingInfo: ReturnType, party: Party, locale: string): void { + const sittingHeading = buildSittingHeading(hearingInfo.sittingStartTime, hearingInfo.caseSequenceIndicator); + const partyInfo = buildPartyInfo(party, locale); + const offences = processOffences(party, locale); + + const hearing: RenderedHearing = { ...hearingInfo, partyInfo, offences }; + + const existingSitting = sittings.find((s) => s.sittingHeading === sittingHeading); + if (existingSitting) { + existingSitting.hearings.push(hearing); + } else { + sittings.push({ sittingHeading, hearings: [hearing] }); + } +} + +function buildSittingHeading(sittingStartTime: string, caseSequenceIndicator: string): string { + if (!sittingStartTime) return ""; + return caseSequenceIndicator ? `${sittingStartTime} [${caseSequenceIndicator}]` : sittingStartTime; +} + +function buildPartyInfo(party: Party, locale: string): RenderedPartyInfo { + if (party.organisationDetails) { + return buildOrganisationPartyInfo(party.organisationDetails); + } + if (party.individualDetails) { + return buildIndividualPartyInfo(party.individualDetails, locale); + } + return { name: "", dob: "", age: "", address: "", asn: "", pncId: "" }; +} + +function buildIndividualPartyInfo(details: IndividualDetails, _locale: string): RenderedPartyInfo { + return { + name: formatIndividualName(details), + dob: details.dateOfBirth ? formatDateFromIso(details.dateOfBirth) : "", + age: details.age !== undefined ? String(details.age) : "", + address: formatAddress(details.address), + asn: details.asn ?? "", + pncId: details.pncId ?? "" + }; +} + +function buildOrganisationPartyInfo(details: OrganisationDetails): RenderedPartyInfo { + return { + name: details.organisationName, + dob: "", + age: "", + address: formatAddress(details.organisationAddress), + asn: "", + pncId: "" + }; +} + +function formatIndividualName(details: IndividualDetails): string { + const nameParts = [details.individualSurname, [details.individualForenames, details.individualMiddleName].filter(Boolean).join(" ")] + .filter(Boolean) + .join(", "); + const gender = details.gender ? ` (${details.gender})` : ""; + const custody = details.inCustody ? "*" : ""; + return nameParts + gender + custody; +} + +function findProsecutingAuthority(parties: Party[]): string { + const authority = parties.find((p) => p.partyRole === "PROSECUTING_AUTHORITY" && p.organisationDetails); + return authority?.organisationDetails?.organisationName ?? ""; +} + +function processOffences(party: Party, _locale: string): RenderedOffence[] { + return (party.offence ?? []).map((offence) => ({ + offenceCode: offence.offenceCode ?? "", + offenceTitle: offence.offenceTitle ?? "", + offenceWording: offence.offenceWording ?? "", + plea: offence.plea ?? "", + pleaDate: offence.pleaDate ? formatDateFromIso(offence.pleaDate) : "", + convictionDate: offence.convictionDate ? formatDateFromIso(offence.convictionDate) : "", + adjournedDate: offence.adjournedDate ? formatDateFromIso(offence.adjournedDate) : "", + offenceLegislation: offence.offenceLegislation ?? "", + offenceMaxPenalty: offence.offenceMaxPen ?? "", + reportingRestriction: offence.reportingRestriction ?? false, + reportingRestrictionDetails: formatReportingRestrictionDetails(offence.reportingRestrictionDetails) + })); +} + +function formatCourtRoomWithJudiciary(courtRoomName: string, judiciary: Judiciary[]): string { + const judiciaryNames = judiciary + .filter((j) => j.johKnownAs) + .map((j) => j.johKnownAs as string) + .join(", "); + return judiciaryNames ? `${courtRoomName}: ${judiciaryNames}` : courtRoomName; +} + +function formatReportingRestrictionDetails(details?: string[]): string { + if (!details) return ""; + return details.filter((d) => d.length > 0).join(", "); +} + +function formatAddressLines(address?: { line?: string[]; town?: string; county?: string; postCode?: string }): string[] { + if (!address) return []; + return [...(address.line ?? []), address.town, address.county, address.postCode].filter((p): p is string => Boolean(p)); +} + +function formatAddress(address?: { line?: string[]; town?: string; county?: string; postCode?: string }): string { + if (!address) return ""; + return [...(address.line ?? []), address.town, address.county, address.postCode].filter(Boolean).join(", "); +} + +function formatDate(date: Date, locale: string): string { + return date.toLocaleDateString(locale === "cy" ? "cy-GB" : "en-GB", { + day: "2-digit", + month: "long", + year: "numeric", + timeZone: "Europe/London" + }); +} + +function formatDateAndTime(isoDateTime: string, locale: string): { date: string; time: string } { + const date = new Date(isoDateTime); + return { + date: date.toLocaleDateString(locale === "cy" ? "cy-GB" : "en-GB", { + day: "2-digit", + month: "long", + year: "numeric", + timeZone: "Europe/London" + }), + time: formatAmPmTime(date) + }; +} + +function formatDateFromIso(isoDateTime: string): string { + const date = new Date(isoDateTime); + const day = String(date.getUTCDate()).padStart(2, "0"); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const year = String(date.getUTCFullYear()); + return `${day}/${month}/${year}`; +} + +function formatSittingTime(isoDateTime: string): string { + return formatAmPmTime(new Date(isoDateTime)); +} + +function formatAmPmTime(date: Date): string { + const minutes = date.toLocaleString("en-GB", { minute: "numeric", timeZone: "Europe/London" }); + const options: Intl.DateTimeFormatOptions = { + hour: "numeric", + hour12: true, + timeZone: "Europe/London", + ...(minutes !== "0" && { minute: "2-digit" }) + }; + const raw = date.toLocaleTimeString("en-GB", options); + return raw.replace(/\s+(am|pm)$/i, (_, s) => s.toLowerCase()); +} diff --git a/libs/list-types/magistrates-standard-list/src/schemas/magistrates-standard-list.json b/libs/list-types/magistrates-standard-list/src/schemas/magistrates-standard-list.json new file mode 100644 index 000000000..ec90e607e --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/schemas/magistrates-standard-list.json @@ -0,0 +1,492 @@ +{ + "$defs": { + "address": { + "title": "Address Details", + "type": "object", + "properties": { + "line": { + "title": "Address Line", + "type": "array", + "items": { + "title": "Items", + "type": "string", + "examples": ["THE LAW COURTS"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + }, + "town": { + "title": "Town", + "description": "The town for the address", + "type": "string", + "examples": ["Cambridge"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "county": { + "title": "County", + "description": "The county for the address", + "type": "string", + "examples": ["Cambridgeshire"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "postCode": { + "title": "Address Postcode", + "type": "string", + "examples": ["PR1 2LL"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + } + }, + "judiciary": { + "title": "Judiciary", + "description": "An array of judiciary", + "type": "object", + "properties": { + "johKnownAs": { + "description": "Name and salutations, titles to be presented on publications", + "title": "JOH Known As", + "type": "string", + "examples": ["judge"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "isPresiding": { + "description": "A flag to indicate whether a judiciary is presiding", + "title": "Is Presiding", + "type": "boolean" + } + } + }, + "party": { + "title": "Party", + "description": "A party", + "type": "object", + "properties": { + "partyRole": { + "description": "Role of the party, selectable list of roles, i.e. prosecution, solicitor defence etc", + "title": "Party Role", + "type": "string", + "examples": ["DEFENDANT", "PROSECUTING_AUTHORITY", "APPLICANT", "RESPONDENT"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "individualDetails": { + "title": "Individual Details", + "description": "Individual Details", + "type": "object", + "properties": { + "individualForenames": { + "description": "Forename of party", + "title": "Individual Forenames", + "type": "string", + "examples": ["John Smith"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "individualMiddleName": { + "description": "Middle name of the individual", + "title": "Individual Middle Name", + "type": "string", + "examples": ["MiddleName"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "individualSurname": { + "description": "Surname of party", + "title": "Individual Surname", + "type": "string", + "examples": ["Surname"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "dateOfBirth": { + "description": "Date of birth of the individual", + "title": "Date Of Birth", + "type": "string", + "examples": ["1901-01-01"], + "pattern": "^((([-+]?\\d{4}(?!\\d{2}\\b))-(0[13578]|1[02])-(0[1-9]|[12]\\d|3[01]))|(([-+]?\\d{4}(?!\\d{2}\\b))-(0[13456789]|1[012])-(0[1-9]|[12]\\d|30))|(([-+]?\\d{4}(?!\\d{2}\\b))-02-(0[1-9]|1\\d|2[0-8]))|(((1[6-9]|[2-9]\\d)(0[48]|[2468][048]|[13579][26])|((16|[2468][048]|[3579][26])00))-02-29))$" + }, + "age": { + "description": "Age of the individual", + "title": "Age", + "type": "integer", + "examples": [1] + }, + "address": { + "title": "Address", + "description": "Party Address", + "$ref": "#/$defs/address" + }, + "inCustody": { + "description": "If party is in custody or not", + "title": "In Custody", + "type": "boolean" + }, + "gender": { + "description": "Party Gender", + "title": "Gender", + "type": "string", + "examples": ["male"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "asn": { + "description": "ASN Number", + "title": "ASN", + "type": "string", + "examples": ["ABC1234567D"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "pncId": { + "description": "PNC Id Number", + "title": "PNC Id", + "type": "string", + "examples": ["ABC1234567D"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + } + }, + "offence": { + "title": "offence", + "description": "The offence and details of offences associated with the case. It is also known as charges.", + "type": "array", + "items": { + "type": "object", + "properties": { + "offenceCode": { + "description": "Offence unique code", + "title": "Offence code", + "type": "string", + "examples": ["dd01-01"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "offenceTitle": { + "description": "Short description of offence", + "title": "Offence title", + "type": "string", + "examples": ["drink driving"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "offenceWording": { + "description": "Long description of offence", + "title": "Offence wording", + "type": "string", + "examples": ["driving whilst under the influence of alcohol"], + "pattern": "^(?!(?:.|\\r|\\n)*(?:<\\s*\\/[^>]*>|<[^>]*\\/>|<\\s*\\/[^&]*>|<[^&]*\\/>))(?:.|\\r|\\n)*$" + }, + "offenceMaxPen": { + "description": "Maximum penalty if found guilty", + "title": "Offence Max Penalty", + "type": "string", + "examples": ["10yrs"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "reportingRestriction": { + "title": "Reporting Restriction", + "description": "Any reporting restrictions posed to the offence", + "type": "boolean" + }, + "reportingRestrictionDetails": { + "title": "Reporting Restrictions Detail", + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + }, + "convictionDate": { + "title": "Conviction Date", + "description": "", + "type": "string", + "examples": ["2016-09-13T23:30:52.123Z"], + "pattern": "^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}([.]\\d{1,9})?Z)?$" + }, + "adjournedDate": { + "title": "Adjourned Date", + "description": "", + "type": "string", + "examples": ["2016-09-13T23:30:52.123Z"], + "pattern": "^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}([.]\\d{1,9})?Z)?$" + }, + "plea": { + "description": "If a plea has been received", + "title": "Plea", + "type": "string", + "enum": ["GUILTY", "NOT_GUILTY", "NONE"] + }, + "pleaDate": { + "title": "Plea Date", + "description": "", + "type": "string", + "examples": ["2016-09-13T23:30:52.123Z"], + "pattern": "^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}([.]\\d{1,9})?Z)?$" + }, + "offenceLegislation": { + "description": "Legislation associated with the offence", + "title": "Offence Legislation", + "type": "string", + "examples": ["Road Traffic Act 1988"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + } + } + }, + "organisationDetails": { + "title": "Organisation Details", + "description": "Organisation Details", + "type": "object", + "required": ["organisationName"], + "properties": { + "organisationName": { + "description": "Party name of organisation", + "title": "Organisation Name", + "type": "string", + "examples": ["A & B Solicitors"], + "pattern": "^(?!.*(?:<\\s*\\/[^>]*>|<[^>]*\\/>|<\\s*\\/[^&]*>|<[^&]*\\/>)).*$" + }, + "organisationAddress": { + "title": "Organisation Address", + "description": "Party Address", + "$ref": "#/$defs/address" + } + } + }, + "subject": { + "title": "Subject Restriction", + "description": "Party is subject of the application/case", + "type": "boolean" + } + } + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Root", + "type": "object", + "required": ["document", "venue", "courtLists"], + "properties": { + "document": { + "title": "document", + "type": "object", + "required": ["publicationDate"], + "properties": { + "publicationDate": { + "title": "Publication date", + "description": "The date + time the list was published", + "type": "string", + "examples": ["2016-09-13T23:30:52.123Z"], + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}([.]\\d{1,9})?Z$" + } + } + }, + "venue": { + "title": "venue", + "type": "object", + "properties": { + "venueAddress": { + "description": "Full venue address", + "$ref": "#/$defs/address" + } + } + }, + "courtLists": { + "title": "Court Lists", + "type": "array", + "items": { + "title": "Court list", + "type": "object", + "required": ["courtHouse"], + "properties": { + "courtHouse": { + "title": "Court House", + "description": "The court house that owns the court rooms", + "required": ["courtRoom"], + "type": "object", + "properties": { + "courtHouseName": { + "description": "Name of the court house", + "title": "Court House Name", + "type": "string", + "examples": ["PRESTON"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "lja": { + "title": "Local justice area name", + "type": "string", + "maxLength": 100, + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "courtRoom": { + "type": "array", + "items": { + "title": "Court Room", + "description": "The court room that owns the court rooms", + "type": "object", + "required": ["courtRoomName", "session"], + "properties": { + "courtRoomName": { + "title": "Court Room Name", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "session": { + "type": "array", + "items": { + "title": "Session", + "description": "Session associated with the court in the publication", + "required": ["sittings"], + "type": "object", + "properties": { + "judiciary": { + "type": "array", + "description": "Judiciary for the session", + "title": "Judiciary", + "items": { + "type": "object", + "$ref": "#/$defs/judiciary" + } + }, + "sittings": { + "title": "Sittings", + "type": "array", + "items": { + "type": "object", + "required": ["sittingStart", "hearing"], + "properties": { + "sittingStart": { + "description": "Sitting Start Time", + "title": "Sitting Start", + "type": "string", + "examples": [], + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}([.]\\d{1,9})?Z$" + }, + "hearing": { + "type": "array", + "items": { + "type": "object", + "description": "Hearing details of the case and associated details for the sitting", + "title": "Hearing", + "properties": { + "hearingType": { + "description": "Type of hearing being presented", + "title": "Hearing Type", + "type": "string", + "examples": ["mda"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "case": { + "type": "array", + "items": { + "type": "object", + "title": "Case", + "required": ["caseUrn"], + "description": "Case Details", + "properties": { + "caseUrn": { + "title": "Case unique identifier", + "description": "A number identifying a case", + "type": "string", + "examples": ["ABC45684548"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "reportingRestriction": { + "title": "Reporting Restriction", + "type": "boolean" + }, + "reportingRestrictionDetails": { + "title": "Reporting Restrictions Detail", + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + }, + "caseSequenceIndicator": { + "title": "Case Sequence Indicator", + "description": "The case sequence indicator", + "type": "string", + "examples": ["2 of 3"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "party": { + "type": "array", + "description": "party for the case", + "title": "Party", + "items": { + "type": "object", + "$ref": "#/$defs/party" + } + } + } + } + }, + "panel": { + "title": "Panel", + "description": "Information about the panel", + "type": "string", + "examples": ["CHILD or ADULT"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "channel": { + "type": "array", + "items": { + "type": "string", + "description": "Channel if different from in court", + "title": "Sitting Channel", + "examples": ["VIDEO HEARING"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + }, + "application": { + "title": "Application", + "description": "Application associated with the case", + "type": "array", + "items": { + "type": "object", + "title": "Application", + "required": ["applicationReference"], + "properties": { + "party": { + "type": "array", + "description": "party for the case", + "title": "Party", + "items": { + "type": "object", + "$ref": "#/$defs/party" + } + }, + "applicationReference": { + "title": "Application Reference", + "description": "application reference", + "type": "string", + "examples": ["ABC1234567D"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "applicationType": { + "title": "Application Type", + "description": "application type", + "type": "string", + "examples": ["application type example"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "applicationParticulars": { + "title": "Application Particulars", + "description": "details of an application", + "type": "string", + "examples": ["application particulars example"], + "pattern": "^(?!(?:.|\\r|\\n)*(?:<\\s*/[^>]*>|<[^>]*\\/>|<\\s*\\/[^&]*>|<[^&]*\\/>))(?:.|\\r|\\n)*$" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/libs/list-types/magistrates-standard-list/src/validation/json-validator.test.ts b/libs/list-types/magistrates-standard-list/src/validation/json-validator.test.ts new file mode 100644 index 000000000..344804d82 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/validation/json-validator.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { validateMagistratesStandardList } from "./json-validator.js"; + +const VALID_DATA = { + document: { + publicationDate: "2025-01-13T09:30:00.000Z" + }, + venue: { + venueAddress: { + line: ["THE LAW COURTS", "CROWN SQUARE"], + town: "Manchester", + county: "Greater Manchester", + postCode: "M3 3FL" + } + }, + courtLists: [ + { + courtHouse: { + courtHouseName: "Manchester Magistrates Court", + courtRoom: [ + { + courtRoomName: "Court 1", + session: [ + { + sittings: [ + { + sittingStart: "2025-01-13T10:00:00.000Z", + hearing: [] + } + ] + } + ] + } + ] + } + } + ] +}; + +describe("validateMagistratesStandardList", () => { + it("should return valid for a well-formed document", () => { + const result = validateMagistratesStandardList(VALID_DATA); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should return invalid when publicationDate is missing", () => { + const invalidData = { + document: {}, + venue: {}, + courtLists: [] + }; + const result = validateMagistratesStandardList(invalidData); + expect(result.isValid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); +}); diff --git a/libs/list-types/magistrates-standard-list/src/validation/json-validator.ts b/libs/list-types/magistrates-standard-list/src/validation/json-validator.ts new file mode 100644 index 000000000..19f493cb4 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/validation/json-validator.ts @@ -0,0 +1,6 @@ +import { type ValidationResult, validateJson } from "@hmcts/publication"; +import schema from "../schemas/magistrates-standard-list.json" with { type: "json" }; + +export function validateMagistratesStandardList(jsonData: unknown): ValidationResult { + return validateJson(jsonData, schema, "1.0"); +} diff --git a/libs/list-types/magistrates-standard-list/tsconfig.json b/libs/list-types/magistrates-standard-list/tsconfig.json new file mode 100644 index 000000000..588a376da --- /dev/null +++ b/libs/list-types/magistrates-standard-list/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules", "src/assets/"] +} diff --git a/libs/location/src/list-type-data.ts b/libs/location/src/list-type-data.ts index 9fcc0df48..3b95a9735 100644 --- a/libs/location/src/list-type-data.ts +++ b/libs/location/src/list-type-data.ts @@ -653,5 +653,16 @@ export const listTypeData: ListTypeData[] = [ defaultSensitivity: "Public", shortenedFriendlyName: "SSCS London Daily Hearing List", subJurisdictionIds: [8] + }, + { + id: 56, + name: "MAGISTRATES_STANDARD_LIST", + englishFriendlyName: "Magistrates Standard List", + welshFriendlyName: "Rhestr Safonol y Llys Ynadon", + provenance: "CRIME_IDAM", + urlPath: "magistrates-standard-list", + isNonStrategic: false, + defaultSensitivity: "Classified", + subJurisdictionIds: [7] } ]; diff --git a/libs/notifications/src/notification/notification-service.ts b/libs/notifications/src/notification/notification-service.ts index 033c0e4d6..5f9bbeb33 100644 --- a/libs/notifications/src/notification/notification-service.ts +++ b/libs/notifications/src/notification/notification-service.ts @@ -37,6 +37,10 @@ import { extractCaseSummary as extractLondonAdminSummary, formatCaseSummaryForEmail as formatLondonAdminSummaryForEmail } from "@hmcts/london-administrative-court-daily-cause-list"; +import { + extractCaseSummary as extractMagistratesStandardSummary, + formatCaseSummaryForEmail as formatMagistratesStandardSummaryForEmail +} from "@hmcts/magistrates-standard-list"; import { prisma } from "@hmcts/postgres-prisma"; import { extractCaseSummary as extractRcjSummary, formatCaseSummaryForEmail as formatRcjSummaryForEmail } from "@hmcts/rcj-standard-daily-cause-list"; import { extractCaseSummary as extractSendSummary, formatCaseSummaryForEmail as formatSendSummaryForEmail } from "@hmcts/send-daily-hearing-list"; @@ -228,6 +232,10 @@ const EMAIL_BUILDER_REGISTRY: Partial> = { UTIAC_JR_CARDIFF_DAILY_HEARING_LIST: { extract: extractUtiacJrLeedsSummary as SummaryExtractor, format: formatUtiacJrLeedsSummaryForEmail + }, + MAGISTRATES_STANDARD_LIST: { + extract: extractMagistratesStandardSummary as SummaryExtractor, + format: formatMagistratesStandardSummaryForEmail } }; diff --git a/libs/publication/package.json b/libs/publication/package.json index b2ada7e10..91e67dffa 100644 --- a/libs/publication/package.json +++ b/libs/publication/package.json @@ -41,6 +41,7 @@ "@hmcts/list-types-common": "workspace:*", "@hmcts/location": "workspace:*", "@hmcts/london-administrative-court-daily-cause-list": "workspace:*", + "@hmcts/magistrates-standard-list": "workspace:*", "@hmcts/notifications": "workspace:*", "@hmcts/postgres-prisma": "workspace:*", "@hmcts/rcj-standard-daily-cause-list": "workspace:*", diff --git a/libs/publication/src/processing/service.ts b/libs/publication/src/processing/service.ts index 8489cb219..61b377d5a 100644 --- a/libs/publication/src/processing/service.ts +++ b/libs/publication/src/processing/service.ts @@ -16,6 +16,7 @@ import { type GrcWeeklyHearingList, generateGrcWeeklyHearingListPdf } from "@hmc import { sendThirdPartyPublications } from "@hmcts/legacy-third-party-fulfilment"; import { getLocationById } from "@hmcts/location"; import { generateLondonAdministrativeCourtDailyCauseListPdf, type LondonAdminCourtData } from "@hmcts/london-administrative-court-daily-cause-list"; +import { generateMagistratesStandardListPdf, type MagistratesStandardList } from "@hmcts/magistrates-standard-list"; import { sendListTypePublicationNotifications, sendLocationAndCaseSubscriptionNotifications } from "@hmcts/notifications"; import { prisma } from "@hmcts/postgres-prisma"; import { generateRcjStandardDailyCauseListPdf, type StandardHearingList } from "@hmcts/rcj-standard-daily-cause-list"; @@ -265,7 +266,8 @@ const PDF_GENERATOR_REGISTRY: Partial> = { ...p, jsonData: p.jsonData as UtiacJrHearingList, contentDate: p.contentDate - }) + }), + MAGISTRATES_STANDARD_LIST: (p) => generateMagistratesStandardListPdf({ ...p, jsonData: p.jsonData as MagistratesStandardList }) }; export async function generatePublicationPdf(params: GeneratePdfParams): Promise { diff --git a/tsconfig.json b/tsconfig.json index 45a04435d..48c57a122 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -84,7 +84,9 @@ "@hmcts/test-support/config": ["libs/test-support/src/config"], "@hmcts/sscs-daily-hearing-list": ["libs/list-types/sscs-daily-hearing-list/src"], "@hmcts/sscs-daily-hearing-list/config": ["libs/list-types/sscs-daily-hearing-list/src/config"], - "@hmcts/azure-blob": ["libs/azure-blob/src"] + "@hmcts/azure-blob": ["libs/azure-blob/src"], + "@hmcts/magistrates-standard-list": ["libs/list-types/magistrates-standard-list/src"], + "@hmcts/magistrates-standard-list/config": ["libs/list-types/magistrates-standard-list/src/config"] }, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, diff --git a/yarn.lock b/yarn.lock index 58c4741df..06734834b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2649,6 +2649,14 @@ __metadata: languageName: unknown linkType: soft +"@hmcts/magistrates-standard-list@workspace:*, @hmcts/magistrates-standard-list@workspace:libs/list-types/magistrates-standard-list": + version: 0.0.0-use.local + resolution: "@hmcts/magistrates-standard-list@workspace:libs/list-types/magistrates-standard-list" + dependencies: + "@hmcts/location": "workspace:*" + languageName: unknown + linkType: soft + "@hmcts/notification@workspace:*, @hmcts/notification@workspace:libs/notification": version: 0.0.0-use.local resolution: "@hmcts/notification@workspace:libs/notification" @@ -2791,6 +2799,7 @@ __metadata: "@hmcts/list-types-common": "workspace:*" "@hmcts/location": "workspace:*" "@hmcts/london-administrative-court-daily-cause-list": "workspace:*" + "@hmcts/magistrates-standard-list": "workspace:*" "@hmcts/notifications": "workspace:*" "@hmcts/postgres-prisma": "workspace:*" "@hmcts/rcj-standard-daily-cause-list": "workspace:*" @@ -3059,6 +3068,7 @@ __metadata: "@hmcts/list-types-common": "workspace:*" "@hmcts/location": "workspace:*" "@hmcts/london-administrative-court-daily-cause-list": "workspace:*" + "@hmcts/magistrates-standard-list": "workspace:*" "@hmcts/public-pages": "workspace:*" "@hmcts/rcj-standard-daily-cause-list": "workspace:*" "@hmcts/send-daily-hearing-list": "workspace:*"