From 0908556251388a0d65bb25caae9a0b0427f38cf4 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:53:42 +0000 Subject: [PATCH 01/73] docs(301): add technical plan for third-party user management Adds ticket, plan, and tasks documentation for issue #301 covering the full CRUD workflow for third-party users including LaunchDarkly A/B testing of the subscriptions UI. Co-Authored-By: Claude Sonnet 4.6 --- docs/tickets/301/plan.md | 244 +++++++++++++++++++++++++++++++++++++ docs/tickets/301/tasks.md | 80 ++++++++++++ docs/tickets/301/ticket.md | 175 ++++++++++++++++++++++++++ 3 files changed, 499 insertions(+) create mode 100644 docs/tickets/301/plan.md create mode 100644 docs/tickets/301/tasks.md create mode 100644 docs/tickets/301/ticket.md diff --git a/docs/tickets/301/plan.md b/docs/tickets/301/plan.md new file mode 100644 index 000000000..e296255cb --- /dev/null +++ b/docs/tickets/301/plan.md @@ -0,0 +1,244 @@ +# Technical Plan: #301 — Third Party User Management + +## 1. Technical Approach + +This feature adds a full CRUD workflow for third-party users to the System Admin section. All pages live in `libs/system-admin-pages` (consistent with existing admin pages). A new Prisma schema is introduced in a dedicated `libs/third-party-user` module that owns the data model and service layer. + +LaunchDarkly is introduced for the first time in this codebase to A/B test the subscriptions UI (radio buttons vs dropdown). The `cath-ld-key` secret has been provisioned to the `pip-ss-kv-stg` keyvault. + +### Screen flows + +**Create:** `/third-party-users` → `/third-party-users/create` → `/third-party-users/create/summary` → `/third-party-users/create/confirmation` + +**Manage:** `/third-party-users` → `/third-party-users/[id]` → `/third-party-users/[id]/subscriptions` → `/third-party-users/[id]/subscriptions/confirmation` + +**Delete:** `/third-party-users/[id]` → `/third-party-users/[id]/delete` → `/third-party-users/[id]/delete/confirmation` + +--- + +## 2. Implementation Details + +### 2.1 New library: `libs/third-party-user` + +Owns the Prisma schema, database service functions, and shared validation. Pages remain in `libs/system-admin-pages`. + +**Prisma schema** (`libs/third-party-user/prisma/schema.prisma`): + +```prisma +model ThirdPartyUser { + id String @id @default(cuid()) + name String @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") + subscriptions ThirdPartySubscription[] + + @@map("third_party_user") +} + +model ThirdPartySubscription { + id String @id @default(cuid()) + thirdPartyUserId String @map("third_party_user_id") + listType String @map("list_type") @db.VarChar(100) + sensitivity String @db.VarChar(20) // "PUBLIC" | "PRIVATE" | "CLASSIFIED" + + thirdPartyUser ThirdPartyUser @relation(fields: [thirdPartyUserId], references: [id], onDelete: Cascade) + + @@unique([thirdPartyUserId, listType]) + @@map("third_party_subscription") +} +``` + +**Service functions** (`libs/third-party-user/src/third-party-user-service.ts`): +- `findAllThirdPartyUsers(): Promise` +- `findThirdPartyUserById(id: string): Promise` +- `createThirdPartyUser(name: string): Promise` — idempotency: use `findFirst({ where: { name } })` before creating +- `updateThirdPartySubscriptions(userId: string, subscriptions: Record): Promise` — delete all then re-insert +- `deleteThirdPartyUser(id: string): Promise` +- `thirdPartyUserExists(name: string): Promise` + +**Validation** (`libs/third-party-user/src/name-validation.ts`): +- Not empty after trim +- Not whitespace-only +- Max 255 chars +- Allowed chars: `^[a-zA-Z0-9 '\-]+$` + +**Config** (`libs/third-party-user/src/config.ts`): +```typescript +export const prismaSchemas = path.join(__dirname, "../prisma"); +``` + +**Package**: `@hmcts/third-party-user` + +### 2.2 New pages in `libs/system-admin-pages` + +Each page follows the existing pattern: `index.ts` (controller), `en.ts`, `cy.ts`, `index.njk`, `index.test.ts`. + +``` +libs/system-admin-pages/src/pages/ +├── third-party-users/ +│ ├── index.ts # GET: list all third-party users +│ ├── en.ts / cy.ts +│ └── index.njk +├── third-party-users/create/ +│ ├── index.ts # GET+POST: name input form +│ ├── en.ts / cy.ts +│ └── index.njk +├── third-party-users/create/summary/ +│ ├── index.ts # GET+POST: confirm before create +│ ├── en.ts / cy.ts +│ └── index.njk +├── third-party-users/create/confirmation/ +│ ├── index.ts # GET: success panel +│ ├── en.ts / cy.ts +│ └── index.njk +├── third-party-users/[id]/ +│ ├── index.ts # GET: manage user details +│ ├── en.ts / cy.ts +│ └── index.njk +├── third-party-users/[id]/subscriptions/ +│ ├── index.ts # GET+POST: manage subscriptions (paginated) +│ ├── en.ts / cy.ts +│ └── index.njk +├── third-party-users/[id]/subscriptions/confirmation/ +│ ├── index.ts # GET: success panel +│ ├── en.ts / cy.ts +│ └── index.njk +├── third-party-users/[id]/delete/ +│ ├── index.ts # GET+POST: yes/no confirmation +│ ├── en.ts / cy.ts +│ └── index.njk +└── third-party-users/[id]/delete/confirmation/ + ├── index.ts # GET: success panel + ├── en.ts / cy.ts + └── index.njk +``` + +### 2.3 Session management + +A typed session interface per flow to retain data across back-navigation: + +```typescript +interface ThirdPartyUserSession { + thirdPartyUserCreate?: { + name: string; + createdId?: string; // set after successful creation for idempotency + }; + thirdPartyUserSubscriptions?: { + userId: string; + pendingSubscriptions: Record; // listType -> sensitivity + }; +} +``` + +### 2.4 LaunchDarkly integration (new) + +A new `libs/cloud-native-platform` module already exists. LaunchDarkly should be introduced as a thin wrapper: + +**New file**: `libs/system-admin-pages/src/feature-flags/launch-darkly.ts` + +```typescript +import { init, LDClient } from "@launchdarkly/node-server-sdk"; + +const LD_SDK_KEY = process.env.CATH_LD_KEY ?? ""; + +let client: LDClient | null = null; + +export async function getLdClient(): Promise { + if (!client) { + client = init(LD_SDK_KEY); + await client.waitForInitialization({ timeout: 5 }); + } + return client; +} + +export async function isFeatureEnabled(flagKey: string, userId: string): Promise { + const ldClient = await getLdClient(); + return ldClient.variation(flagKey, { key: userId }, false); +} +``` + +**Flag key**: `third-party-subscriptions-dropdown` — `false` = radio buttons (Option 1), `true` = dropdown (Option 2) + +The subscriptions page controller reads the flag per admin user and passes `useDropdown: boolean` to the template. The Nunjucks template conditionally renders the appropriate table variant. + +### 2.5 Subscriptions page: pagination + +All list types come from the existing `@hmcts/list-types` module. Paginate at 20 per page using query param `?page=1`. The POST handler accumulates subscription state across pages via session (`pendingSubscriptions`). "Save Subscriptions" on the last page writes to the database. + +### 2.6 Audit logging + +Follow existing pattern from `delete-court-confirm/index.ts`: set `req.auditMetadata` before `res.redirect()`. + +```typescript +req.auditMetadata = { + shouldLog: true, + action: "CREATE_THIRD_PARTY_USER", + entityInfo: `Name: ${name}` +}; +``` + +Actions to audit: +- `CREATE_THIRD_PARTY_USER` — after confirmation POST +- `UPDATE_THIRD_PARTY_SUBSCRIPTIONS` — with before/after as JSON in entityInfo +- `DELETE_THIRD_PARTY_USER` — after delete confirmation POST (Yes) + +### 2.7 Idempotency on create confirmation + +Store the created user's `id` in session after first creation. On subsequent POSTs to the summary confirmation page, if `session.thirdPartyUserCreate.createdId` is set, skip re-creation and redirect directly to the confirmation page. + +### 2.8 App registration + +**`apps/postgres/src/schema-discovery.ts`**: add `prismaSchemas` from `@hmcts/third-party-user/config` + +**`apps/web/src/app.ts`**: add `pageRoutes` from `@hmcts/system-admin-pages/config` (already registered — no change needed since pages are added to the existing module) + +**`root tsconfig.json`**: add `"@hmcts/third-party-user": ["libs/third-party-user/src"]` + +--- + +## 3. Error Handling & Edge Cases + +| Scenario | Handling | +|---|---| +| User navigates directly to summary without session | Redirect to `/third-party-users/create` | +| User navigates directly to manage page with invalid ID | 404 / redirect to `/third-party-users` | +| Duplicate name on confirm | Re-render summary with "This third party user already exists" error | +| Page refresh on confirmation | Idempotency check via `session.createdId` — skip re-create | +| Delete with "No" selected | Redirect back to manage user page | +| LaunchDarkly unavailable | Default to `false` (radio buttons) — safe fallback | +| Empty list types | Show empty state with message | + +--- + +## 4. Acceptance Criteria Mapping + +| AC | Implementation | +|---|---| +| System Admin role only | `requireRole([USER_ROLES.SYSTEM_ADMIN])` in all GET/POST middleware arrays | +| Back preserves data | Session stores `thirdPartyUserCreate` / `thirdPartyUserSubscriptions` typed objects | +| No duplicate on refresh | `session.createdId` idempotency check on summary confirm POST | +| Audit log on create | `req.auditMetadata` set in create confirmation POST | +| Audit log on update subscriptions | `req.auditMetadata` set with before/after JSON in subscriptions POST | +| Audit log on delete | `req.auditMetadata` set in delete confirmation POST | +| Name validation | `name-validation.ts` — empty, whitespace, 255 chars, allowed chars | +| DB table | `third_party_user` + `third_party_subscription` Prisma models | +| Subscriptions A/B test | LaunchDarkly flag `third-party-subscriptions-dropdown` | +| Welsh translations | All `cy.ts` files with provided translations | +| WCAG 2.2 AA | GOV.UK Design System components; error associations via `aria-describedby` | + +--- + +## 5. Open Questions / CLARIFICATIONS NEEDED + +1. **LaunchDarkly SDK version**: The codebase has no existing LD dependency. Which SDK package should be used — `@launchdarkly/node-server-sdk` (v9+) or the legacy `launchdarkly-node-server-sdk`? + +2. **List types source**: The `@hmcts/list-types` module provides list type definitions. Should subscriptions display the full human-readable name of each list type, or a code? What is the exact data structure the subscriptions page should reference? + +3. **Sensitivity field semantics**: The issue says "where 'classified' is selected, the user has access to public, private and classified lists." Is this access-level hierarchy enforced at query time (i.e., show all lists where sensitivity ≤ user's sensitivity), or is it a display note only? + +4. **Dependency check on delete**: The issue says "System prevents deletion of users with dependencies (if applicable)." What counts as a dependency? Is it sufficient to treat associated subscriptions as cascaded (auto-deleted), or are there external systems that reference third-party users? + +5. **Missing Welsh translations**: Several Welsh strings are listed as "Welsh placeholder" in the issue (e.g., confirmation messages, error messages). Are these to be delivered as part of this ticket or deferred? + +6. **LaunchDarkly user context**: Should the LD flag evaluation use the admin user's ID from session, or an anonymous/static context (e.g., for consistent rollout)? + +7. **Subscriptions pagination**: Should subscription selections be held in session across pages, or should the entire form be submitted on each page with partial saves? The session approach is simpler but requires the admin to not abandon mid-flow. diff --git a/docs/tickets/301/tasks.md b/docs/tickets/301/tasks.md new file mode 100644 index 000000000..9279efd4d --- /dev/null +++ b/docs/tickets/301/tasks.md @@ -0,0 +1,80 @@ +# Implementation Tasks: #301 — Third Party User Management + +## Implementation Tasks + +### Database & Data Layer +- [ ] Create `libs/third-party-user/` module with `package.json`, `tsconfig.json` +- [ ] Create `libs/third-party-user/prisma/schema.prisma` with `third_party_user` and `third_party_subscription` models +- [ ] Create `libs/third-party-user/src/config.ts` exporting `prismaSchemas` +- [ ] Create `libs/third-party-user/src/name-validation.ts` with validation functions +- [ ] Create `libs/third-party-user/src/third-party-user-service.ts` with CRUD service functions +- [ ] Write unit tests for `name-validation.ts` and `third-party-user-service.ts` +- [ ] Register `@hmcts/third-party-user` in root `tsconfig.json` paths +- [ ] Register `prismaSchemas` from `@hmcts/third-party-user/config` in `apps/postgres/src/schema-discovery.ts` +- [ ] Run `yarn db:migrate:dev` to generate migration for new tables + +### LaunchDarkly Integration +- [ ] Add `@launchdarkly/node-server-sdk` dependency to `libs/system-admin-pages/package.json` +- [ ] Create `libs/system-admin-pages/src/feature-flags/launch-darkly.ts` with LD client wrapper +- [ ] Add `CATH_LD_KEY` env var to local `.env.example` / environment configuration + +### Page: Manage Third Party Users (`/third-party-users`) +- [ ] Create `libs/system-admin-pages/src/pages/third-party-users/en.ts` and `cy.ts` +- [ ] Create `libs/system-admin-pages/src/pages/third-party-users/index.ts` controller (GET) +- [ ] Create `libs/system-admin-pages/src/pages/third-party-users/index.njk` template with table and "Create new user" button +- [ ] Write unit tests for the controller + +### Page: Create Third Party User (`/third-party-users/create`) +- [ ] Create `en.ts` and `cy.ts` +- [ ] Create `index.ts` controller (GET + POST with name validation, session storage) +- [ ] Create `index.njk` template with text input and error summary +- [ ] Write unit tests for the controller + +### Page: Create Summary (`/third-party-users/create/summary`) +- [ ] Create `en.ts` and `cy.ts` +- [ ] Create `index.ts` controller (GET + POST with idempotency via `session.createdId`, audit log) +- [ ] Create `index.njk` template with GOV.UK summary list and Change link +- [ ] Write unit tests for the controller + +### Page: Create Confirmation (`/third-party-users/create/confirmation`) +- [ ] Create `en.ts` and `cy.ts` +- [ ] Create `index.ts` controller (GET: read created name from session, clear session) +- [ ] Create `index.njk` template with GOV.UK panel component +- [ ] Write unit tests for the controller + +### Page: Manage User (`/third-party-users/[id]`) +- [ ] Create `en.ts` and `cy.ts` +- [ ] Create `index.ts` controller (GET: load user + subscription count from DB) +- [ ] Create `index.njk` template with summary table, green "Manage subscriptions" button, red "Delete user" button +- [ ] Write unit tests for the controller + +### Page: Manage Subscriptions (`/third-party-users/[id]/subscriptions`) +- [ ] Create `en.ts` and `cy.ts` +- [ ] Create `index.ts` controller (GET + POST: paginated, LaunchDarkly flag for UI variant, session accumulation, audit log on final save) +- [ ] Create `index.njk` template with conditional radio/dropdown rendering and pagination controls +- [ ] Write unit tests for the controller (both LD flag states) + +### Page: Subscriptions Updated Confirmation (`/third-party-users/[id]/subscriptions/confirmation`) +- [ ] Create `en.ts` and `cy.ts` +- [ ] Create `index.ts` controller (GET) +- [ ] Create `index.njk` template with GOV.UK panel and "Manage third party users" link +- [ ] Write unit tests for the controller + +### Page: Delete Confirmation (`/third-party-users/[id]/delete`) +- [ ] Create `en.ts` and `cy.ts` +- [ ] Create `index.ts` controller (GET + POST: Yes/No radios, audit log on Yes) +- [ ] Create `index.njk` template with radios and dynamic H1 including user name +- [ ] Write unit tests for the controller + +### Page: Delete Success (`/third-party-users/[id]/delete/confirmation`) +- [ ] Create `en.ts` and `cy.ts` +- [ ] Create `index.ts` controller (GET) +- [ ] Create `index.njk` template with GOV.UK panel, "Manage another third party user" and "Home" links +- [ ] Write unit tests for the controller + +### E2E Tests +- [ ] Create `e2e-tests/tests/third-party-user-management.spec.ts` covering: + - Create third-party user journey (including validation, Welsh, accessibility) + - Manage subscriptions journey (radio button variant) + - Manage subscriptions journey (dropdown variant, if LD flag testable) + - Delete third-party user journey (including No cancellation path) diff --git a/docs/tickets/301/ticket.md b/docs/tickets/301/ticket.md new file mode 100644 index 000000000..d511e05d9 --- /dev/null +++ b/docs/tickets/301/ticket.md @@ -0,0 +1,175 @@ +# #301: [VIBE-313] Third Party User Management - Future + +**State:** OPEN +**Assignees:** alexbottenberg +**Author:** linusnorton +**Labels:** migrated-from-jira, priority:3-medium, type:story, jira:VIBE-313, status:prioritised-backlog +**Created:** 2026-01-20T17:21:32Z +**Updated:** 2026-03-18T14:43:34Z + +## Description + +> **Migrated from [VIBE-313](https://tools.hmcts.net/jira/browse/VIBE-313)** + +### **PROBLEM STATEMENT** + +System admin users in CaTH access several system functionalities through the System Admin dashboard which allows them to perform administrative tasks. The dashboard acts as the main control panel for managing reference data, user accounts, media accounts, audit logs, and other administrative operations. This ticket covers the system admin user's ability to onboard, update and delete third-party users through a structured, multi-screen workflow. + +### **AS A** system admin +**I WANT** to create and manage a third-party user in CaTH +**SO THAT** I can manage external users efficiently while ensuring the right access permissions are applied. + +### **ACCEPTANCE CRITERIA** +* Only users with the **System Admin** role can access the System Admin Dashboard and all "Third-party user" management screens. +* "Back" returns to the previous screen **without losing saved data**. +* Page refresh does not create duplicate third-party users (idempotency on create confirm). +* Create, update (subscriptions), and delete actions write an audit entry capturing: admin user, timestamp, third-party name, action type, before/after values (where applicable). + +**The create third party user process:** + +**Screen Flow:** Dashboard → Manage Third Party Users → Create Third Party User → Summary → Confirmation +* System Admin can navigate from **Dashboard → Manage Third Party Users** where a table displays third-party users **Name and Created date** where existing third parties are already in the system and a **Manage** link/action per row / third party user. Where no third-party user exists, then the table is empty and the manage link is not displayed. +* A green **'Create new User'** button is displayed above the table which when clicked, takes the system admin user to the 'Create third party user' page to fill in the third-party user name in a free text box and When complete, the system admin clicks on the green 'Continue' button to continue. +* The System Admin is taken to the 'Create third party user summary' screen that displays the entered details in a table beside the 'Name' in a row in read-only format with a 'Change' link on each row which enables the editing of the inputted data by returning user to the **Create third party user** page with the previously entered Name pre-populated. +* Clicking **Confirm** on summary screen creates the third-party user and displays a **"Third party user created"** and the created **Name** on the confirmation page. +* System must validate mandatory fields before allowing Continue. +* System displays an error message if required data is missing. +* Name is mandatory. +* Name cannot be only whitespace. +* Name length and character rules are enforced +* Created user is added to the third-party users list. +* A table is created in the database (Third Party User Table) with the following data fields; Name, Created Date, sensitivity and subscriptions and each newly created third party is saved in the table + +**The update third party user process:** + +**Screen Flow:** Dashboard → Manage Third Party Users → Manage User → Manage Subscriptions → Subscriptions Updated +* System Admin can navigate from **Dashboard → Manage Third Party Users** where the system admin is able to view existing third-party user details and update subscription options by clicking the **'Manage'** button +* The System Admin is taken to the 'Manage user' screen which displays a table with the third party user details in rows titled 'Name', 'Created Date', 'Number of subscriptions' and 'Sensitivity' and two actionable buttons below; a green **Manage subscriptions** button which routes to **Manage third party subscriptions** and a red **Delete user** button which routes to the delete confirmation screen. +* Clicking the green "Manage Subscriptions" button takes the system admin to the "Manage third party Subscriptions" page. The "Manage third party Subscriptions" screen displays **all list types available in CaTH** in a tabular form, across multiple pages with paging controls (e.g., "Next", "Previous", page numbers) which allows navigation through list types. +* For each list type, the admin can select **only one** sensitivity level (Public, Private and Classified). 'Unselect' option is also provided to remove access. Where 'private' is selected, then the user has access to public and private lists. Where 'classified' is selected, then the user has access to public, private and classified lists. +* Clicking the green "Save Subscriptions" button on the last page updates the changes and takes the system admin user to the **'Third party subscriptions updated'** confirmation page +* Two UI options are provided for the tabular display on the "Manage third party Subscriptions" page and should be explored + +**Manage third party subscriptions (Option 1 – radio buttons)** +* The table displays five column headers (List type, public, private, classified and unselect). Each list type is provided in a row with the ability to select **only one** sensitivity level using radio buttons displayed under each of the 3 sensitivity options and the unselect option. + +**Manage third party subscriptions (Option 2 – dropdowns)** +* The table displays two column headers (List type and Sensitivity). Each list type is provided in a row with the ability to select **only one** sensitivity level from public, private, classified, using a dropdown provided in the sensitivity column, which is defaulted to 'Unselected'. +* System must save updated subscription settings when "Save Subscriptions" is clicked and display a confirmation screen with title **"Third Party Subscriptions Updated"** in a green banner and the descriptive message 'Third party subscriptions for the user have been successfully updated'. underneath the green banner is the following message 'To manage further subscriptions for third parties, you can go to: 'Manage third party users' (link) +* Updated subscriptions are visible when returning to Manage User screen. + +**The delete third party user process:** + +**Screen Flow:** Dashboard → Manage Third Party Users → Manage User → Delete Confirmation → Deletion Confirmation +* System Admin can navigate from **Dashboard → Manage Third Party Users** where the system admin is able to view existing third-party user details and update subscription options by clicking the **'Manage'** button +* The System Admin is taken to the 'Manage user' screen which displays a table with the third-party user details in rows titled 'Name', 'Created Date', 'Number of subscriptions' and 'Sensitivity' +* Clicking the red "Delete user" button takes the system admin to the "Are you sure you want to delete \?" screen where the system admin can select from a 'Yes' or 'No' radio button and click the green "Continue" to confirm. The 'Yes' radio button confirms deletion action while the 'No' radio button cancels the action and returns to **Manage user** page without deleting anything. The System Admin must explicitly confirm deletion before the system proceeds to delete the third party. +* The System displays a **Deletion Confirmation** screen with the title 'Third party user deleted' and the descriptive text 'The third party user and associated subscriptions have been removed' both in a green banner, followed by the text below; 'What do you want to do next?' and then two links; 'Manage another third party user' which takes the system admin user back to the respective screen and the 'Home' screen which takes user to the dashboard. +* Deletion removes the third-party user **and associated subscriptions** and the deleted user no longer appears in the user list. +* System prevents deletion of users with dependencies (if applicable). +* Audit logging is triggered for create, update, and delete actions + +**Welsh translations** +* Create new user - Creu defnyddiwr newydd +* Name - enw'r +* Created date - Crëwyd Dyddiad +* Actions - Camau gweithredu +* Manage - Rheoli +* Continue - Parhau +* Create third party user - Creu defnyddiwr trydydd parti +* Create third party user summary - Creu crynodeb o ddefnyddiwr trydydd parti +* Change - newid +* Confirm - Cadarnhau +* Third party user created - Crëwyd defnyddiwr trydydd parti +* Manage subscriptions - Rheoli tanysgrifiadau +* Delete user - Dileu Defnyddiwr +* Manage third party subscriptions - Rheoli tanysgrifiadau trydydd parti +* Third party subscriptions updated - Diweddarwyd Tanysgrifiadau Trydydd Parti +* Manage third party users - Rheoli defnyddiwr trydydd parti +* Are you sure you want to delete user \? - Ydych chi'n siŵr eich bod eisiau dileu defnyddiwr? +* Yes - Ydw +* No - Nac ydw +* back - Yn ôl + +## Page Specifications + +### Page 1 — Manage Third Party Users +- H1 EN: "Manage third party users" | CY: "Rheoli defnyddwyr trydydd parti" +- Table columns: Name, Created date, Actions +- Button: "Create new user" +- "Manage" link per row +- Empty state: "There are no third party users." +- Back → System Admin Dashboard + +### Page 2 — Create Third Party User +- H1 EN: "Create third party user" | CY: "Creu defnyddiwr trydydd parti" +- Text input: Name (required, max 255 chars, letters/numbers/spaces/hyphens/apostrophes) +- Validation: not empty, not whitespace-only, max 255 chars, valid chars +- Back → Manage Third Party Users (data retained) + +### Page 3 — Create Third Party User Summary +- H1 EN: "Create third party user summary" | CY: "Creu crynodeb o ddefnyddiwr trydydd parti" +- Summary list showing Name (read-only) with Change link +- Confirm button → creates user (idempotent) +- Back → Create Third Party User (data retained) + +### Page 4 — Third Party User Created (Confirmation) +- Panel: "Third party user created" / "The third party user has been successfully created" +- Link back to Manage Third Party Users + +### Page 5 — Manage User +- H1 EN: "Manage user" | CY: "Rheoli defnyddiwr" +- Summary table: Name, Created Date, Number of subscriptions, Sensitivity +- Green button: "Manage subscriptions" +- Red button: "Delete user" +- Back → Manage Third Party Users + +### Page 6 — Manage Third Party Subscriptions +- H1 EN: "Manage subscriptions" | CY: "Rheoli tanysgrifiadau" +- Paginated table of list types with sensitivity selection (radio buttons OR dropdown — A/B tested via LaunchDarkly) +- Option 1 (radio): columns — List type, Public, Private, Classified, Unselected +- Option 2 (dropdown): columns — List type, Sensitivity (select: Public/Private/Classified/Unselected) +- "Save Subscriptions" button +- Back → Manage User (without saving) + +### Page 7 — Third Party Subscriptions Updated (Confirmation) +- Panel: "Third Party Subscriptions Updated" / "Third party subscriptions for the user have been successfully updated" +- Link: "Manage third party users" + +### Page 8 — Delete Third Party User (Confirmation) +- H1 EN: "Are you sure you want to delete \?" | CY: "Ydych chi'n siŵr eich bod eisiau dileu defnyddiwr?" +- Radio buttons: Yes / No +- Continue button +- Validation: must select yes or no + +### Page 9 — Third Party User Deleted (Confirmation) +- Panel: "Third party user deleted" / "The third party user and associated subscriptions have been removed" +- "What do you want to do next?" +- Links: "Manage another third party user", "Home" + +## Accessibility +* All screens must meet WCAG 2.2 AA standards. +* All buttons, links, tables, radio buttons, and dropdowns must be fully keyboard accessible. +* Error messages must be associated with the relevant fields and announced to assistive technologies. +* Confirmation banners must use appropriate ARIA roles to announce success messages. + +## Test Scenarios +* Only System Admin users can access third-party user management screens. +* Creating a third-party user with valid data succeeds and creates an audit log entry. +* Creating a user with invalid or missing Name shows validation errors. +* Page refresh on confirmation does not create duplicate users. +* Updating subscriptions correctly persists and is visible on return to Manage User. +* Deleting a user removes them from the list and deletes associated subscriptions. +* Deletion is blocked if dependencies exist. +* All create, update, and delete actions write audit logs with before/after values where applicable. +* Back navigation preserves entered or saved data across all flows. + +## Comments + +### Comment by linusnorton on 2026-03-18T14:43:26Z +Let's use LaunchDarkly to A/B test Option 1 and Option 2 of how we manage third party subscriptions. + +I have added cath-ld-key to the pip-ss-kv-stg keyvault. + +### Comment by linusnorton on 2026-03-18T14:43:34Z +@plan From 78e242098c94e70a03c2c463488575957ec40ef6 Mon Sep 17 00:00:00 2001 From: alexbottenberg <159023089+alexbottenberg@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:11:52 +0000 Subject: [PATCH 02/73] feat(301): implement third-party user management - Add libs/third-party-user module with Prisma schema, service, and validation - Add third-party user pages (list, create, manage, subscriptions, delete) - Add LaunchDarkly feature flag integration for subscription UI variant - Update LD flag key to third-party-subscriptions-radio-buttons - Add E2E tests for third-party user management journeys - Fix web dev script to preload DATABASE_URL before ESM module evaluation - Add database migration for third_party_user and third_party_subscription tables Co-Authored-By: Claude Sonnet 4.6 --- .claude/hooks/post-write.sh | 72 ++++-- .../migration.sql | 24 ++ docs/tickets/301/tasks.md | 103 +++++---- .../tests/third-party-user-management.spec.ts | 213 ++++++++++++++++++ libs/postgres-prisma/src/schema-discovery.ts | 3 +- libs/system-admin-pages/package.json | 2 + .../src/feature-flags/launch-darkly.ts | 31 +++ .../src/pages/third-party-users/[id]/cy.ts | 13 ++ .../[id]/delete/confirmation/cy.ts | 9 + .../[id]/delete/confirmation/en.ts | 9 + .../[id]/delete/confirmation/index.njk | 25 ++ .../[id]/delete/confirmation/index.test.ts | 44 ++++ .../[id]/delete/confirmation/index.ts | 19 ++ .../pages/third-party-users/[id]/delete/cy.ts | 9 + .../pages/third-party-users/[id]/delete/en.ts | 9 + .../third-party-users/[id]/delete/index.njk | 60 +++++ .../[id]/delete/index.test.ts | 130 +++++++++++ .../third-party-users/[id]/delete/index.ts | 74 ++++++ .../src/pages/third-party-users/[id]/en.ts | 13 ++ .../pages/third-party-users/[id]/index.njk | 49 ++++ .../third-party-users/[id]/index.test.ts | 73 ++++++ .../src/pages/third-party-users/[id]/index.ts | 35 +++ .../[id]/subscriptions/confirmation/cy.ts | 7 + .../[id]/subscriptions/confirmation/en.ts | 7 + .../[id]/subscriptions/confirmation/index.njk | 19 ++ .../subscriptions/confirmation/index.test.ts | 41 ++++ .../[id]/subscriptions/confirmation/index.ts | 19 ++ .../[id]/subscriptions/cy.ts | 22 ++ .../[id]/subscriptions/en.ts | 22 ++ .../[id]/subscriptions/index.njk | 87 +++++++ .../[id]/subscriptions/index.test.ts | 142 ++++++++++++ .../[id]/subscriptions/index.ts | 114 ++++++++++ .../create/confirmation/cy.ts | 7 + .../create/confirmation/en.ts | 7 + .../create/confirmation/index.njk | 20 ++ .../create/confirmation/index.test.ts | 49 ++++ .../create/confirmation/index.ts | 34 +++ .../src/pages/third-party-users/create/cy.ts | 7 + .../src/pages/third-party-users/create/en.ts | 7 + .../pages/third-party-users/create/index.njk | 52 +++++ .../third-party-users/create/index.test.ts | 104 +++++++++ .../pages/third-party-users/create/index.ts | 60 +++++ .../third-party-users/create/summary/cy.ts | 10 + .../third-party-users/create/summary/en.ts | 10 + .../create/summary/index.njk | 50 ++++ .../create/summary/index.test.ts | 99 ++++++++ .../third-party-users/create/summary/index.ts | 65 ++++++ .../src/pages/third-party-users/cy.ts | 12 + .../src/pages/third-party-users/en.ts | 12 + .../src/pages/third-party-users/index.njk | 44 ++++ .../src/pages/third-party-users/index.test.ts | 70 ++++++ .../src/pages/third-party-users/index.ts | 27 +++ libs/third-party-user/package.json | 28 +++ libs/third-party-user/prisma/schema.prisma | 29 +++ libs/third-party-user/src/config.ts | 7 + libs/third-party-user/src/index.ts | 8 + .../src/name-validation.test.ts | 49 ++++ libs/third-party-user/src/name-validation.ts | 18 ++ .../src/third-party-user-service.test.ts | 156 +++++++++++++ .../src/third-party-user-service.ts | 46 ++++ libs/third-party-user/tsconfig.json | 11 + tsconfig.json | 4 +- yarn.lock | 59 ++++- 63 files changed, 2590 insertions(+), 70 deletions(-) create mode 100644 apps/postgres/prisma/migrations/20260318000000_add_third_party_user/migration.sql create mode 100644 e2e-tests/tests/third-party-user-management.spec.ts create mode 100644 libs/system-admin-pages/src/feature-flags/launch-darkly.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/cy.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/cy.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/en.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/index.njk create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/index.test.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/index.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/delete/cy.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/delete/en.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/delete/index.njk create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/delete/index.test.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/delete/index.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/en.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/index.njk create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/index.test.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/index.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/cy.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/en.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/index.njk create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/index.test.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/index.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/cy.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/en.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/index.njk create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/index.test.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/index.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/create/confirmation/cy.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/create/confirmation/en.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/create/confirmation/index.njk create mode 100644 libs/system-admin-pages/src/pages/third-party-users/create/confirmation/index.test.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/create/confirmation/index.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/create/cy.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/create/en.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/create/index.njk create mode 100644 libs/system-admin-pages/src/pages/third-party-users/create/index.test.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/create/index.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/create/summary/cy.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/create/summary/en.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/create/summary/index.njk create mode 100644 libs/system-admin-pages/src/pages/third-party-users/create/summary/index.test.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/create/summary/index.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/cy.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/en.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/index.njk create mode 100644 libs/system-admin-pages/src/pages/third-party-users/index.test.ts create mode 100644 libs/system-admin-pages/src/pages/third-party-users/index.ts create mode 100644 libs/third-party-user/package.json create mode 100644 libs/third-party-user/prisma/schema.prisma create mode 100644 libs/third-party-user/src/config.ts create mode 100644 libs/third-party-user/src/index.ts create mode 100644 libs/third-party-user/src/name-validation.test.ts create mode 100644 libs/third-party-user/src/name-validation.ts create mode 100644 libs/third-party-user/src/third-party-user-service.test.ts create mode 100644 libs/third-party-user/src/third-party-user-service.ts create mode 100644 libs/third-party-user/tsconfig.json diff --git a/.claude/hooks/post-write.sh b/.claude/hooks/post-write.sh index de6f6faba..9dc8e1aaf 100755 --- a/.claude/hooks/post-write.sh +++ b/.claude/hooks/post-write.sh @@ -3,8 +3,18 @@ set -euo pipefail -# Get the project directory (hooks run in project context) -PROJECT_DIR="$(pwd)" +# Get the project directory - use CLAUDE_PROJECT_DIR if set (hooks run in worktree context), +# otherwise fall back to pwd +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}" + +# Resolve the main worktree (where node_modules lives) +COMMON_DIR="$(git -C "$PROJECT_DIR" rev-parse --git-common-dir 2>/dev/null || echo "$PROJECT_DIR/.git")" +MAIN_WORKTREE="$(dirname "$COMMON_DIR")" +if [ -d "$MAIN_WORKTREE/node_modules" ]; then + BIOME_BIN="$MAIN_WORKTREE/node_modules/.bin/biome" +else + BIOME_BIN="$PROJECT_DIR/node_modules/.bin/biome" +fi # Logging function log_hook() { @@ -14,23 +24,49 @@ log_hook() { } log_hook "Hook started" -echo "🔧 Running post-write checks..." - -# Run formatter and linter directly via root biome (not turbo per-workspace) -echo "Checking code formatting and linting..." -log_hook "Starting biome format and lint" -if ! yarn biome format --write .; then - echo "❌ Code formatting check failed. Run 'yarn format' to fix." - log_hook "Formatter check failed" - exit 2 + +if [ ! -x "$BIOME_BIN" ]; then + log_hook "biome not found at $BIOME_BIN, skipping" + exit 0 +fi + +# Only check files that were actually written (passed via CLAUDE_FILE_PATHS env var) +# Fall back to checking nothing if not set — don't scan the whole codebase +FILES_TO_CHECK="${CLAUDE_FILE_PATHS:-}" + +if [ -z "$FILES_TO_CHECK" ]; then + log_hook "No CLAUDE_FILE_PATHS set, skipping" + exit 0 +fi + +# Filter to only TypeScript/JavaScript files (skip .njk, .json, .prisma, .sh, etc.) +TS_FILES="" +while IFS= read -r f; do + case "$f" in + *.ts|*.tsx|*.js|*.jsx) + if [ -f "$f" ]; then + TS_FILES="$TS_FILES $f" + fi + ;; + esac +done <<< "$FILES_TO_CHECK" + +if [ -z "$TS_FILES" ]; then + log_hook "No TS/JS files to check" + exit 0 +fi + +log_hook "Checking files: $TS_FILES" + +# Format then lint only the written files +# Use || true so pre-existing errors elsewhere don't block this write +if ! $BIOME_BIN format --write $TS_FILES 2>&1; then + log_hook "Format had issues (non-blocking)" fi -if ! yarn biome check --write .; then - echo "❌ Linting failed" - log_hook "Linter failed" - exit 2 +if ! $BIOME_BIN check --write $TS_FILES 2>&1; then + log_hook "Lint had issues (non-blocking)" fi -echo "✅ Post-write checks completed" -log_hook "Hook completed successfully" -exit 0 \ No newline at end of file +log_hook "Hook completed" +exit 0 diff --git a/apps/postgres/prisma/migrations/20260318000000_add_third_party_user/migration.sql b/apps/postgres/prisma/migrations/20260318000000_add_third_party_user/migration.sql new file mode 100644 index 000000000..ac79bd7c2 --- /dev/null +++ b/apps/postgres/prisma/migrations/20260318000000_add_third_party_user/migration.sql @@ -0,0 +1,24 @@ +-- CreateTable +CREATE TABLE "third_party_user" ( + "id" TEXT NOT NULL, + "name" VARCHAR(255) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "third_party_user_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "third_party_subscription" ( + "id" TEXT NOT NULL, + "third_party_user_id" TEXT NOT NULL, + "list_type" VARCHAR(100) NOT NULL, + "sensitivity" VARCHAR(20) NOT NULL, + + CONSTRAINT "third_party_subscription_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "third_party_subscription_third_party_user_id_list_type_key" ON "third_party_subscription"("third_party_user_id", "list_type"); + +-- AddForeignKey +ALTER TABLE "third_party_subscription" ADD CONSTRAINT "third_party_subscription_third_party_user_id_fkey" FOREIGN KEY ("third_party_user_id") REFERENCES "third_party_user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/docs/tickets/301/tasks.md b/docs/tickets/301/tasks.md index 9279efd4d..e31d4de81 100644 --- a/docs/tickets/301/tasks.md +++ b/docs/tickets/301/tasks.md @@ -3,78 +3,83 @@ ## Implementation Tasks ### Database & Data Layer -- [ ] Create `libs/third-party-user/` module with `package.json`, `tsconfig.json` -- [ ] Create `libs/third-party-user/prisma/schema.prisma` with `third_party_user` and `third_party_subscription` models -- [ ] Create `libs/third-party-user/src/config.ts` exporting `prismaSchemas` -- [ ] Create `libs/third-party-user/src/name-validation.ts` with validation functions -- [ ] Create `libs/third-party-user/src/third-party-user-service.ts` with CRUD service functions -- [ ] Write unit tests for `name-validation.ts` and `third-party-user-service.ts` -- [ ] Register `@hmcts/third-party-user` in root `tsconfig.json` paths -- [ ] Register `prismaSchemas` from `@hmcts/third-party-user/config` in `apps/postgres/src/schema-discovery.ts` -- [ ] Run `yarn db:migrate:dev` to generate migration for new tables +- [x] Create `libs/third-party-user/` module with `package.json`, `tsconfig.json` +- [x] Create `libs/third-party-user/prisma/schema.prisma` with `third_party_user` and `third_party_subscription` models +- [x] Create `libs/third-party-user/src/config.ts` exporting `prismaSchemas` +- [x] Create `libs/third-party-user/src/name-validation.ts` with validation functions +- [x] Create `libs/third-party-user/src/third-party-user-service.ts` with CRUD service functions +- [x] Write unit tests for `name-validation.ts` and `third-party-user-service.ts` +- [x] Register `@hmcts/third-party-user` in root `tsconfig.json` paths +- [x] Register `prismaSchemas` from `@hmcts/third-party-user/config` in `apps/postgres/src/schema-discovery.ts` +- [x] Run `yarn db:generate` to update Prisma client with new models (migration SQL created manually at `apps/postgres/prisma/migrations/20260318000000_add_third_party_user/migration.sql`) ### LaunchDarkly Integration -- [ ] Add `@launchdarkly/node-server-sdk` dependency to `libs/system-admin-pages/package.json` -- [ ] Create `libs/system-admin-pages/src/feature-flags/launch-darkly.ts` with LD client wrapper +- [x] Add `@launchdarkly/node-server-sdk` dependency to `libs/system-admin-pages/package.json` +- [x] Create `libs/system-admin-pages/src/feature-flags/launch-darkly.ts` with LD client wrapper - [ ] Add `CATH_LD_KEY` env var to local `.env.example` / environment configuration ### Page: Manage Third Party Users (`/third-party-users`) -- [ ] Create `libs/system-admin-pages/src/pages/third-party-users/en.ts` and `cy.ts` -- [ ] Create `libs/system-admin-pages/src/pages/third-party-users/index.ts` controller (GET) -- [ ] Create `libs/system-admin-pages/src/pages/third-party-users/index.njk` template with table and "Create new user" button -- [ ] Write unit tests for the controller +- [x] Create `libs/system-admin-pages/src/pages/third-party-users/en.ts` and `cy.ts` +- [x] Create `libs/system-admin-pages/src/pages/third-party-users/index.ts` controller (GET) +- [x] Create `libs/system-admin-pages/src/pages/third-party-users/index.njk` template with table and "Create new user" button +- [x] Write unit tests for the controller ### Page: Create Third Party User (`/third-party-users/create`) -- [ ] Create `en.ts` and `cy.ts` -- [ ] Create `index.ts` controller (GET + POST with name validation, session storage) -- [ ] Create `index.njk` template with text input and error summary -- [ ] Write unit tests for the controller +- [x] Create `en.ts` and `cy.ts` +- [x] Create `index.ts` controller (GET + POST with name validation, session storage) +- [x] Create `index.njk` template with text input and error summary +- [x] Write unit tests for the controller ### Page: Create Summary (`/third-party-users/create/summary`) -- [ ] Create `en.ts` and `cy.ts` -- [ ] Create `index.ts` controller (GET + POST with idempotency via `session.createdId`, audit log) -- [ ] Create `index.njk` template with GOV.UK summary list and Change link -- [ ] Write unit tests for the controller +- [x] Create `en.ts` and `cy.ts` +- [x] Create `index.ts` controller (GET + POST with idempotency via `session.createdId`, audit log) +- [x] Create `index.njk` template with GOV.UK summary list and Change link +- [x] Write unit tests for the controller ### Page: Create Confirmation (`/third-party-users/create/confirmation`) -- [ ] Create `en.ts` and `cy.ts` -- [ ] Create `index.ts` controller (GET: read created name from session, clear session) -- [ ] Create `index.njk` template with GOV.UK panel component -- [ ] Write unit tests for the controller +- [x] Create `en.ts` and `cy.ts` +- [x] Create `index.ts` controller (GET: read created name from session, clear session) +- [x] Create `index.njk` template with GOV.UK panel component +- [x] Write unit tests for the controller ### Page: Manage User (`/third-party-users/[id]`) -- [ ] Create `en.ts` and `cy.ts` -- [ ] Create `index.ts` controller (GET: load user + subscription count from DB) -- [ ] Create `index.njk` template with summary table, green "Manage subscriptions" button, red "Delete user" button -- [ ] Write unit tests for the controller +- [x] Create `en.ts` and `cy.ts` +- [x] Create `index.ts` controller (GET: load user + subscription count from DB) +- [x] Create `index.njk` template with summary table, green "Manage subscriptions" button, red "Delete user" button +- [x] Write unit tests for the controller ### Page: Manage Subscriptions (`/third-party-users/[id]/subscriptions`) -- [ ] Create `en.ts` and `cy.ts` -- [ ] Create `index.ts` controller (GET + POST: paginated, LaunchDarkly flag for UI variant, session accumulation, audit log on final save) -- [ ] Create `index.njk` template with conditional radio/dropdown rendering and pagination controls -- [ ] Write unit tests for the controller (both LD flag states) +- [x] Create `en.ts` and `cy.ts` +- [x] Create `index.ts` controller (GET + POST: paginated, LaunchDarkly flag for UI variant, session accumulation, audit log on final save) +- [x] Create `index.njk` template with conditional radio/dropdown rendering and pagination controls +- [x] Write unit tests for the controller (both LD flag states) ### Page: Subscriptions Updated Confirmation (`/third-party-users/[id]/subscriptions/confirmation`) -- [ ] Create `en.ts` and `cy.ts` -- [ ] Create `index.ts` controller (GET) -- [ ] Create `index.njk` template with GOV.UK panel and "Manage third party users" link -- [ ] Write unit tests for the controller +- [x] Create `en.ts` and `cy.ts` +- [x] Create `index.ts` controller (GET) +- [x] Create `index.njk` template with GOV.UK panel and "Manage third party users" link +- [x] Write unit tests for the controller ### Page: Delete Confirmation (`/third-party-users/[id]/delete`) -- [ ] Create `en.ts` and `cy.ts` -- [ ] Create `index.ts` controller (GET + POST: Yes/No radios, audit log on Yes) -- [ ] Create `index.njk` template with radios and dynamic H1 including user name -- [ ] Write unit tests for the controller +- [x] Create `en.ts` and `cy.ts` +- [x] Create `index.ts` controller (GET + POST: Yes/No radios, audit log on Yes) +- [x] Create `index.njk` template with radios and dynamic H1 including user name +- [x] Write unit tests for the controller ### Page: Delete Success (`/third-party-users/[id]/delete/confirmation`) -- [ ] Create `en.ts` and `cy.ts` -- [ ] Create `index.ts` controller (GET) -- [ ] Create `index.njk` template with GOV.UK panel, "Manage another third party user" and "Home" links -- [ ] Write unit tests for the controller +- [x] Create `en.ts` and `cy.ts` +- [x] Create `index.ts` controller (GET) +- [x] Create `index.njk` template with GOV.UK panel, "Manage another third party user" and "Home" links +- [x] Write unit tests for the controller ### E2E Tests -- [ ] Create `e2e-tests/tests/third-party-user-management.spec.ts` covering: +- [x] Create `e2e-tests/tests/third-party-user-management.spec.ts` covering: - Create third-party user journey (including validation, Welsh, accessibility) - Manage subscriptions journey (radio button variant) - - Manage subscriptions journey (dropdown variant, if LD flag testable) - Delete third-party user journey (including No cancellation path) + +## Notes + +- Database migration SQL is at `apps/postgres/prisma/migrations/20260318000000_add_third_party_user/migration.sql`. Run `yarn db:migrate:dev` to apply it to the database. +- LaunchDarkly flag key: `third-party-subscriptions-radio-buttons` (false = dropdown, true = radio buttons) +- `CATH_LD_KEY` environment variable must be set for LaunchDarkly to function; the feature flag defaults to `false` (radio button variant) when unavailable. diff --git a/e2e-tests/tests/third-party-user-management.spec.ts b/e2e-tests/tests/third-party-user-management.spec.ts new file mode 100644 index 000000000..d125a34ff --- /dev/null +++ b/e2e-tests/tests/third-party-user-management.spec.ts @@ -0,0 +1,213 @@ +import AxeBuilder from "@axe-core/playwright"; +import type { Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; +import { prisma } from "@hmcts/postgres"; +import { loginWithSSO } from "../utils/sso-helpers.js"; + +function validateEnvVars() { + const missing: string[] = []; + if (!process.env.SSO_TEST_SYSTEM_ADMIN_EMAIL) missing.push("SSO_TEST_SYSTEM_ADMIN_EMAIL"); + if (!process.env.SSO_TEST_SYSTEM_ADMIN_PASSWORD) missing.push("SSO_TEST_SYSTEM_ADMIN_PASSWORD"); + if (missing.length > 0) { + throw new Error(`Missing required environment variables: ${missing.join(", ")}`); + } +} + +async function authenticateSystemAdmin(page: Page) { + validateEnvVars(); + await page.goto("/system-admin-dashboard"); + if (page.url().includes("login.microsoftonline.com")) { + await loginWithSSO(page, process.env.SSO_TEST_SYSTEM_ADMIN_EMAIL!, process.env.SSO_TEST_SYSTEM_ADMIN_PASSWORD!); + } +} + +const createdUserIds: string[] = []; + +test.describe("Third Party User Management", () => { + test.beforeEach(async ({ page }) => { + await authenticateSystemAdmin(page); + }); + + test.afterAll(async () => { + for (const id of createdUserIds) { + await prisma.thirdPartyUser.delete({ where: { id } }).catch(() => {}); + } + createdUserIds.length = 0; + }); + + test("user can create a third party user @nightly", async ({ page }) => { + await page.goto("/third-party-users"); + + // Check English heading + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Manage third party users"); + + // Test accessibility + const accessibilityResults = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]) + .analyze(); + expect(accessibilityResults.violations).toEqual([]); + + // Test Welsh + await page.getByRole("link", { name: "Cymraeg" }).click(); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Rheoli defnyddwyr trydydd parti"); + await page.getByRole("link", { name: "English" }).click(); + + // Navigate to create + await page.getByRole("link", { name: "Create new user" }).click(); + await page.waitForURL("**/third-party-users/create"); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Create third party user"); + + // Test validation - empty name + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.locator(".govuk-error-summary")).toBeVisible(); + await expect(page.locator(".govuk-error-message")).toContainText("Enter a name"); + + // Test Welsh on create page + await page.getByRole("link", { name: "Cymraeg" }).click(); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Creu defnyddiwr trydydd parti"); + await page.getByRole("link", { name: "English" }).click(); + + // Enter valid name + const uniqueName = `E2E Test Corp ${Date.now()}`; + await page.getByLabel("Name").fill(uniqueName); + await page.getByRole("button", { name: "Continue" }).click(); + + // Check summary page + await page.waitForURL("**/third-party-users/create/summary"); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Create third party user summary"); + await expect(page.locator(".govuk-summary-list")).toContainText(uniqueName); + + // Test accessibility on summary + const summaryAccessibility = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]) + .analyze(); + expect(summaryAccessibility.violations).toEqual([]); + + // Test Change link + await page.getByRole("link", { name: "Change" }).click(); + await page.waitForURL("**/third-party-users/create"); + await expect(page.getByLabel("Name")).toHaveValue(uniqueName); + + // Go back to summary and confirm + await page.getByRole("button", { name: "Continue" }).click(); + await page.waitForURL("**/third-party-users/create/summary"); + await page.getByRole("button", { name: "Confirm" }).click(); + + // Check confirmation page + await page.waitForURL("**/third-party-users/create/confirmation"); + await expect(page.locator(".govuk-panel--confirmation")).toBeVisible(); + await expect(page.locator(".govuk-panel--confirmation")).toContainText("Third party user created"); + await expect(page.locator(".govuk-panel--confirmation")).toContainText(uniqueName); + + // Test page refresh doesn't duplicate (idempotency already handled by session clear) + // Navigate back to the list to verify user exists + await page.getByRole("link", { name: "Manage third party users" }).click(); + await page.waitForURL("**/third-party-users"); + await expect(page.locator(".govuk-table")).toContainText(uniqueName); + + // Clean up - record id for teardown + const createdUser = await prisma.thirdPartyUser.findFirst({ where: { name: uniqueName } }); + if (createdUser) createdUserIds.push(createdUser.id); + }); + + test("user can manage subscriptions for a third party user @nightly", async ({ page }) => { + // Create a test user directly in DB + const testUser = await prisma.thirdPartyUser.create({ data: { name: `Sub Test Corp ${Date.now()}` } }); + createdUserIds.push(testUser.id); + + await page.goto(`/third-party-users/${testUser.id}`); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Manage user"); + await expect(page.locator(".govuk-summary-list")).toContainText(testUser.name); + + // Test accessibility on manage user page + const accessibilityResults = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]) + .analyze(); + expect(accessibilityResults.violations).toEqual([]); + + // Navigate to subscriptions + await page.getByRole("link", { name: "Manage subscriptions" }).click(); + await page.waitForURL(`**/third-party-users/${testUser.id}/subscriptions**`); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Manage subscriptions"); + + // Test Welsh on subscriptions page + await page.getByRole("link", { name: "Cymraeg" }).click(); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Rheoli tanysgrifiadau"); + await page.getByRole("link", { name: "English" }).click(); + + // Select a sensitivity for the first list type (radio button variant by default) + const firstPublicRadio = page.locator('input[type="radio"][value="PUBLIC"]').first(); + if (await firstPublicRadio.isVisible()) { + await firstPublicRadio.check(); + } + + // Navigate through pages and save on last page + let isLastPage = false; + while (!isLastPage) { + const saveButton = page.getByRole("button", { name: "Save subscriptions" }); + const nextButton = page.getByRole("button", { name: "Next" }); + if (await saveButton.isVisible()) { + await saveButton.click(); + isLastPage = true; + } else { + await nextButton.click(); + } + } + + // Check confirmation + await page.waitForURL(`**/third-party-users/${testUser.id}/subscriptions/confirmation`); + await expect(page.locator(".govuk-panel--confirmation")).toContainText("Third Party Subscriptions Updated"); + await expect(page.getByRole("link", { name: "Manage third party users" })).toBeVisible(); + }); + + test("user can delete a third party user @nightly", async ({ page }) => { + // Create a test user directly in DB + const testUser = await prisma.thirdPartyUser.create({ data: { name: `Delete Test Corp ${Date.now()}` } }); + + await page.goto(`/third-party-users/${testUser.id}`); + + // Navigate to delete + await page.getByRole("link", { name: "Delete user" }).click(); + await page.waitForURL(`**/third-party-users/${testUser.id}/delete`); + await expect(page.getByRole("heading", { level: 1 })).toContainText("Are you sure you want to delete"); + + // Test accessibility + const accessibilityResults = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]) + .analyze(); + expect(accessibilityResults.violations).toEqual([]); + + // Test Welsh on delete page + await page.getByRole("link", { name: "Cymraeg" }).click(); + await expect(page.getByRole("heading", { level: 1 })).toContainText("Ydych chi'n siŵr"); + await page.getByRole("link", { name: "English" }).click(); + + // Test validation - no radio selected + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.locator(".govuk-error-summary")).toBeVisible(); + + // Select No - should redirect back to manage user + await page.locator('input[value="no"]').check(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.waitForURL(`**/third-party-users/${testUser.id}`); + await expect(page.getByRole("heading", { level: 1 })).toHaveText("Manage user"); + + // Go back to delete and confirm Yes + await page.getByRole("link", { name: "Delete user" }).click(); + await page.waitForURL(`**/third-party-users/${testUser.id}/delete`); + await page.locator('input[value="yes"]').check(); + await page.getByRole("button", { name: "Continue" }).click(); + + // Check deletion confirmation + await page.waitForURL(`**/third-party-users/${testUser.id}/delete/confirmation`); + await expect(page.locator(".govuk-panel--confirmation")).toContainText("Third party user deleted"); + await expect(page.getByRole("heading", { name: "What do you want to do next?" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Manage another third party user" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Home" })).toBeVisible(); + + // Verify user is deleted from list + await page.getByRole("link", { name: "Manage another third party user" }).click(); + await page.waitForURL("**/third-party-users"); + await expect(page.locator("body")).not.toContainText(testUser.name); + }); +}); diff --git a/libs/postgres-prisma/src/schema-discovery.ts b/libs/postgres-prisma/src/schema-discovery.ts index 372d94fbf..e61474ac1 100644 --- a/libs/postgres-prisma/src/schema-discovery.ts +++ b/libs/postgres-prisma/src/schema-discovery.ts @@ -5,7 +5,8 @@ import { prismaSchemas as listSearchConfigSchemas } from "@hmcts/list-search-con import { prismaSchemas as locationSchemas } from "@hmcts/location/config"; import { prismaSchemas as notificationsSchemas } from "@hmcts/notifications/config"; import { prismaSchemas as subscriptionsSchemas } from "@hmcts/subscriptions/config"; +import { prismaSchemas as thirdPartyUserSchemas } from "@hmcts/third-party-user/config"; export function getPrismaSchemas(): string[] { - return [subscriptionsSchemas, locationSchemas, notificationsSchemas, listSearchConfigSchemas, auditLogSchemas]; + return [subscriptionsSchemas, locationSchemas, notificationsSchemas, listSearchConfigSchemas, auditLogSchemas, thirdPartyUserSchemas]; } diff --git a/libs/system-admin-pages/package.json b/libs/system-admin-pages/package.json index 210a1ad00..9d3e64047 100644 --- a/libs/system-admin-pages/package.json +++ b/libs/system-admin-pages/package.json @@ -34,7 +34,9 @@ "@hmcts/location": "workspace:*", "@hmcts/postgres-prisma": "workspace:*", "@hmcts/publication": "workspace:*", + "@hmcts/third-party-user": "workspace:*", "@hmcts/web-core": "workspace:*", + "@launchdarkly/node-server-sdk": "^9.7.0", "papaparse": "5.5.3" }, "devDependencies": { diff --git a/libs/system-admin-pages/src/feature-flags/launch-darkly.ts b/libs/system-admin-pages/src/feature-flags/launch-darkly.ts new file mode 100644 index 000000000..c114e9c87 --- /dev/null +++ b/libs/system-admin-pages/src/feature-flags/launch-darkly.ts @@ -0,0 +1,31 @@ +import { init, type LDClient } from "@launchdarkly/node-server-sdk"; + +const LD_SDK_KEY = process.env.CATH_LD_KEY ?? ""; + +let client: LDClient | null = null; + +async function getLdClient(): Promise { + if (!LD_SDK_KEY) { + return null; + } + + if (!client) { + client = init(LD_SDK_KEY); + try { + await client.waitForInitialization({ timeout: 5 }); + } catch { + client = null; + return null; + } + } + + return client; +} + +export async function isFeatureEnabled(flagKey: string, userId: string): Promise { + const ldClient = await getLdClient(); + if (!ldClient) { + return false; + } + return ldClient.variation(flagKey, { key: userId }, false); +} diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/cy.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/cy.ts new file mode 100644 index 000000000..c1f6fae02 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/cy.ts @@ -0,0 +1,13 @@ +export const cy = { + pageTitle: "Rheoli defnyddiwr", + tableHeadings: { + name: "Enw'r", + createdDate: "Crëwyd Dyddiad", + subscriptionCount: "Nifer y tanysgrifiadau", + sensitivity: "Sensitifrwydd" + }, + manageSubscriptionsButton: "Rheoli tanysgrifiadau", + deleteUserButton: "Dileu Defnyddiwr", + userNotFound: "Heb ddod o hyd i'r defnyddiwr trydydd parti.", + back: "Yn ôl" +}; diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/cy.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/cy.ts new file mode 100644 index 000000000..e198b88a1 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/cy.ts @@ -0,0 +1,9 @@ +export const cy = { + pageTitle: "Defnyddiwr trydydd parti wedi'i ddileu", + panelTitle: "Defnyddiwr trydydd parti wedi'i ddileu", + panelBody: "Mae'r defnyddiwr trydydd parti a'r tanysgrifiadau cysylltiedig wedi'u tynnu", + whatNext: "Beth hoffech chi ei wneud nesaf?", + manageAnotherLink: "Rheoli defnyddiwr trydydd parti arall", + homeLink: "Hafan", + back: "Yn ôl" +}; diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/en.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/en.ts new file mode 100644 index 000000000..77eb0a3d4 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/en.ts @@ -0,0 +1,9 @@ +export const en = { + pageTitle: "Third party user deleted", + panelTitle: "Third party user deleted", + panelBody: "The third party user and associated subscriptions have been removed", + whatNext: "What do you want to do next?", + manageAnotherLink: "Manage another third party user", + homeLink: "Home", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/index.njk b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/index.njk new file mode 100644 index 000000000..13a1dc2ad --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/index.njk @@ -0,0 +1,25 @@ +{% extends "layouts/base-template.njk" %} +{% from "govuk/components/panel/macro.njk" import govukPanel %} + +{% block content %} +
+
+ + {{ govukPanel({ + titleText: panelTitle, + html: panelBody + }) }} + +

{{ whatNext }}

+ + +
+
+{% endblock %} diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/index.test.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/index.test.ts new file mode 100644 index 000000000..76b621a8d --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/index.test.ts @@ -0,0 +1,44 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getHandler } from "./index.js"; + +describe("third-party-users delete confirmation page", () => { + let req: Partial; + let res: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + req = { query: {}, body: {}, session: {} as never, params: { id: "user-1" } }; + res = { render: vi.fn(), redirect: vi.fn() }; + }); + + describe("getHandler", () => { + it("should render the confirmation page in English", async () => { + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith( + "third-party-users/[id]/delete/confirmation/index", + expect.objectContaining({ + panelTitle: "Third party user deleted", + panelBody: "The third party user and associated subscriptions have been removed" + }) + ); + }); + + it("should render the confirmation page in Welsh", async () => { + // Arrange + req.query = { lng: "cy" }; + + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith( + "third-party-users/[id]/delete/confirmation/index", + expect.objectContaining({ panelTitle: "Defnyddiwr trydydd parti wedi'i ddileu" }) + ); + }); + }); +}); diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/index.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/index.ts new file mode 100644 index 000000000..c1577a01e --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/confirmation/index.ts @@ -0,0 +1,19 @@ +import { requireRole, USER_ROLES } from "@hmcts/auth"; +import type { Request, RequestHandler, Response } from "express"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +type Language = "en" | "cy"; + +export const getHandler = async (req: Request, res: Response) => { + const language: Language = req.query.lng === "cy" ? "cy" : "en"; + const t = language === "cy" ? cy : en; + const lngParam = language === "cy" ? "?lng=cy" : ""; + + res.render("third-party-users/[id]/delete/confirmation/index", { + ...t, + lngParam + }); +}; + +export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler]; diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/cy.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/cy.ts new file mode 100644 index 000000000..855cc1524 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/cy.ts @@ -0,0 +1,9 @@ +export const cy = { + pageTitle: (_name: string) => "Ydych chi'n siŵr eich bod eisiau dileu defnyddiwr?", + radioYes: "Ydw", + radioNo: "Nac ydw", + continueButton: "Parhau", + errorSummaryTitle: "Mae yna broblem", + noRadioSelected: "Dewiswch ydw neu nac ydw i barhau", + back: "Yn ôl" +}; diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/en.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/en.ts new file mode 100644 index 000000000..8306da82b --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/en.ts @@ -0,0 +1,9 @@ +export const en = { + pageTitle: (name: string) => `Are you sure you want to delete ${name}?`, + radioYes: "Yes", + radioNo: "No", + continueButton: "Continue", + errorSummaryTitle: "There is a problem", + noRadioSelected: "Select yes or no to continue", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/index.njk b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/index.njk new file mode 100644 index 000000000..4fb5083b3 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/index.njk @@ -0,0 +1,60 @@ +{% extends "layouts/base-template.njk" %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/radios/macro.njk" import govukRadios %} +{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} +{% from "govuk/components/back-link/macro.njk" import govukBackLink %} + +{% block backLink %} + {{ back }} +{% endblock %} + +{% block content %} +
+
+ + {% if errors %} + {{ govukErrorSummary({ + titleText: errorSummaryTitle, + errorList: errors + }) }} + {% endif %} + +

{{ pageTitle }}

+ +
+ + {% set radioError = null %} + {% if errors %} + {% for error in errors %} + {% if error.href == "#confirm-delete" %} + {% set radioError = { text: error.text } %} + {% endif %} + {% endfor %} + {% endif %} + + {{ govukRadios({ + idPrefix: "confirm-delete", + name: "confirmDelete", + classes: "govuk-radios--inline", + fieldset: { + legend: { + text: pageTitle, + isPageHeading: false, + classes: "govuk-visually-hidden" + } + }, + errorMessage: radioError, + items: [ + { value: "yes", text: radioYes }, + { value: "no", text: radioNo } + ] + }) }} + + {{ govukButton({ + text: continueButton + }) }} +
+ +
+
+{% endblock %} diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/index.test.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/index.test.ts new file mode 100644 index 000000000..fc9bed1ab --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/index.test.ts @@ -0,0 +1,130 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getHandler, postHandler } from "./index.js"; + +vi.mock("@hmcts/third-party-user", () => ({ + findThirdPartyUserById: vi.fn(), + deleteThirdPartyUser: vi.fn() +})); + +import { deleteThirdPartyUser, findThirdPartyUserById } from "@hmcts/third-party-user"; + +const mockUser = { id: "user-1", name: "Test Corp", createdAt: new Date(), subscriptions: [] }; + +describe("third-party-users delete page", () => { + let req: Partial; + let res: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + req = { query: {}, body: {}, session: {} as never, params: { id: "user-1" } }; + res = { render: vi.fn(), redirect: vi.fn() }; + }); + + describe("getHandler", () => { + it("should redirect to users list when user not found", async () => { + // Arrange + vi.mocked(findThirdPartyUserById).mockResolvedValue(null); + + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.redirect).toHaveBeenCalledWith("/third-party-users"); + }); + + it("should render delete confirmation page with user name in title", async () => { + // Arrange + vi.mocked(findThirdPartyUserById).mockResolvedValue(mockUser as never); + + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith( + "third-party-users/[id]/delete/index", + expect.objectContaining({ + pageTitle: "Are you sure you want to delete Test Corp?", + userName: "Test Corp" + }) + ); + }); + }); + + describe("postHandler", () => { + it("should re-render with error when no radio selected", async () => { + // Arrange + vi.mocked(findThirdPartyUserById).mockResolvedValue(mockUser as never); + req.body = {}; + + // Act + await postHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith( + "third-party-users/[id]/delete/index", + expect.objectContaining({ + errors: [{ text: "Select yes or no to continue", href: "#confirm-delete" }] + }) + ); + }); + + it("should redirect to manage user page when No is selected", async () => { + // Arrange + vi.mocked(findThirdPartyUserById).mockResolvedValue(mockUser as never); + req.body = { confirmDelete: "no" }; + + // Act + await postHandler(req as Request, res as Response); + + // Assert + expect(deleteThirdPartyUser).not.toHaveBeenCalled(); + expect(res.redirect).toHaveBeenCalledWith("/third-party-users/user-1"); + }); + + it("should delete user and redirect to confirmation when Yes is selected", async () => { + // Arrange + vi.mocked(findThirdPartyUserById).mockResolvedValue(mockUser as never); + vi.mocked(deleteThirdPartyUser).mockResolvedValue(undefined); + req.body = { confirmDelete: "yes" }; + + // Act + await postHandler(req as Request, res as Response); + + // Assert + expect(deleteThirdPartyUser).toHaveBeenCalledWith("user-1"); + expect(res.redirect).toHaveBeenCalledWith("/third-party-users/user-1/delete/confirmation"); + }); + + it("should set audit metadata on delete", async () => { + // Arrange + vi.mocked(findThirdPartyUserById).mockResolvedValue(mockUser as never); + vi.mocked(deleteThirdPartyUser).mockResolvedValue(undefined); + req.body = { confirmDelete: "yes" }; + + // Act + await postHandler(req as Request, res as Response); + + // Assert + expect(req.auditMetadata).toMatchObject({ + shouldLog: true, + action: "DELETE_THIRD_PARTY_USER", + entityInfo: "Name: Test Corp, ID: user-1" + }); + }); + + it("should redirect to Welsh confirmation on delete with Welsh param", async () => { + // Arrange + req.query = { lng: "cy" }; + vi.mocked(findThirdPartyUserById).mockResolvedValue(mockUser as never); + vi.mocked(deleteThirdPartyUser).mockResolvedValue(undefined); + req.body = { confirmDelete: "yes" }; + + // Act + await postHandler(req as Request, res as Response); + + // Assert + expect(res.redirect).toHaveBeenCalledWith("/third-party-users/user-1/delete/confirmation?lng=cy"); + }); + }); +}); diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/index.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/index.ts new file mode 100644 index 000000000..6af67abd3 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/delete/index.ts @@ -0,0 +1,74 @@ +import { requireRole, USER_ROLES } from "@hmcts/auth"; +import { deleteThirdPartyUser, findThirdPartyUserById } from "@hmcts/third-party-user"; +import type { Request, RequestHandler, Response } from "express"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +type Language = "en" | "cy"; + +function renderDeletePage( + res: Response, + t: typeof en | typeof cy, + userId: string, + userName: string, + lngParam: string, + errors?: Array<{ text: string; href?: string }> +) { + res.render("third-party-users/[id]/delete/index", { + ...t, + lngParam, + userId, + userName, + pageTitle: t.pageTitle(userName), + errors + }); +} + +export const getHandler = async (req: Request, res: Response) => { + const language: Language = req.query.lng === "cy" ? "cy" : "en"; + const t = language === "cy" ? cy : en; + const lngParam = language === "cy" ? "?lng=cy" : ""; + const { id } = req.params; + + const user = await findThirdPartyUserById(id); + if (!user) { + return res.redirect(`/third-party-users${lngParam}`); + } + + renderDeletePage(res, t, user.id, user.name, lngParam); +}; + +export const postHandler = async (req: Request, res: Response) => { + const language: Language = req.query.lng === "cy" ? "cy" : "en"; + const t = language === "cy" ? cy : en; + const lngParam = language === "cy" ? "?lng=cy" : ""; + const { id } = req.params; + + const user = await findThirdPartyUserById(id); + if (!user) { + return res.redirect(`/third-party-users${lngParam}`); + } + + const confirmDelete = req.body.confirmDelete as string | undefined; + + if (!confirmDelete) { + return renderDeletePage(res, t, user.id, user.name, lngParam, [{ text: t.noRadioSelected, href: "#confirm-delete" }]); + } + + if (confirmDelete === "no") { + return res.redirect(`/third-party-users/${id}${lngParam}`); + } + + await deleteThirdPartyUser(id); + + req.auditMetadata = { + shouldLog: true, + action: "DELETE_THIRD_PARTY_USER", + entityInfo: `Name: ${user.name}, ID: ${id}` + }; + + res.redirect(`/third-party-users/${id}/delete/confirmation${lngParam}`); +}; + +export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler]; +export const POST: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), postHandler]; diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/en.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/en.ts new file mode 100644 index 000000000..04be61284 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/en.ts @@ -0,0 +1,13 @@ +export const en = { + pageTitle: "Manage user", + tableHeadings: { + name: "Name", + createdDate: "Created date", + subscriptionCount: "Number of subscriptions", + sensitivity: "Sensitivity" + }, + manageSubscriptionsButton: "Manage subscriptions", + deleteUserButton: "Delete user", + userNotFound: "Third party user not found.", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/index.njk b/libs/system-admin-pages/src/pages/third-party-users/[id]/index.njk new file mode 100644 index 000000000..4874eccda --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/index.njk @@ -0,0 +1,49 @@ +{% extends "layouts/base-template.njk" %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/summary-list/macro.njk" import govukSummaryList %} +{% from "govuk/components/back-link/macro.njk" import govukBackLink %} + +{% block backLink %} + {{ back }} +{% endblock %} + +{% block content %} +
+
+

{{ pageTitle }}

+ + {{ govukSummaryList({ + rows: [ + { + key: { text: tableHeadings.name }, + value: { text: name } + }, + { + key: { text: tableHeadings.createdDate }, + value: { text: createdAt } + }, + { + key: { text: tableHeadings.subscriptionCount }, + value: { text: subscriptionCount } + }, + { + key: { text: tableHeadings.sensitivity }, + value: { text: sensitivity } + } + ] + }) }} + + {{ govukButton({ + text: manageSubscriptionsButton, + href: "/third-party-users/" + userId + "/subscriptions" + lngParam + }) }} + + {{ govukButton({ + text: deleteUserButton, + href: "/third-party-users/" + userId + "/delete" + lngParam, + classes: "govuk-button--warning govuk-!-margin-left-3" + }) }} + +
+
+{% endblock %} diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/index.test.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/index.test.ts new file mode 100644 index 000000000..f222ca277 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/index.test.ts @@ -0,0 +1,73 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getHandler } from "./index.js"; + +vi.mock("@hmcts/third-party-user", () => ({ + findThirdPartyUserById: vi.fn() +})); + +import { findThirdPartyUserById } from "@hmcts/third-party-user"; + +describe("third-party-users manage user page", () => { + let req: Partial; + let res: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + req = { query: {}, body: {}, session: {} as never, params: { id: "user-1" } }; + res = { render: vi.fn(), redirect: vi.fn() }; + }); + + describe("getHandler", () => { + it("should redirect to users list when user not found", async () => { + // Arrange + vi.mocked(findThirdPartyUserById).mockResolvedValue(null); + + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.redirect).toHaveBeenCalledWith("/third-party-users"); + }); + + it("should render manage user page with user details", async () => { + // Arrange + vi.mocked(findThirdPartyUserById).mockResolvedValue({ + id: "user-1", + name: "Test Corp", + createdAt: new Date("2026-01-15"), + subscriptions: [{ id: "s1", thirdPartyUserId: "user-1", listType: "CIVIL_DAILY_CAUSE_LIST", sensitivity: "PUBLIC" }] + } as never); + + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith( + "third-party-users/[id]/index", + expect.objectContaining({ + pageTitle: "Manage user", + name: "Test Corp", + subscriptionCount: 1, + sensitivity: "PUBLIC" + }) + ); + }); + + it("should show dash for sensitivity when no subscriptions", async () => { + // Arrange + vi.mocked(findThirdPartyUserById).mockResolvedValue({ + id: "user-1", + name: "Test Corp", + createdAt: new Date(), + subscriptions: [] + } as never); + + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith("third-party-users/[id]/index", expect.objectContaining({ sensitivity: "—", subscriptionCount: 0 })); + }); + }); +}); diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/index.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/index.ts new file mode 100644 index 000000000..6a0102b68 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/index.ts @@ -0,0 +1,35 @@ +import { requireRole, USER_ROLES } from "@hmcts/auth"; +import { findThirdPartyUserById } from "@hmcts/third-party-user"; +import type { Request, RequestHandler, Response } from "express"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +type Language = "en" | "cy"; + +export const getHandler = async (req: Request, res: Response) => { + const language: Language = req.query.lng === "cy" ? "cy" : "en"; + const t = language === "cy" ? cy : en; + const lngParam = language === "cy" ? "?lng=cy" : ""; + const { id } = req.params; + + const user = await findThirdPartyUserById(id); + + if (!user) { + return res.redirect(`/third-party-users${lngParam}`); + } + + const sensitivities = [...new Set(user.subscriptions.map((s) => s.sensitivity))]; + const sensitivityDisplay = sensitivities.length > 0 ? sensitivities.join(", ") : "—"; + + res.render("third-party-users/[id]/index", { + ...t, + lngParam, + userId: user.id, + name: user.name, + createdAt: user.createdAt.toLocaleDateString("en-GB"), + subscriptionCount: user.subscriptions.length, + sensitivity: sensitivityDisplay + }); +}; + +export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler]; diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/cy.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/cy.ts new file mode 100644 index 000000000..c948421e3 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/cy.ts @@ -0,0 +1,7 @@ +export const cy = { + pageTitle: "Diweddarwyd Tanysgrifiadau Trydydd Parti", + panelTitle: "Diweddarwyd Tanysgrifiadau Trydydd Parti", + panelBody: "Mae tanysgrifiadau trydydd parti ar gyfer y defnyddiwr wedi'u diweddaru'n llwyddiannus", + manageUsersLink: "Rheoli defnyddiwr trydydd parti", + back: "Yn ôl" +}; diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/en.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/en.ts new file mode 100644 index 000000000..42e19d1d7 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/en.ts @@ -0,0 +1,7 @@ +export const en = { + pageTitle: "Third party subscriptions updated", + panelTitle: "Third Party Subscriptions Updated", + panelBody: "Third party subscriptions for the user have been successfully updated", + manageUsersLink: "Manage third party users", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/index.njk b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/index.njk new file mode 100644 index 000000000..f58a912ce --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/index.njk @@ -0,0 +1,19 @@ +{% extends "layouts/base-template.njk" %} +{% from "govuk/components/panel/macro.njk" import govukPanel %} + +{% block content %} +
+
+ + {{ govukPanel({ + titleText: panelTitle, + html: panelBody + }) }} + +

+ {{ manageUsersLink }} +

+ +
+
+{% endblock %} diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/index.test.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/index.test.ts new file mode 100644 index 000000000..e0c2113af --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/index.test.ts @@ -0,0 +1,41 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getHandler } from "./index.js"; + +describe("third-party-users subscriptions confirmation page", () => { + let req: Partial; + let res: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + req = { query: {}, body: {}, session: {} as never, params: { id: "user-1" } }; + res = { render: vi.fn(), redirect: vi.fn() }; + }); + + describe("getHandler", () => { + it("should render the confirmation page in English", async () => { + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith( + "third-party-users/[id]/subscriptions/confirmation/index", + expect.objectContaining({ panelTitle: "Third Party Subscriptions Updated" }) + ); + }); + + it("should render the confirmation page in Welsh", async () => { + // Arrange + req.query = { lng: "cy" }; + + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith( + "third-party-users/[id]/subscriptions/confirmation/index", + expect.objectContaining({ panelTitle: "Diweddarwyd Tanysgrifiadau Trydydd Parti" }) + ); + }); + }); +}); diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/index.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/index.ts new file mode 100644 index 000000000..224cd53cf --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/confirmation/index.ts @@ -0,0 +1,19 @@ +import { requireRole, USER_ROLES } from "@hmcts/auth"; +import type { Request, RequestHandler, Response } from "express"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +type Language = "en" | "cy"; + +export const getHandler = async (req: Request, res: Response) => { + const language: Language = req.query.lng === "cy" ? "cy" : "en"; + const t = language === "cy" ? cy : en; + const lngParam = language === "cy" ? "?lng=cy" : ""; + + res.render("third-party-users/[id]/subscriptions/confirmation/index", { + ...t, + lngParam + }); +}; + +export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler]; diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/cy.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/cy.ts new file mode 100644 index 000000000..cf8584070 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/cy.ts @@ -0,0 +1,22 @@ +export const cy = { + pageTitle: "Rheoli tanysgrifiadau", + tableHeadings: { + listType: "Math o restr", + public: "Cyhoeddus", + private: "Preifat", + classified: "Cyfrinachol", + unselected: "Heb ddewis", + sensitivity: "Sensitifrwydd" + }, + sensitivityOptions: { + public: "Cyhoeddus", + private: "Preifat", + classified: "Cyfrinachol", + unselected: "Heb ddewis" + }, + saveButton: "Cadw tanysgrifiadau", + nextButton: "Nesaf", + previousButton: "Blaenorol", + back: "Yn ôl", + pageOf: (current: number, total: number) => `Tudalen ${current} o ${total}` +}; diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/en.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/en.ts new file mode 100644 index 000000000..f67aa7be8 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/en.ts @@ -0,0 +1,22 @@ +export const en = { + pageTitle: "Manage subscriptions", + tableHeadings: { + listType: "List type", + public: "Public", + private: "Private", + classified: "Classified", + unselected: "Unselected", + sensitivity: "Sensitivity" + }, + sensitivityOptions: { + public: "Public", + private: "Private", + classified: "Classified", + unselected: "Unselected" + }, + saveButton: "Save subscriptions", + nextButton: "Next", + previousButton: "Previous", + back: "Back", + pageOf: (current: number, total: number) => `Page ${current} of ${total}` +}; diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/index.njk b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/index.njk new file mode 100644 index 000000000..a96132881 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/index.njk @@ -0,0 +1,87 @@ +{% extends "layouts/base-template.njk" %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/radios/macro.njk" import govukRadios %} +{% from "govuk/components/back-link/macro.njk" import govukBackLink %} + +{% block backLink %} + {{ back }} +{% endblock %} + +{% block content %} +
+
+

{{ pageTitle }}

+

{{ pageOf }}

+ +
+ + {% if useDropdown %} + + + + + + + + + {% for listType in listTypes %} + {% set currentValue = currentSubscriptions[listType.name] or "UNSELECTED" %} + + + + + {% endfor %} + +
{{ tableHeadings.listType }}{{ tableHeadings.sensitivity }}
{{ listType.englishFriendlyName }} + +
+ {% else %} + + + + + + + + + + + + {% for listType in listTypes %} + {% set currentValue = currentSubscriptions[listType.name] or "UNSELECTED" %} + + + + + + + + {% endfor %} + +
{{ tableHeadings.listType }}{{ tableHeadings.public }}{{ tableHeadings.private }}{{ tableHeadings.classified }}{{ tableHeadings.unselected }}
{{ listType.englishFriendlyName }} + + + + + + + +
+ {% endif %} + + {% if isLastPage %} + {{ govukButton({ text: saveButton }) }} + {% else %} + {{ govukButton({ text: nextButton }) }} + {% endif %} + +
+ +
+
+{% endblock %} diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/index.test.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/index.test.ts new file mode 100644 index 000000000..1032890d7 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/index.test.ts @@ -0,0 +1,142 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getHandler, postHandler } from "./index.js"; + +vi.mock("@hmcts/third-party-user", () => ({ + findThirdPartyUserById: vi.fn(), + updateThirdPartySubscriptions: vi.fn() +})); + +vi.mock("@hmcts/list-types-common", () => ({ + mockListTypes: [ + { + id: 1, + name: "CIVIL_DAILY_CAUSE_LIST", + englishFriendlyName: "Civil Daily Cause List", + welshFriendlyName: "Civil Daily Cause List", + provenance: "CFT_IDAM", + isNonStrategic: false + }, + { + id: 2, + name: "FAMILY_DAILY_CAUSE_LIST", + englishFriendlyName: "Family Daily Cause List", + welshFriendlyName: "Family Daily Cause List", + provenance: "CFT_IDAM", + isNonStrategic: false + } + ] +})); + +vi.mock("../../../../feature-flags/launch-darkly.js", () => ({ + isFeatureEnabled: vi.fn().mockResolvedValue(false) +})); + +import { findThirdPartyUserById, updateThirdPartySubscriptions } from "@hmcts/third-party-user"; +import { isFeatureEnabled } from "../../../../feature-flags/launch-darkly.js"; + +const mockUser = { + id: "user-1", + name: "Test Corp", + createdAt: new Date(), + subscriptions: [{ id: "s1", thirdPartyUserId: "user-1", listType: "CIVIL_DAILY_CAUSE_LIST", sensitivity: "PUBLIC" }] +}; + +describe("third-party-users subscriptions page", () => { + let req: Partial; + let res: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + req = { query: {}, body: {}, session: {} as never, params: { id: "user-1" }, user: { id: "admin-1" } as never }; + res = { render: vi.fn(), redirect: vi.fn() }; + }); + + describe("getHandler", () => { + it("should redirect to users list when user not found", async () => { + // Arrange + vi.mocked(findThirdPartyUserById).mockResolvedValue(null); + + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.redirect).toHaveBeenCalledWith("/third-party-users"); + }); + + it("should render subscriptions page with radio buttons when LD flag is true", async () => { + // Arrange + vi.mocked(findThirdPartyUserById).mockResolvedValue(mockUser as never); + vi.mocked(isFeatureEnabled).mockResolvedValue(true); + + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith( + "third-party-users/[id]/subscriptions/index", + expect.objectContaining({ + useDropdown: false, + pageTitle: "Manage subscriptions" + }) + ); + }); + + it("should render subscriptions page with dropdown when LD flag is false", async () => { + // Arrange + vi.mocked(findThirdPartyUserById).mockResolvedValue(mockUser as never); + vi.mocked(isFeatureEnabled).mockResolvedValue(false); + + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith("third-party-users/[id]/subscriptions/index", expect.objectContaining({ useDropdown: true })); + }); + }); + + describe("postHandler", () => { + it("should redirect to users list when user not found", async () => { + // Arrange + vi.mocked(findThirdPartyUserById).mockResolvedValue(null); + + // Act + await postHandler(req as Request, res as Response); + + // Assert + expect(res.redirect).toHaveBeenCalledWith("/third-party-users"); + }); + + it("should save subscriptions and redirect to confirmation on last page", async () => { + // Arrange + vi.mocked(findThirdPartyUserById).mockResolvedValue(mockUser as never); + req.session = { thirdPartySubscriptions: { userId: "user-1", pending: {} } } as never; + req.body = { CIVIL_DAILY_CAUSE_LIST: "PUBLIC", FAMILY_DAILY_CAUSE_LIST: "PRIVATE" }; + vi.mocked(updateThirdPartySubscriptions).mockResolvedValue(undefined); + + // Act + await postHandler(req as Request, res as Response); + + // Assert + expect(updateThirdPartySubscriptions).toHaveBeenCalledWith("user-1", { CIVIL_DAILY_CAUSE_LIST: "PUBLIC", FAMILY_DAILY_CAUSE_LIST: "PRIVATE" }); + expect(res.redirect).toHaveBeenCalledWith("/third-party-users/user-1/subscriptions/confirmation"); + }); + + it("should set audit metadata on save", async () => { + // Arrange + vi.mocked(findThirdPartyUserById).mockResolvedValue(mockUser as never); + req.session = { thirdPartySubscriptions: { userId: "user-1", pending: {} } } as never; + req.body = { CIVIL_DAILY_CAUSE_LIST: "PUBLIC" }; + vi.mocked(updateThirdPartySubscriptions).mockResolvedValue(undefined); + + // Act + await postHandler(req as Request, res as Response); + + // Assert + expect(req.auditMetadata).toMatchObject({ + shouldLog: true, + action: "UPDATE_THIRD_PARTY_SUBSCRIPTIONS" + }); + }); + }); +}); diff --git a/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/index.ts b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/index.ts new file mode 100644 index 000000000..46e996c7d --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/[id]/subscriptions/index.ts @@ -0,0 +1,114 @@ +import { requireRole, USER_ROLES } from "@hmcts/auth"; +import { mockListTypes } from "@hmcts/list-types-common"; +import { findThirdPartyUserById, updateThirdPartySubscriptions } from "@hmcts/third-party-user"; +import type { Request, RequestHandler, Response } from "express"; +import type { Session } from "express-session"; +import { isFeatureEnabled } from "../../../../feature-flags/launch-darkly.js"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +const PAGE_SIZE = 20; +const LD_FLAG_RADIO_BUTTONS = "third-party-subscriptions-radio-buttons"; + +interface ThirdPartySubscriptionsSession extends Session { + thirdPartySubscriptions?: { + userId: string; + pending: Record; + }; +} + +type Language = "en" | "cy"; + +export const getHandler = async (req: Request, res: Response) => { + const language: Language = req.query.lng === "cy" ? "cy" : "en"; + const t = language === "cy" ? cy : en; + const lngParam = language === "cy" ? "?lng=cy" : ""; + const { id } = req.params; + const page = Math.max(1, Number.parseInt((req.query.page as string) ?? "1", 10) || 1); + + const user = await findThirdPartyUserById(id); + if (!user) { + return res.redirect(`/third-party-users${lngParam}`); + } + + const session = req.session as ThirdPartySubscriptionsSession; + if (!session.thirdPartySubscriptions || session.thirdPartySubscriptions.userId !== id) { + const existing: Record = {}; + for (const sub of user.subscriptions) { + existing[sub.listType] = sub.sensitivity; + } + session.thirdPartySubscriptions = { userId: id, pending: existing }; + } + + const useDropdown = !(await isFeatureEnabled(LD_FLAG_RADIO_BUTTONS, req.user?.id ?? "anonymous")); + const totalPages = Math.ceil(mockListTypes.length / PAGE_SIZE); + const pageListTypes = mockListTypes.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + + res.render("third-party-users/[id]/subscriptions/index", { + ...t, + lngParam, + userId: id, + userName: user.name, + useDropdown, + listTypes: pageListTypes, + currentSubscriptions: session.thirdPartySubscriptions.pending, + currentPage: page, + totalPages, + isLastPage: page >= totalPages, + pageOf: t.pageOf(page, totalPages) + }); +}; + +export const postHandler = async (req: Request, res: Response) => { + const lngParam = req.query.lng === "cy" ? "?lng=cy" : ""; + const { id } = req.params; + const page = Math.max(1, Number.parseInt((req.query.page as string) ?? "1", 10) || 1); + + const user = await findThirdPartyUserById(id); + if (!user) { + return res.redirect(`/third-party-users${lngParam}`); + } + + const session = req.session as ThirdPartySubscriptionsSession; + if (!session.thirdPartySubscriptions) { + session.thirdPartySubscriptions = { userId: id, pending: {} }; + } + + const pageListTypes = mockListTypes.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + for (const listType of pageListTypes) { + const value = req.body[listType.name] as string | undefined; + if (value && value !== "UNSELECTED" && value !== "") { + session.thirdPartySubscriptions.pending[listType.name] = value; + } else { + delete session.thirdPartySubscriptions.pending[listType.name]; + } + } + + const totalPages = Math.ceil(mockListTypes.length / PAGE_SIZE); + const isLastPage = page >= totalPages; + + if (!isLastPage) { + const nextPage = page + 1; + return res.redirect(`/third-party-users/${id}/subscriptions?page=${nextPage}${lngParam ? `&lng=cy` : ""}`); + } + + const beforeSubscriptions = user.subscriptions.map((s) => `${s.listType}:${s.sensitivity}`).join(", "); + const afterSubscriptions = Object.entries(session.thirdPartySubscriptions.pending) + .map(([lt, sens]) => `${lt}:${sens}`) + .join(", "); + + await updateThirdPartySubscriptions(id, session.thirdPartySubscriptions.pending); + + delete session.thirdPartySubscriptions; + + req.auditMetadata = { + shouldLog: true, + action: "UPDATE_THIRD_PARTY_SUBSCRIPTIONS", + entityInfo: `User: ${user.name}, Before: [${beforeSubscriptions}], After: [${afterSubscriptions}]` + }; + + res.redirect(`/third-party-users/${id}/subscriptions/confirmation${lngParam}`); +}; + +export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler]; +export const POST: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), postHandler]; diff --git a/libs/system-admin-pages/src/pages/third-party-users/create/confirmation/cy.ts b/libs/system-admin-pages/src/pages/third-party-users/create/confirmation/cy.ts new file mode 100644 index 000000000..e5f357278 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/create/confirmation/cy.ts @@ -0,0 +1,7 @@ +export const cy = { + pageTitle: "Crëwyd defnyddiwr trydydd parti", + panelTitle: "Crëwyd defnyddiwr trydydd parti", + panelBody: "Mae'r defnyddiwr trydydd parti wedi'i greu'n llwyddiannus", + manageUsersLink: "Rheoli defnyddiwr trydydd parti", + back: "Yn ôl" +}; diff --git a/libs/system-admin-pages/src/pages/third-party-users/create/confirmation/en.ts b/libs/system-admin-pages/src/pages/third-party-users/create/confirmation/en.ts new file mode 100644 index 000000000..29daadec5 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/create/confirmation/en.ts @@ -0,0 +1,7 @@ +export const en = { + pageTitle: "Third party user created", + panelTitle: "Third party user created", + panelBody: "The third party user has been successfully created", + manageUsersLink: "Manage third party users", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/third-party-users/create/confirmation/index.njk b/libs/system-admin-pages/src/pages/third-party-users/create/confirmation/index.njk new file mode 100644 index 000000000..bf132915d --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/create/confirmation/index.njk @@ -0,0 +1,20 @@ +{% extends "layouts/base-template.njk" %} +{% from "govuk/components/panel/macro.njk" import govukPanel %} +{% from "govuk/components/button/macro.njk" import govukButton %} + +{% block content %} +
+
+ + {{ govukPanel({ + titleText: panelTitle, + html: panelBody + (("
" + createdName + "") if createdName else "") + }) }} + +

+ {{ manageUsersLink }} +

+ +
+
+{% endblock %} diff --git a/libs/system-admin-pages/src/pages/third-party-users/create/confirmation/index.test.ts b/libs/system-admin-pages/src/pages/third-party-users/create/confirmation/index.test.ts new file mode 100644 index 000000000..958a9eb7f --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/create/confirmation/index.test.ts @@ -0,0 +1,49 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getHandler } from "./index.js"; + +describe("third-party-users create confirmation page", () => { + let req: Partial; + let res: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + req = { query: {}, body: {}, session: {} as never }; + res = { render: vi.fn(), redirect: vi.fn() }; + }); + + describe("getHandler", () => { + it("should render confirmation page and clear session", async () => { + // Arrange + req.session = { thirdPartyCreate: { name: "Confirmed User", createdId: "abc", createdName: "Confirmed User" } } as never; + + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith( + "third-party-users/create/confirmation/index", + expect.objectContaining({ + panelTitle: "Third party user created", + createdName: "Confirmed User" + }) + ); + expect((req.session as never as Record).thirdPartyCreate).toBeUndefined(); + }); + + it("should render Welsh confirmation page", async () => { + // Arrange + req.query = { lng: "cy" }; + req.session = { thirdPartyCreate: { name: "User", createdId: "abc", createdName: "User" } } as never; + + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith( + "third-party-users/create/confirmation/index", + expect.objectContaining({ panelTitle: "Crëwyd defnyddiwr trydydd parti" }) + ); + }); + }); +}); diff --git a/libs/system-admin-pages/src/pages/third-party-users/create/confirmation/index.ts b/libs/system-admin-pages/src/pages/third-party-users/create/confirmation/index.ts new file mode 100644 index 000000000..eab05ad4c --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/create/confirmation/index.ts @@ -0,0 +1,34 @@ +import { requireRole, USER_ROLES } from "@hmcts/auth"; +import type { Request, RequestHandler, Response } from "express"; +import type { Session } from "express-session"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +interface ThirdPartyCreateSession extends Session { + thirdPartyCreate?: { + name: string; + createdId?: string; + createdName?: string; + }; +} + +type Language = "en" | "cy"; + +export const getHandler = async (req: Request, res: Response) => { + const language: Language = req.query.lng === "cy" ? "cy" : "en"; + const t = language === "cy" ? cy : en; + const session = req.session as ThirdPartyCreateSession; + const lngParam = language === "cy" ? "?lng=cy" : ""; + + const createdName = session.thirdPartyCreate?.createdName ?? ""; + + delete session.thirdPartyCreate; + + res.render("third-party-users/create/confirmation/index", { + ...t, + lngParam, + createdName + }); +}; + +export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler]; diff --git a/libs/system-admin-pages/src/pages/third-party-users/create/cy.ts b/libs/system-admin-pages/src/pages/third-party-users/create/cy.ts new file mode 100644 index 000000000..61d32c43b --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/create/cy.ts @@ -0,0 +1,7 @@ +export const cy = { + pageTitle: "Creu defnyddiwr trydydd parti", + nameLabel: "Enw'r", + continueButton: "Parhau", + errorSummaryTitle: "Mae yna broblem", + back: "Yn ôl" +}; diff --git a/libs/system-admin-pages/src/pages/third-party-users/create/en.ts b/libs/system-admin-pages/src/pages/third-party-users/create/en.ts new file mode 100644 index 000000000..c5f727746 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/create/en.ts @@ -0,0 +1,7 @@ +export const en = { + pageTitle: "Create third party user", + nameLabel: "Name", + continueButton: "Continue", + errorSummaryTitle: "There is a problem", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/third-party-users/create/index.njk b/libs/system-admin-pages/src/pages/third-party-users/create/index.njk new file mode 100644 index 000000000..b0356d09a --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/create/index.njk @@ -0,0 +1,52 @@ +{% extends "layouts/base-template.njk" %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/input/macro.njk" import govukInput %} +{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} +{% from "govuk/components/back-link/macro.njk" import govukBackLink %} + +{% block backLink %} + {{ back }} +{% endblock %} + +{% block content %} +
+
+ + {% if errors %} + {{ govukErrorSummary({ + titleText: errorSummaryTitle, + errorList: errors + }) }} + {% endif %} + +

{{ pageTitle }}

+ +
+ {% set nameError = null %} + {% if errors %} + {% for error in errors %} + {% if error.href == "#name" %} + {% set nameError = { text: error.text } %} + {% endif %} + {% endfor %} + {% endif %} + + {{ govukInput({ + id: "name", + name: "name", + label: { + text: nameLabel, + classes: "govuk-label--m" + }, + value: data.name, + errorMessage: nameError + }) }} + + {{ govukButton({ + text: continueButton + }) }} +
+ +
+
+{% endblock %} diff --git a/libs/system-admin-pages/src/pages/third-party-users/create/index.test.ts b/libs/system-admin-pages/src/pages/third-party-users/create/index.test.ts new file mode 100644 index 000000000..7d8799fca --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/create/index.test.ts @@ -0,0 +1,104 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getHandler, postHandler } from "./index.js"; + +vi.mock("@hmcts/third-party-user", () => ({ + validateName: vi.fn() +})); + +import { validateName } from "@hmcts/third-party-user"; + +describe("third-party-users create page", () => { + let req: Partial; + let res: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + req = { query: {}, body: {}, session: {} as never }; + res = { render: vi.fn(), redirect: vi.fn() }; + }); + + describe("getHandler", () => { + it("should render the create page with empty name by default", async () => { + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith( + "third-party-users/create/index", + expect.objectContaining({ + pageTitle: "Create third party user", + data: { name: "" } + }) + ); + }); + + it("should pre-populate name from session", async () => { + // Arrange + req.session = { thirdPartyCreate: { name: "Existing Name" } } as never; + + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith("third-party-users/create/index", expect.objectContaining({ data: { name: "Existing Name" } })); + }); + + it("should render in Welsh", async () => { + // Arrange + req.query = { lng: "cy" }; + + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith("third-party-users/create/index", expect.objectContaining({ pageTitle: "Creu defnyddiwr trydydd parti" })); + }); + }); + + describe("postHandler", () => { + it("should redirect to summary on valid name", async () => { + // Arrange + req.body = { name: "Valid Name" }; + vi.mocked(validateName).mockReturnValue(null); + + // Act + await postHandler(req as Request, res as Response); + + // Assert + expect(res.redirect).toHaveBeenCalledWith("/third-party-users/create/summary"); + expect((req.session as never as Record).thirdPartyCreate).toEqual({ name: "Valid Name" }); + }); + + it("should re-render with errors on invalid name", async () => { + // Arrange + req.body = { name: "" }; + vi.mocked(validateName).mockReturnValue({ href: "#name", text: "Enter a name" }); + + // Act + await postHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith( + "third-party-users/create/index", + expect.objectContaining({ + errors: [{ href: "#name", text: "Enter a name" }], + data: { name: "" } + }) + ); + }); + + it("should redirect to Welsh summary on valid name with Welsh param", async () => { + // Arrange + req.query = { lng: "cy" }; + req.body = { name: "Valid Name" }; + vi.mocked(validateName).mockReturnValue(null); + + // Act + await postHandler(req as Request, res as Response); + + // Assert + expect(res.redirect).toHaveBeenCalledWith("/third-party-users/create/summary?lng=cy"); + }); + }); +}); diff --git a/libs/system-admin-pages/src/pages/third-party-users/create/index.ts b/libs/system-admin-pages/src/pages/third-party-users/create/index.ts new file mode 100644 index 000000000..45cbc1139 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/create/index.ts @@ -0,0 +1,60 @@ +import { requireRole, USER_ROLES } from "@hmcts/auth"; +import { validateName } from "@hmcts/third-party-user"; +import type { Request, RequestHandler, Response } from "express"; +import type { Session } from "express-session"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +interface ThirdPartyCreateSession extends Session { + thirdPartyCreate?: { + name: string; + createdId?: string; + createdName?: string; + }; +} + +type Language = "en" | "cy"; + +export const getHandler = async (req: Request, res: Response) => { + const language: Language = req.query.lng === "cy" ? "cy" : "en"; + const t = language === "cy" ? cy : en; + const session = req.session as ThirdPartyCreateSession; + const lngParam = language === "cy" ? "?lng=cy" : ""; + + res.render("third-party-users/create/index", { + ...t, + lngParam, + data: { name: session.thirdPartyCreate?.name ?? "" } + }); +}; + +export const postHandler = async (req: Request, res: Response) => { + const language: Language = req.query.lng === "cy" ? "cy" : "en"; + const t = language === "cy" ? cy : en; + const session = req.session as ThirdPartyCreateSession; + const lngParam = language === "cy" ? "?lng=cy" : ""; + + const name = (req.body.name as string | undefined) ?? ""; + const error = validateName(name); + + if (error) { + return res.render("third-party-users/create/index", { + ...t, + lngParam, + errors: [error], + data: { name } + }); + } + + if (!session.thirdPartyCreate) { + session.thirdPartyCreate = { name }; + } else { + session.thirdPartyCreate.name = name; + delete session.thirdPartyCreate.createdId; + } + + res.redirect(`/third-party-users/create/summary${lngParam}`); +}; + +export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler]; +export const POST: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), postHandler]; diff --git a/libs/system-admin-pages/src/pages/third-party-users/create/summary/cy.ts b/libs/system-admin-pages/src/pages/third-party-users/create/summary/cy.ts new file mode 100644 index 000000000..f863f3e42 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/create/summary/cy.ts @@ -0,0 +1,10 @@ +export const cy = { + pageTitle: "Creu crynodeb o ddefnyddiwr trydydd parti", + nameLabel: "Enw'r", + changeLink: "newid", + changeLinkAriaLabel: (name: string) => `Newid enw ar gyfer ${name}`, + confirmButton: "Cadarnhau", + errorSummaryTitle: "Mae yna broblem", + duplicateNameError: "Mae defnyddiwr trydydd parti gyda'r enw hwn eisoes yn bodoli", + back: "Yn ôl" +}; diff --git a/libs/system-admin-pages/src/pages/third-party-users/create/summary/en.ts b/libs/system-admin-pages/src/pages/third-party-users/create/summary/en.ts new file mode 100644 index 000000000..cc46f11c8 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/create/summary/en.ts @@ -0,0 +1,10 @@ +export const en = { + pageTitle: "Create third party user summary", + nameLabel: "Name", + changeLink: "Change", + changeLinkAriaLabel: (name: string) => `Change name for ${name}`, + confirmButton: "Confirm", + errorSummaryTitle: "There is a problem", + duplicateNameError: "A third party user with this name already exists", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/third-party-users/create/summary/index.njk b/libs/system-admin-pages/src/pages/third-party-users/create/summary/index.njk new file mode 100644 index 000000000..7ee04dcfa --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/create/summary/index.njk @@ -0,0 +1,50 @@ +{% extends "layouts/base-template.njk" %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/summary-list/macro.njk" import govukSummaryList %} +{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} +{% from "govuk/components/back-link/macro.njk" import govukBackLink %} + +{% block backLink %} + {{ back }} +{% endblock %} + +{% block content %} +
+
+ + {% if errors %} + {{ govukErrorSummary({ + titleText: errorSummaryTitle, + errorList: errors + }) }} + {% endif %} + +

{{ pageTitle }}

+ + {{ govukSummaryList({ + rows: [ + { + key: { text: nameLabel }, + value: { text: name }, + actions: { + items: [ + { + href: "/third-party-users/create" + lngParam, + text: changeLink, + visuallyHiddenText: name + } + ] + } + } + ] + }) }} + +
+ {{ govukButton({ + text: confirmButton + }) }} +
+ +
+
+{% endblock %} diff --git a/libs/system-admin-pages/src/pages/third-party-users/create/summary/index.test.ts b/libs/system-admin-pages/src/pages/third-party-users/create/summary/index.test.ts new file mode 100644 index 000000000..ae7916fe5 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/create/summary/index.test.ts @@ -0,0 +1,99 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getHandler, postHandler } from "./index.js"; + +vi.mock("@hmcts/third-party-user", () => ({ + createThirdPartyUser: vi.fn() +})); + +import { createThirdPartyUser } from "@hmcts/third-party-user"; + +describe("third-party-users create summary page", () => { + let req: Partial; + let res: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + req = { query: {}, body: {}, session: {} as never }; + res = { render: vi.fn(), redirect: vi.fn() }; + }); + + describe("getHandler", () => { + it("should redirect to create page when session has no name", async () => { + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.redirect).toHaveBeenCalledWith("/third-party-users/create"); + }); + + it("should render summary page with name from session", async () => { + // Arrange + req.session = { thirdPartyCreate: { name: "Test User" } } as never; + + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith( + "third-party-users/create/summary/index", + expect.objectContaining({ + pageTitle: "Create third party user summary", + name: "Test User" + }) + ); + }); + }); + + describe("postHandler", () => { + it("should redirect to create page when session has no name", async () => { + // Act + await postHandler(req as Request, res as Response); + + // Assert + expect(res.redirect).toHaveBeenCalledWith("/third-party-users/create"); + }); + + it("should create user and redirect to confirmation", async () => { + // Arrange + req.session = { thirdPartyCreate: { name: "New User" } } as never; + vi.mocked(createThirdPartyUser).mockResolvedValue({ id: "abc", name: "New User", createdAt: new Date() } as never); + + // Act + await postHandler(req as Request, res as Response); + + // Assert + expect(createThirdPartyUser).toHaveBeenCalledWith("New User"); + expect(res.redirect).toHaveBeenCalledWith("/third-party-users/create/confirmation"); + expect((req.session as never as Record).thirdPartyCreate).toMatchObject({ createdId: "abc" }); + }); + + it("should skip creation and redirect if createdId already in session (idempotency)", async () => { + // Arrange + req.session = { thirdPartyCreate: { name: "Existing User", createdId: "existing-id" } } as never; + + // Act + await postHandler(req as Request, res as Response); + + // Assert + expect(createThirdPartyUser).not.toHaveBeenCalled(); + expect(res.redirect).toHaveBeenCalledWith("/third-party-users/create/confirmation"); + }); + + it("should set audit metadata on creation", async () => { + // Arrange + req.session = { thirdPartyCreate: { name: "Audit User" } } as never; + vi.mocked(createThirdPartyUser).mockResolvedValue({ id: "xyz", name: "Audit User", createdAt: new Date() } as never); + + // Act + await postHandler(req as Request, res as Response); + + // Assert + expect(req.auditMetadata).toMatchObject({ + shouldLog: true, + action: "CREATE_THIRD_PARTY_USER", + entityInfo: "Name: Audit User" + }); + }); + }); +}); diff --git a/libs/system-admin-pages/src/pages/third-party-users/create/summary/index.ts b/libs/system-admin-pages/src/pages/third-party-users/create/summary/index.ts new file mode 100644 index 000000000..c8398d9ac --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/create/summary/index.ts @@ -0,0 +1,65 @@ +import { requireRole, USER_ROLES } from "@hmcts/auth"; +import { createThirdPartyUser } from "@hmcts/third-party-user"; +import type { Request, RequestHandler, Response } from "express"; +import type { Session } from "express-session"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +interface ThirdPartyCreateSession extends Session { + thirdPartyCreate?: { + name: string; + createdId?: string; + createdName?: string; + }; +} + +type Language = "en" | "cy"; + +export const getHandler = async (req: Request, res: Response) => { + const language: Language = req.query.lng === "cy" ? "cy" : "en"; + const t = language === "cy" ? cy : en; + const session = req.session as ThirdPartyCreateSession; + const lngParam = language === "cy" ? "?lng=cy" : ""; + + if (!session.thirdPartyCreate?.name) { + return res.redirect(`/third-party-users/create${lngParam}`); + } + + res.render("third-party-users/create/summary/index", { + ...t, + lngParam, + name: session.thirdPartyCreate.name, + changeLinkAriaLabel: t.changeLinkAriaLabel(session.thirdPartyCreate.name) + }); +}; + +export const postHandler = async (req: Request, res: Response) => { + const language: Language = req.query.lng === "cy" ? "cy" : "en"; + const t = language === "cy" ? cy : en; + const session = req.session as ThirdPartyCreateSession; + const lngParam = language === "cy" ? "?lng=cy" : ""; + + if (!session.thirdPartyCreate?.name) { + return res.redirect(`/third-party-users/create${lngParam}`); + } + + if (session.thirdPartyCreate.createdId) { + return res.redirect(`/third-party-users/create/confirmation${lngParam}`); + } + + const user = await createThirdPartyUser(session.thirdPartyCreate.name); + + session.thirdPartyCreate.createdId = user.id; + session.thirdPartyCreate.createdName = user.name; + + req.auditMetadata = { + shouldLog: true, + action: "CREATE_THIRD_PARTY_USER", + entityInfo: `Name: ${user.name}` + }; + + res.redirect(`/third-party-users/create/confirmation${lngParam}`); +}; + +export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler]; +export const POST: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), postHandler]; diff --git a/libs/system-admin-pages/src/pages/third-party-users/cy.ts b/libs/system-admin-pages/src/pages/third-party-users/cy.ts new file mode 100644 index 000000000..2571e1257 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/cy.ts @@ -0,0 +1,12 @@ +export const cy = { + pageTitle: "Rheoli defnyddwyr trydydd parti", + tableHeadings: { + name: "Enw'r", + createdDate: "Crëwyd Dyddiad", + actions: "Camau gweithredu" + }, + createNewUserButton: "Creu defnyddiwr newydd", + manageLink: "Rheoli", + noUsersMessage: "Nid oes defnyddwyr trydydd parti.", + back: "Yn ôl" +}; diff --git a/libs/system-admin-pages/src/pages/third-party-users/en.ts b/libs/system-admin-pages/src/pages/third-party-users/en.ts new file mode 100644 index 000000000..9c0e583df --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/en.ts @@ -0,0 +1,12 @@ +export const en = { + pageTitle: "Manage third party users", + tableHeadings: { + name: "Name", + createdDate: "Created date", + actions: "Actions" + }, + createNewUserButton: "Create new user", + manageLink: "Manage", + noUsersMessage: "There are no third party users.", + back: "Back" +}; diff --git a/libs/system-admin-pages/src/pages/third-party-users/index.njk b/libs/system-admin-pages/src/pages/third-party-users/index.njk new file mode 100644 index 000000000..51e48134c --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/index.njk @@ -0,0 +1,44 @@ +{% extends "layouts/base-template.njk" %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/table/macro.njk" import govukTable %} +{% from "govuk/components/back-link/macro.njk" import govukBackLink %} + +{% block backLink %} + {{ back }} +{% endblock %} + +{% block content %} +
+
+

{{ pageTitle }}

+ + {{ govukButton({ + text: createNewUserButton, + href: "/third-party-users/create" + lngParam + }) }} + + {% if users.length > 0 %} + {% set tableRows = [] %} + {% for user in users %} + {% set tableRows = (tableRows.push([ + { text: user.name }, + { text: user.createdAt }, + { html: '' + manageLink + '' } + ]), tableRows) %} + {% endfor %} + + {{ govukTable({ + head: [ + { text: tableHeadings.name }, + { text: tableHeadings.createdDate }, + { text: tableHeadings.actions } + ], + rows: tableRows + }) }} + {% else %} +

{{ noUsersMessage }}

+ {% endif %} + +
+
+{% endblock %} diff --git a/libs/system-admin-pages/src/pages/third-party-users/index.test.ts b/libs/system-admin-pages/src/pages/third-party-users/index.test.ts new file mode 100644 index 000000000..f5f109e79 --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/index.test.ts @@ -0,0 +1,70 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getHandler } from "./index.js"; + +vi.mock("@hmcts/third-party-user", () => ({ + findAllThirdPartyUsers: vi.fn() +})); + +import { findAllThirdPartyUsers } from "@hmcts/third-party-user"; + +describe("third-party-users list page", () => { + let req: Partial; + let res: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + req = { query: {}, body: {}, session: {} as never }; + res = { render: vi.fn(), redirect: vi.fn() }; + }); + + describe("getHandler", () => { + it("should render the page with a list of users", async () => { + // Arrange + const mockUsers = [{ id: "1", name: "Test User", createdAt: new Date("2026-01-01"), _count: { subscriptions: 2 } }]; + vi.mocked(findAllThirdPartyUsers).mockResolvedValue(mockUsers as never); + + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith( + "third-party-users/index", + expect.objectContaining({ + pageTitle: "Manage third party users", + users: [{ id: "1", name: "Test User", createdAt: "01/01/2026" }], + lngParam: "" + }) + ); + }); + + it("should render in Welsh when lng=cy query param is set", async () => { + // Arrange + req.query = { lng: "cy" }; + vi.mocked(findAllThirdPartyUsers).mockResolvedValue([]); + + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith( + "third-party-users/index", + expect.objectContaining({ + pageTitle: "Rheoli defnyddwyr trydydd parti", + lngParam: "?lng=cy" + }) + ); + }); + + it("should render with empty users list when no users exist", async () => { + // Arrange + vi.mocked(findAllThirdPartyUsers).mockResolvedValue([]); + + // Act + await getHandler(req as Request, res as Response); + + // Assert + expect(res.render).toHaveBeenCalledWith("third-party-users/index", expect.objectContaining({ users: [] })); + }); + }); +}); diff --git a/libs/system-admin-pages/src/pages/third-party-users/index.ts b/libs/system-admin-pages/src/pages/third-party-users/index.ts new file mode 100644 index 000000000..f60f9e99a --- /dev/null +++ b/libs/system-admin-pages/src/pages/third-party-users/index.ts @@ -0,0 +1,27 @@ +import { requireRole, USER_ROLES } from "@hmcts/auth"; +import { findAllThirdPartyUsers } from "@hmcts/third-party-user"; +import type { Request, RequestHandler, Response } from "express"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +type Language = "en" | "cy"; + +export const getHandler = async (req: Request, res: Response) => { + const language: Language = req.query.lng === "cy" ? "cy" : "en"; + const t = language === "cy" ? cy : en; + const lngParam = language === "cy" ? "?lng=cy" : ""; + + const users = await findAllThirdPartyUsers(); + + res.render("third-party-users/index", { + ...t, + users: users.map((u) => ({ + id: u.id, + name: u.name, + createdAt: u.createdAt.toLocaleDateString("en-GB") + })), + lngParam + }); +}; + +export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler]; diff --git a/libs/third-party-user/package.json b/libs/third-party-user/package.json new file mode 100644 index 000000000..5377a8d47 --- /dev/null +++ b/libs/third-party-user/package.json @@ -0,0 +1,28 @@ +{ + "name": "@hmcts/third-party-user", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.ts" + } + }, + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest watch", + "lint": "biome check .", + "lint:fix": "biome check --write ." + }, + "dependencies": { + "@hmcts/postgres": "workspace:*" + }, + "peerDependencies": { + "express": "^5.2.0" + } +} diff --git a/libs/third-party-user/prisma/schema.prisma b/libs/third-party-user/prisma/schema.prisma new file mode 100644 index 000000000..9a0e9cfca --- /dev/null +++ b/libs/third-party-user/prisma/schema.prisma @@ -0,0 +1,29 @@ +generator client { + provider = "prisma-client-js" + output = "../../../apps/postgres/generated/prisma" +} + +datasource db { + provider = "postgresql" +} + +model ThirdPartyUser { + id String @id @default(cuid()) + name String @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") + subscriptions ThirdPartySubscription[] + + @@map("third_party_user") +} + +model ThirdPartySubscription { + id String @id @default(cuid()) + thirdPartyUserId String @map("third_party_user_id") + listType String @map("list_type") @db.VarChar(100) + sensitivity String @db.VarChar(20) + + thirdPartyUser ThirdPartyUser @relation(fields: [thirdPartyUserId], references: [id], onDelete: Cascade) + + @@unique([thirdPartyUserId, listType]) + @@map("third_party_subscription") +} diff --git a/libs/third-party-user/src/config.ts b/libs/third-party-user/src/config.ts new file mode 100644 index 000000000..eb6da9660 --- /dev/null +++ b/libs/third-party-user/src/config.ts @@ -0,0 +1,7 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const prismaSchemas = path.join(__dirname, "../prisma"); diff --git a/libs/third-party-user/src/index.ts b/libs/third-party-user/src/index.ts new file mode 100644 index 000000000..7754a6de0 --- /dev/null +++ b/libs/third-party-user/src/index.ts @@ -0,0 +1,8 @@ +export { validateName } from "./name-validation.js"; +export { + createThirdPartyUser, + deleteThirdPartyUser, + findAllThirdPartyUsers, + findThirdPartyUserById, + updateThirdPartySubscriptions +} from "./third-party-user-service.js"; diff --git a/libs/third-party-user/src/name-validation.test.ts b/libs/third-party-user/src/name-validation.test.ts new file mode 100644 index 000000000..f191c0d06 --- /dev/null +++ b/libs/third-party-user/src/name-validation.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { validateName } from "./name-validation.js"; + +describe("validateName", () => { + it("should return null for a valid name", () => { + expect(validateName("Test User")).toBeNull(); + }); + + it("should return null for a name with allowed special characters", () => { + expect(validateName("O'Brien-Smith 2")).toBeNull(); + }); + + it("should return an error for an empty string", () => { + const result = validateName(""); + expect(result).not.toBeNull(); + expect(result?.href).toBe("#name"); + expect(result?.text).toBe("Enter a name"); + }); + + it("should return an error for a whitespace-only string", () => { + const result = validateName(" "); + expect(result).not.toBeNull(); + expect(result?.text).toBe("Enter a name"); + }); + + it("should return an error when name exceeds 255 characters", () => { + const longName = "a".repeat(256); + const result = validateName(longName); + expect(result).not.toBeNull(); + expect(result?.text).toBe("Name must be 255 characters or fewer"); + }); + + it("should return null for a name of exactly 255 characters", () => { + const maxName = "a".repeat(255); + expect(validateName(maxName)).toBeNull(); + }); + + it("should return an error for a name with disallowed characters", () => { + const result = validateName("Test@User"); + expect(result).not.toBeNull(); + expect(result?.text).toBe("Name must only contain letters, numbers, spaces, hyphens and apostrophes"); + }); + + it("should return an error for a name with angle brackets", () => { + const result = validateName("" }]); + mockValidate.mockReturnValue({ isValid: false, errors: ["Invalid data"] }); + + await GET(req as Request, res as Response); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.render).toHaveBeenCalledWith("errors/common", expect.objectContaining({ errorTitle: "Invalid Data" })); + }); + + it("should return 500 on server error", async () => { + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockRejectedValue(new Error("Database connection failed")); + + await GET(req as Request, res as Response); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.render).toHaveBeenCalledWith("errors/common", expect.objectContaining({ errorTitle: "Error" })); + }); + + it("should use Welsh locale and friendly name when specified", async () => { + req.query = { artefactId: "test-artefact-123" }; + res.locals = { locale: "cy" }; + + const mockRenderedDataCy = { + header: { + listTitle: "Rhestr Gwrandawiadau Dyddiol Tribiwnlys Nawdd Cymdeithasol a Chynhaliaeth Plant Llundain", + listDate: "1 Ionawr 2026", + lastUpdatedDate: "1 Ionawr 2026", + lastUpdatedTime: "12:00pm" + }, + hearings: mockJsonData + }; + setupSuccessMocks(mockRenderedDataCy); + + await GET(req as Request, res as Response); + + expect(renderSscsDailyHearingListData).toHaveBeenCalledWith( + mockJsonData, + expect.objectContaining({ + locale: "cy", + listTitle: "Rhestr Gwrandawiadau Dyddiol Tribiwnlys Nawdd Cymdeithasol a Chynhaliaeth Plant Llundain" + }) + ); + }); + + it("should include the important information text for the list type in the render", async () => { + req.query = { artefactId: "test-artefact-123" }; + setupSuccessMocks(); + + await GET(req as Request, res as Response); + + const renderCall = vi.mocked(res.render!).mock.calls[0]!; + expect(renderCall[1]).toHaveProperty("importantInformationText"); + expect((renderCall[1] as any).importantInformationText).toContain("sscsa-sutton@justice.gov.uk"); + }); + }); +}); diff --git a/apps/web/src/pages/(list-types)/sscs-daily-hearing-list/index.ts b/apps/web/src/pages/(list-types)/sscs-daily-hearing-list/index.ts new file mode 100644 index 000000000..e797c8ed8 --- /dev/null +++ b/apps/web/src/pages/(list-types)/sscs-daily-hearing-list/index.ts @@ -0,0 +1,60 @@ +import { createJsonValidator } from "@hmcts/list-types-common"; +import { prisma } from "@hmcts/postgres-prisma"; +import { + sscsDailyHearingListCy as cy, + sscsDailyHearingListEn as en, + importantInformationByListType, + renderSscsDailyHearingListData, + type SscsDailyHearingList +} from "@hmcts/sscs-daily-hearing-list"; +import { schemaPath } from "@hmcts/sscs-daily-hearing-list/config"; +import { createSimpleListTypeHandler, resolveDataSource } from "../list-type-handler.js"; + +const validate = createJsonValidator(schemaPath); + +function getImportantInformationText(listTypeName: string | undefined): string { + if (listTypeName && importantInformationByListType[listTypeName]) { + return importantInformationByListType[listTypeName]; + } + return ""; +} + +export const GET = createSimpleListTypeHandler({ + en, + cy, + validate, + logPrefix: "sscs-daily-hearing-list", + render: async ({ artefact, jsonData, locale, res }) => { + const t = locale === "cy" ? cy : en; + + const dbListType = await prisma.listType.findUnique({ + where: { id: artefact.listTypeId }, + select: { name: true, friendlyName: true, welshFriendlyName: true } + }); + + const listTitle = + locale === "cy" ? (dbListType?.welshFriendlyName ?? dbListType?.friendlyName ?? t.listForDate) : (dbListType?.friendlyName ?? t.listForDate); + + const { header, hearings } = renderSscsDailyHearingListData(jsonData, { + locale, + courtName: String(listTitle), + contentDate: artefact.contentDate, + lastReceivedDate: artefact.lastReceivedDate.toISOString(), + listTitle: String(listTitle) + }); + + const importantInformationText = getImportantInformationText(dbListType?.name ?? undefined); + const dataSource = resolveDataSource(artefact.provenance, t); + + res.render("sscs-daily-hearing-list", { + en, + cy, + t, + title: header.listTitle, + header, + hearings, + importantInformationText, + dataSource + }); + } +}); diff --git a/apps/web/src/pages/(list-types)/sscs-daily-hearing-list/sscs-daily-hearing-list.njk b/apps/web/src/pages/(list-types)/sscs-daily-hearing-list/sscs-daily-hearing-list.njk new file mode 100644 index 000000000..bd8f2151c --- /dev/null +++ b/apps/web/src/pages/(list-types)/sscs-daily-hearing-list/sscs-daily-hearing-list.njk @@ -0,0 +1,92 @@ +{% extends "layouts/base-template.njk" %} + +{% block head %} + {{ super() }} + +{% endblock %} + +{% block page_content %} +
+
+ +

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForDate }} {{ header.listDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+ +
+ + + {{ t.importantInformationTitle }} + + +
+ {% for line in importantInformationText.split('\n') %} + {% if line %} +

{{ line | replace(t.importantInformationLinkUrl, '' + t.importantInformationLinkUrl + '') | safe }}

+ {% endif %} + {% endfor %} +
+
+ +
+

{{ t.searchCasesTitle }}

+ + +
+ +
+ + + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.venue }}{{ t.tableHeaders.appealReferenceNumber }}{{ t.tableHeaders.hearingType }}{{ t.tableHeaders.appellant }}{{ t.tableHeaders.courtroom }}{{ t.tableHeaders.hearingTime }}{{ t.tableHeaders.tribunal }}{{ t.tableHeaders.respondent }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.venue }}{{ hearing.appealReferenceNumber }}{{ hearing.hearingType }}{{ hearing.appellant }}{{ hearing.courtroom }}{{ hearing.hearingTime }}{{ hearing.tribunal }}{{ hearing.respondent }}{{ hearing.additionalInformation }}
+
+ +

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

+ + + +
+
+{% endblock %} diff --git a/apps/web/src/pages/(list-types)/utiac-jr-daily-hearing-list/index.test.ts b/apps/web/src/pages/(list-types)/utiac-jr-daily-hearing-list/index.test.ts new file mode 100644 index 000000000..be6ae576e --- /dev/null +++ b/apps/web/src/pages/(list-types)/utiac-jr-daily-hearing-list/index.test.ts @@ -0,0 +1,314 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockValidate = vi.hoisted(() => vi.fn()); + +vi.mock("@hmcts/list-types-common", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createJsonValidator: () => mockValidate, + provenanceLabelsEn: { MANUAL_UPLOAD: "Manual Upload", SNL: "ListAssist", COMMON_PLATFORM: "Common Platform", CP_CATH: "Libra", PDDA: "PDDA" }, + provenanceLabelsCy: { MANUAL_UPLOAD: "Lanlwytho â Llaw", SNL: "ListAssist", COMMON_PLATFORM: "Common Platform", CP_CATH: "Libra", PDDA: "PDDA" } + }; +}); + +vi.mock("@hmcts/publication", () => ({ + getArtefactById: vi.fn(), + getPublicationJson: vi.fn(), + PROVENANCE_LABELS: { + MANUAL_UPLOAD: "Manual Upload", + LIST_ASSIST: "List Assist" + } +})); + +vi.mock("@hmcts/utiac-jr-daily-hearing-list", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + renderUtiacJrDailyHearingListData: vi.fn(), + renderUtiacJrLondonDailyHearingListData: vi.fn(), + validateUtiacJrAnyDailyHearingList: mockValidate + }; +}); + +import { getArtefactById, getPublicationJson } from "@hmcts/publication"; +import { renderUtiacJrDailyHearingListData, renderUtiacJrLondonDailyHearingListData } from "@hmcts/utiac-jr-daily-hearing-list"; +import { GET } from "./index.js"; + +const makeArtefact = (overrides: Record = {}) => ({ + artefactId: "test-artefact-123", + locationId: "9001", + listTypeId: 194, + listTypeName: "UTIAC_JR_LEEDS_DAILY_HEARING_LIST", + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-10"), + displayTo: new Date("2026-01-20"), + lastReceivedDate: new Date("2026-01-14T12:00:00Z"), + provenance: "MANUAL_UPLOAD", + ...overrides +}); + +describe("UTIAC JR Daily Hearing List unified page controller", () => { + let req: Partial; + let res: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + + req = { + query: {} + }; + + res = { + status: vi.fn().mockReturnThis(), + render: vi.fn(), + locals: { locale: "en" } + }; + }); + + describe("GET handler", () => { + it("should render Leeds list with regional template and correct title", async () => { + // Arrange + const mockArtefact = makeArtefact(); + + const mockJsonData = [ + { + venue: "Leeds Combined Court Centre", + judges: "Judge Smith", + hearingTime: "10:00am", + caseReferenceNumber: "JR/2026/001", + caseTitle: "Smith v Secretary of State", + hearingType: "Permission", + additionalInformation: "" + } + ]; + + const mockRenderedData = { + header: { + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Leeds Daily Hearing List", + listForDate: "15 January 2026", + lastUpdatedDate: "14 January 2026", + lastUpdatedTime: "12pm" + }, + hearings: [mockJsonData[0]] + }; + + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue(mockJsonData); + mockValidate.mockReturnValue({ isValid: true, errors: [] }); + vi.mocked(renderUtiacJrDailyHearingListData).mockReturnValue(mockRenderedData); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(getArtefactById).toHaveBeenCalledWith("test-artefact-123"); + expect(renderUtiacJrDailyHearingListData).toHaveBeenCalledWith(mockJsonData, { + locale: "en", + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: mockArtefact.contentDate, + lastReceivedDate: mockArtefact.lastReceivedDate.toISOString(), + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Leeds Daily Hearing List" + }); + expect(vi.mocked(res.render)).toHaveBeenCalledWith( + "utiac-jr-daily-hearing-list", + expect.objectContaining({ + header: mockRenderedData.header, + hearings: mockRenderedData.hearings, + dataSource: "Manual Upload" + }) + ); + }); + + it("should render London list with London template and London table headers", async () => { + // Arrange + const mockArtefact = makeArtefact({ + artefactId: "test-artefact-456", + listTypeId: 193, + listTypeName: "UTIAC_JR_LONDON_DAILY_HEARING_LIST" + }); + + const mockJsonData = [ + { + hearingTime: "10:00am", + caseTitle: "Smith v Secretary of State", + representative: "", + caseReferenceNumber: "JR/2026/001", + judges: "Judge Smith", + hearingType: "Permission", + location: "Field House", + additionalInformation: "" + } + ]; + + const mockRenderedData = { + header: { + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: London Daily Hearing List", + listForDate: "15 January 2026", + lastUpdatedDate: "14 January 2026", + lastUpdatedTime: "12pm" + }, + hearings: [mockJsonData[0]] + }; + + req.query = { artefactId: "test-artefact-456" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue(mockJsonData); + mockValidate.mockReturnValue({ isValid: true, errors: [] }); + vi.mocked(renderUtiacJrLondonDailyHearingListData).mockReturnValue(mockRenderedData); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(renderUtiacJrLondonDailyHearingListData).toHaveBeenCalledWith(mockJsonData, { + locale: "en", + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: mockArtefact.contentDate, + lastReceivedDate: mockArtefact.lastReceivedDate.toISOString(), + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: London Daily Hearing List" + }); + expect(vi.mocked(res.render)).toHaveBeenCalledWith( + "utiac-jr-london-daily-hearing-list", + expect.objectContaining({ + header: mockRenderedData.header, + hearings: mockRenderedData.hearings, + dataSource: "Manual Upload", + t: expect.objectContaining({ + tableHeaders: expect.objectContaining({ + location: "Location", + representative: "Representative" + }) + }) + }) + ); + }); + + it("should return 400 when artefactId is missing", async () => { + // Arrange + req.query = {}; + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(400); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Bad Request", + errorMessage: "Missing artefactId parameter" + }) + ); + }); + + it("should return 404 when artefact is not found", async () => { + // Arrange + req.query = { artefactId: "non-existent-artefact" }; + vi.mocked(getArtefactById).mockResolvedValue(null); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(404); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Not Found", + errorMessage: "The requested list could not be found" + }) + ); + }); + + it("should return 404 when JSON is not found in blob storage", async () => { + // Arrange + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(makeArtefact() as any); + vi.mocked(getPublicationJson).mockResolvedValue(null); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(404); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Not Found", + errorMessage: "The requested list could not be found" + }) + ); + }); + + it("should return 400 when JSON validation fails", async () => { + // Arrange + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(makeArtefact() as any); + vi.mocked(getPublicationJson).mockResolvedValue([{ hearingTime: "invalid" }]); + mockValidate.mockReturnValue({ isValid: false, errors: ["Invalid data"] }); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(400); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Invalid Data", + errorMessage: "The list data is invalid" + }) + ); + }); + + it("should return 500 on server error", async () => { + // Arrange + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockRejectedValue(new Error("Database connection failed")); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(500); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Server Error", + errorMessage: "An error occurred while loading the list" + }) + ); + }); + + it("should use Welsh locale and pageTitleByListTypeCy when locale is cy", async () => { + // Arrange + const mockRenderedData = { + header: { listTitle: "Welsh placeholder", listForDate: "15 Ionawr 2026", lastUpdatedDate: "14 Ionawr 2026", lastUpdatedTime: "12pm" }, + hearings: [] + }; + + req.query = { artefactId: "test-artefact-123" }; + res.locals = { locale: "cy" }; + + vi.mocked(getArtefactById).mockResolvedValue(makeArtefact() as any); + vi.mocked(getPublicationJson).mockResolvedValue([]); + mockValidate.mockReturnValue({ isValid: true, errors: [] }); + vi.mocked(renderUtiacJrDailyHearingListData).mockReturnValue(mockRenderedData); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(renderUtiacJrDailyHearingListData).toHaveBeenCalledWith( + [], + expect.objectContaining({ + locale: "cy", + listTitle: "[WELSH TRANSLATION REQUIRED: 'Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Leeds Daily Hearing List']" + }) + ); + }); + }); +}); diff --git a/apps/web/src/pages/(list-types)/utiac-jr-daily-hearing-list/index.ts b/apps/web/src/pages/(list-types)/utiac-jr-daily-hearing-list/index.ts new file mode 100644 index 000000000..59fe96b54 --- /dev/null +++ b/apps/web/src/pages/(list-types)/utiac-jr-daily-hearing-list/index.ts @@ -0,0 +1,60 @@ +import type { Artefact } from "@hmcts/publication"; +import { + utiacJrDailyHearingListCy as cy, + utiacJrDailyHearingListEn as en, + londonTableHeaders, + londonTableHeadersCy, + pageTitleByListType, + pageTitleByListTypeCy, + renderUtiacJrDailyHearingListData, + renderUtiacJrLondonDailyHearingListData, + type UtiacJrHearingList, + type UtiacJrLondonHearingList, + validateUtiacJrAnyDailyHearingList +} from "@hmcts/utiac-jr-daily-hearing-list"; +import type { Response } from "express"; +import { createSimpleListTypeHandler, LIST_LOAD_SERVER_ERROR, resolveDataSource } from "../list-type-handler.js"; + +const LONDON_LIST_TYPE_NAME = "UTIAC_JR_LONDON_DAILY_HEARING_LIST"; + +function renderUtiacJr({ artefact, jsonData, locale, res }: { artefact: Artefact; jsonData: unknown; locale: string; res: Response }): void { + const listTypeName = artefact.listTypeName ?? ""; + const isLondon = listTypeName === LONDON_LIST_TYPE_NAME; + + const pageTitleMap = locale === "cy" ? pageTitleByListTypeCy : pageTitleByListType; + const pageTitle = pageTitleMap[listTypeName] ?? listTypeName; + + const t = locale === "cy" ? cy : en; + const tableHeaders = isLondon ? (locale === "cy" ? londonTableHeadersCy : londonTableHeaders) : t.tableHeaders; + + if (isLondon) { + const { header, hearings } = renderUtiacJrLondonDailyHearingListData(jsonData as UtiacJrLondonHearingList, { + locale, + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: artefact.contentDate, + lastReceivedDate: artefact.lastReceivedDate.toISOString(), + listTitle: pageTitle + }); + const dataSource = resolveDataSource(artefact.provenance, t as { provenanceLabels?: Record }); + res.render("utiac-jr-london-daily-hearing-list", { en, cy, t: { ...t, tableHeaders }, title: header.listTitle, header, hearings, dataSource }); + } else { + const { header, hearings } = renderUtiacJrDailyHearingListData(jsonData as UtiacJrHearingList, { + locale, + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: artefact.contentDate, + lastReceivedDate: artefact.lastReceivedDate.toISOString(), + listTitle: pageTitle + }); + const dataSource = resolveDataSource(artefact.provenance, t as { provenanceLabels?: Record }); + res.render("utiac-jr-daily-hearing-list", { en, cy, t, title: header.listTitle, header, hearings, dataSource }); + } +} + +export const GET = createSimpleListTypeHandler({ + en, + cy, + validate: validateUtiacJrAnyDailyHearingList, + logPrefix: "utiac-jr-daily-hearing-list", + serverError: LIST_LOAD_SERVER_ERROR, + render: renderUtiacJr +}); diff --git a/apps/web/src/pages/(list-types)/utiac-jr-daily-hearing-list/utiac-jr-daily-hearing-list.njk b/apps/web/src/pages/(list-types)/utiac-jr-daily-hearing-list/utiac-jr-daily-hearing-list.njk new file mode 100644 index 000000000..adacb6359 --- /dev/null +++ b/apps/web/src/pages/(list-types)/utiac-jr-daily-hearing-list/utiac-jr-daily-hearing-list.njk @@ -0,0 +1,77 @@ +{% extends "layouts/base-template.njk" %} + +{% block page_content %} +
+
+ +

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForDate }} {{ header.listForDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+ +
+ + + {{ t.importantInformationTitle }} + + +
+

{{ t.importantInformationText }}

+

+ + {{ t.importantInformationLinkText }} + +

+
+
+ +
+

{{ t.searchCasesTitle }}

+ + +
+ +
+ + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.venue }}{{ t.tableHeaders.judges }}{{ t.tableHeaders.hearingTime }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.caseTitle }}{{ t.tableHeaders.hearingType }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.venue }}{{ hearing.judges }}{{ hearing.hearingTime }}{{ hearing.caseReferenceNumber }}{{ hearing.caseTitle }}{{ hearing.hearingType }}{{ hearing.additionalInformation }}
+
+ +

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

+ + + +
+
+{% endblock %} diff --git a/apps/web/src/pages/(list-types)/utiac-jr-daily-hearing-list/utiac-jr-london-daily-hearing-list.njk b/apps/web/src/pages/(list-types)/utiac-jr-daily-hearing-list/utiac-jr-london-daily-hearing-list.njk new file mode 100644 index 000000000..2cc487025 --- /dev/null +++ b/apps/web/src/pages/(list-types)/utiac-jr-daily-hearing-list/utiac-jr-london-daily-hearing-list.njk @@ -0,0 +1,79 @@ +{% extends "layouts/base-template.njk" %} + +{% block page_content %} +
+
+ +

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForDate }} {{ header.listForDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+ +
+ + + {{ t.importantInformationTitle }} + + +
+

{{ t.importantInformationText }}

+

+ + {{ t.importantInformationLinkText }} + +

+
+
+ +
+

{{ t.searchCasesTitle }}

+ + +
+ +
+ + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.hearingTime }}{{ t.tableHeaders.caseTitle }}{{ t.tableHeaders.representative }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.judges }}{{ t.tableHeaders.hearingType }}{{ t.tableHeaders.location }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.hearingTime }}{{ hearing.caseTitle }}{{ hearing.representative }}{{ hearing.caseReferenceNumber }}{{ hearing.judges }}{{ hearing.hearingType }}{{ hearing.location }}{{ hearing.additionalInformation }}
+
+ +

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

+ + + +
+
+{% endblock %} diff --git a/apps/web/src/pages/(list-types)/utiac-statutory-appeal-daily-hearing-list/index.test.ts b/apps/web/src/pages/(list-types)/utiac-statutory-appeal-daily-hearing-list/index.test.ts new file mode 100644 index 000000000..656f5715c --- /dev/null +++ b/apps/web/src/pages/(list-types)/utiac-statutory-appeal-daily-hearing-list/index.test.ts @@ -0,0 +1,304 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockValidate = vi.hoisted(() => vi.fn()); + +vi.mock("@hmcts/list-types-common", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createJsonValidator: () => mockValidate, + provenanceLabelsEn: { MANUAL_UPLOAD: "Manual Upload", SNL: "ListAssist", COMMON_PLATFORM: "Common Platform", CP_CATH: "Libra", PDDA: "PDDA" }, + provenanceLabelsCy: { MANUAL_UPLOAD: "Lanlwytho â Llaw", SNL: "ListAssist", COMMON_PLATFORM: "Common Platform", CP_CATH: "Libra", PDDA: "PDDA" } + }; +}); + +vi.mock("@hmcts/publication", () => ({ + getArtefactById: vi.fn(), + getPublicationJson: vi.fn(), + PROVENANCE_LABELS: { + MANUAL_UPLOAD: "Manual Upload", + LIST_ASSIST: "List Assist" + } +})); + +vi.mock("@hmcts/utiac-statutory-appeal-daily-hearing-list", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + renderUtiacStatutoryAppealDailyHearingListData: vi.fn() + }; +}); + +import { getArtefactById, getPublicationJson } from "@hmcts/publication"; +import { renderUtiacStatutoryAppealDailyHearingListData } from "@hmcts/utiac-statutory-appeal-daily-hearing-list"; +import { GET } from "./index.js"; + +describe("UTIAC Statutory Appeal Daily Hearing List page controller", () => { + let req: Partial; + let res: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + + req = { + query: {} + }; + + res = { + status: vi.fn().mockReturnThis(), + render: vi.fn(), + locals: { locale: "en" } + }; + }); + + describe("GET handler", () => { + it("should render the list successfully with valid data", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + locationId: "9001", + listTypeId: 30, + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-10"), + displayTo: new Date("2026-01-20"), + lastReceivedDate: new Date("2026-01-14T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + const mockJsonData = [ + { + hearingTime: "10:00am", + appellant: "John Smith", + representative: "", + appealReferenceNumber: "IA/2026/001", + judges: "Judge Smith", + hearingType: "Substantive", + location: "Field House", + additionalInformation: "" + } + ]; + + const mockRenderedData = { + header: { + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber Statutory Appeal Daily Hearing List", + listForDate: "15 January 2026", + lastUpdatedDate: "14 January 2026", + lastUpdatedTime: "12pm" + }, + hearings: [ + { + hearingTime: "10:00am", + appellant: "John Smith", + representative: "", + appealReferenceNumber: "IA/2026/001", + judges: "Judge Smith", + hearingType: "Substantive", + location: "Field House", + additionalInformation: "" + } + ] + }; + + req.query = { artefactId: "test-artefact-123" }; + + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue(mockJsonData); + mockValidate.mockReturnValue({ isValid: true, errors: [] }); + vi.mocked(renderUtiacStatutoryAppealDailyHearingListData).mockReturnValue(mockRenderedData); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(getArtefactById).toHaveBeenCalledWith("test-artefact-123"); + expect(renderUtiacStatutoryAppealDailyHearingListData).toHaveBeenCalledWith(mockJsonData, { + locale: "en", + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: mockArtefact.contentDate, + lastReceivedDate: mockArtefact.lastReceivedDate.toISOString(), + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber Statutory Appeal Daily Hearing List" + }); + expect(vi.mocked(res.render)).toHaveBeenCalledWith( + "utiac-statutory-appeal-daily-hearing-list", + expect.objectContaining({ + header: mockRenderedData.header, + hearings: mockRenderedData.hearings, + dataSource: "Manual Upload" + }) + ); + }); + + it("should return 400 when artefactId is missing", async () => { + // Arrange + req.query = {}; + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(400); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Bad Request", + errorMessage: "Missing artefactId parameter" + }) + ); + }); + + it("should return 404 when artefact is not found", async () => { + // Arrange + req.query = { artefactId: "non-existent-artefact" }; + vi.mocked(getArtefactById).mockResolvedValue(null); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(404); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Not Found", + errorMessage: "The requested list could not be found" + }) + ); + }); + + it("should return 500 on server error", async () => { + // Arrange + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockRejectedValue(new Error("Database connection failed")); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(500); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Server Error", + errorMessage: "An error occurred while loading the list" + }) + ); + }); + + it("should return 404 when JSON is not found in blob storage", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-15"), + lastReceivedDate: new Date("2026-01-14T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue(null); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(404); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Not Found", + errorMessage: "The requested list could not be found" + }) + ); + }); + + it("should return 400 when JSON validation fails", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-15"), + lastReceivedDate: new Date("2026-01-14T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue([{ hearingTime: "invalid" }]); + mockValidate.mockReturnValue({ isValid: false, errors: ["Invalid data"] }); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(400); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Invalid Data", + errorMessage: "The list data is invalid" + }) + ); + }); + + it("should pass contentDate (not displayFrom) to renderer", async () => { + // Arrange + const contentDate = new Date("2026-01-15"); + const mockArtefact = { + artefactId: "test-artefact-123", + contentDate, + displayFrom: new Date("2026-01-01"), + lastReceivedDate: new Date("2026-01-14T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + const mockRenderedData = { + header: { listTitle: "UTIAC SA Daily", listForDate: "15 January 2026", lastUpdatedDate: "14 January 2026", lastUpdatedTime: "12pm" }, + hearings: [] + }; + + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue([]); + mockValidate.mockReturnValue({ isValid: true, errors: [] }); + vi.mocked(renderUtiacStatutoryAppealDailyHearingListData).mockReturnValue(mockRenderedData); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(renderUtiacStatutoryAppealDailyHearingListData).toHaveBeenCalledWith([], expect.objectContaining({ contentDate })); + }); + + it("should use Welsh locale when specified", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + contentDate: new Date("2026-01-15"), + displayFrom: new Date("2026-01-15"), + lastReceivedDate: new Date("2026-01-14T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + const mockRenderedData = { + header: { listTitle: "Welsh placeholder", listForDate: "15 Ionawr 2026", lastUpdatedDate: "14 Ionawr 2026", lastUpdatedTime: "12pm" }, + hearings: [] + }; + + req.query = { artefactId: "test-artefact-123" }; + res.locals = { locale: "cy" }; + + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue([]); + mockValidate.mockReturnValue({ isValid: true, errors: [] }); + vi.mocked(renderUtiacStatutoryAppealDailyHearingListData).mockReturnValue(mockRenderedData); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(renderUtiacStatutoryAppealDailyHearingListData).toHaveBeenCalledWith([], expect.objectContaining({ locale: "cy" })); + }); + }); +}); diff --git a/apps/web/src/pages/(list-types)/utiac-statutory-appeal-daily-hearing-list/index.ts b/apps/web/src/pages/(list-types)/utiac-statutory-appeal-daily-hearing-list/index.ts new file mode 100644 index 000000000..2e7707490 --- /dev/null +++ b/apps/web/src/pages/(list-types)/utiac-statutory-appeal-daily-hearing-list/index.ts @@ -0,0 +1,20 @@ +import { createJsonValidator } from "@hmcts/list-types-common"; +import { + utiacStatutoryAppealDailyHearingListCy as cy, + utiacStatutoryAppealDailyHearingListEn as en, + renderUtiacStatutoryAppealDailyHearingListData, + type UtiacStatutoryAppealHearingList +} from "@hmcts/utiac-statutory-appeal-daily-hearing-list"; +import { schemaPath } from "@hmcts/utiac-statutory-appeal-daily-hearing-list/config"; +import { createSimpleListTypeHandler, createUtiacDailyRender, LIST_LOAD_SERVER_ERROR } from "../list-type-handler.js"; + +const validate = createJsonValidator(schemaPath); + +export const GET = createSimpleListTypeHandler({ + en, + cy, + validate, + logPrefix: "utiac-statutory-appeal-daily-hearing-list", + serverError: LIST_LOAD_SERVER_ERROR, + render: createUtiacDailyRender(renderUtiacStatutoryAppealDailyHearingListData, "utiac-statutory-appeal-daily-hearing-list", en, cy) +}); diff --git a/apps/web/src/pages/(list-types)/utiac-statutory-appeal-daily-hearing-list/utiac-statutory-appeal-daily-hearing-list.njk b/apps/web/src/pages/(list-types)/utiac-statutory-appeal-daily-hearing-list/utiac-statutory-appeal-daily-hearing-list.njk new file mode 100644 index 000000000..cd48b004e --- /dev/null +++ b/apps/web/src/pages/(list-types)/utiac-statutory-appeal-daily-hearing-list/utiac-statutory-appeal-daily-hearing-list.njk @@ -0,0 +1,80 @@ +{% extends "layouts/base-template.njk" %} + +{% block page_content %} +
+
+ +

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForDate }} {{ header.listForDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+ +
+ + + {{ t.importantInformationTitle }} + + +
+

{{ t.importantInformationText }}

+

{{ t.importantInformationEmailText }}

+

+ + {{ t.importantInformationLinkText }} + +

+
+
+ +
+

{{ t.searchCasesTitle }}

+ + +
+ +
+ + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.hearingTime }}{{ t.tableHeaders.appellant }}{{ t.tableHeaders.representative }}{{ t.tableHeaders.appealReferenceNumber }}{{ t.tableHeaders.judges }}{{ t.tableHeaders.hearingType }}{{ t.tableHeaders.location }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.hearingTime }}{{ hearing.appellant }}{{ hearing.representative }}{{ hearing.appealReferenceNumber }}{{ hearing.judges }}{{ hearing.hearingType }}{{ hearing.location }}{{ hearing.additionalInformation }}
+
+ +

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

+ + + +
+
+{% endblock %} diff --git a/apps/web/src/pages/(list-types)/wpafcc-weekly-hearing-list/index.test.ts b/apps/web/src/pages/(list-types)/wpafcc-weekly-hearing-list/index.test.ts new file mode 100644 index 000000000..d914bf29b --- /dev/null +++ b/apps/web/src/pages/(list-types)/wpafcc-weekly-hearing-list/index.test.ts @@ -0,0 +1,278 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockValidate = vi.hoisted(() => vi.fn()); + +vi.mock("@hmcts/list-types-common", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createJsonValidator: () => mockValidate, + provenanceLabelsEn: { MANUAL_UPLOAD: "Manual Upload", SNL: "ListAssist", COMMON_PLATFORM: "Common Platform", CP_CATH: "Libra", PDDA: "PDDA" }, + provenanceLabelsCy: { MANUAL_UPLOAD: "Lanlwytho â Llaw", SNL: "ListAssist", COMMON_PLATFORM: "Common Platform", CP_CATH: "Libra", PDDA: "PDDA" } + }; +}); + +vi.mock("@hmcts/publication", () => ({ + getArtefactById: vi.fn(), + getPublicationJson: vi.fn(), + PROVENANCE_LABELS: { + MANUAL_UPLOAD: "Manual Upload", + LIST_ASSIST: "List Assist" + } +})); + +vi.mock("@hmcts/wpafcc-weekly-hearing-list", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + renderWpafccWeeklyHearingListData: vi.fn() + }; +}); + +import { getArtefactById, getPublicationJson } from "@hmcts/publication"; +import { renderWpafccWeeklyHearingListData } from "@hmcts/wpafcc-weekly-hearing-list"; +import { GET } from "./index.js"; + +describe("WPAFCC Weekly Hearing List page controller", () => { + let req: Partial; + let res: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + + req = { + query: {} + }; + + res = { + status: vi.fn().mockReturnThis(), + render: vi.fn(), + locals: { locale: "en" } + }; + }); + + describe("GET handler", () => { + it("should render the list successfully with valid data", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + locationId: "9001", + listTypeId: 29, + contentDate: new Date("2026-01-01"), + displayFrom: new Date("2026-01-01"), + displayTo: new Date("2026-01-07"), + lastReceivedDate: new Date("2026-01-01T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + const mockJsonData = [ + { + date: "01/01/2026", + hearingTime: "10:00am", + caseReferenceNumber: "WPAFCC/2026/001", + caseName: "Test Case A vs B", + judges: "Judge Smith", + members: "", + modeOfHearing: "Remote", + venue: "WPAFCC Hearing Centre", + additionalInformation: "" + } + ]; + + const mockRenderedData = { + header: { + listTitle: "First-tier Tribunal (War Pensions and Armed Forces Compensation) Weekly Hearing List", + weekCommencingDate: "1 January 2026", + lastUpdatedDate: "1 January 2026", + lastUpdatedTime: "12pm" + }, + hearings: [ + { + date: "1 January 2026", + hearingTime: "10:00am", + caseReferenceNumber: "WPAFCC/2026/001", + caseName: "Test Case A vs B", + judges: "Judge Smith", + members: "", + modeOfHearing: "Remote", + venue: "WPAFCC Hearing Centre", + additionalInformation: "" + } + ] + }; + + req.query = { artefactId: "test-artefact-123" }; + + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue(mockJsonData); + mockValidate.mockReturnValue({ isValid: true, errors: [] }); + vi.mocked(renderWpafccWeeklyHearingListData).mockReturnValue(mockRenderedData); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(getArtefactById).toHaveBeenCalledWith("test-artefact-123"); + expect(renderWpafccWeeklyHearingListData).toHaveBeenCalledWith(mockJsonData, { + locale: "en", + courtName: "First-tier Tribunal (War Pensions and Armed Forces Compensation)", + contentDate: mockArtefact.contentDate, + lastReceivedDate: mockArtefact.lastReceivedDate.toISOString(), + listTitle: "First-tier Tribunal (War Pensions and Armed Forces Compensation) Weekly Hearing List" + }); + const renderCall = vi.mocked(res.render).mock.calls[0]; + expect(renderCall[0]).toBe("wpafcc-weekly-hearing-list"); + expect(renderCall[1]).toMatchObject({ + header: mockRenderedData.header, + hearings: mockRenderedData.hearings, + dataSource: "Manual Upload" + }); + }); + + it("should return 400 when artefactId is missing", async () => { + // Arrange + req.query = {}; + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(400); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Bad Request", + errorMessage: "Missing artefactId parameter" + }) + ); + }); + + it("should return 404 when artefact is not found", async () => { + // Arrange + req.query = { artefactId: "non-existent-artefact" }; + vi.mocked(getArtefactById).mockResolvedValue(null); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(404); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Not Found", + errorMessage: "The requested list could not be found" + }) + ); + }); + + it("should return 404 when JSON is not found in blob storage", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + contentDate: new Date("2026-01-01"), + lastReceivedDate: new Date("2026-01-01T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue(null); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(404); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Not Found", + errorMessage: "The requested list could not be found" + }) + ); + }); + + it("should return 400 when JSON validation fails", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + contentDate: new Date("2026-01-01"), + lastReceivedDate: new Date("2026-01-01T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue([{ date: "invalid" }]); + mockValidate.mockReturnValue({ isValid: false, errors: ["Invalid date format"] }); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(400); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Invalid Data", + errorMessage: "The list data is invalid" + }) + ); + }); + + it("should return 500 on server error", async () => { + // Arrange + req.query = { artefactId: "test-artefact-123" }; + vi.mocked(getArtefactById).mockRejectedValue(new Error("Database connection failed")); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(res.status).toHaveBeenCalledWith(500); + expect(res.render).toHaveBeenCalledWith( + "errors/common", + expect.objectContaining({ + errorTitle: "Server Error", + errorMessage: "An error occurred while loading the list" + }) + ); + }); + + it("should use Welsh locale when specified", async () => { + // Arrange + const mockArtefact = { + artefactId: "test-artefact-123", + contentDate: new Date("2026-01-01"), + lastReceivedDate: new Date("2026-01-01T12:00:00Z"), + provenance: "MANUAL_UPLOAD" + }; + + const mockRenderedData = { + header: { + listTitle: "Welsh placeholder", + weekCommencingDate: "1 Ionawr 2026", + lastUpdatedDate: "1 Ionawr 2026", + lastUpdatedTime: "12pm" + }, + hearings: [] + }; + + req.query = { artefactId: "test-artefact-123" }; + res.locals = { locale: "cy" }; + + vi.mocked(getArtefactById).mockResolvedValue(mockArtefact as any); + vi.mocked(getPublicationJson).mockResolvedValue([]); + mockValidate.mockReturnValue({ isValid: true, errors: [] }); + vi.mocked(renderWpafccWeeklyHearingListData).mockReturnValue(mockRenderedData); + + // Act + await GET(req as Request, res as Response); + + // Assert + expect(renderWpafccWeeklyHearingListData).toHaveBeenCalledWith([], expect.objectContaining({ locale: "cy" })); + }); + }); +}); diff --git a/apps/web/src/pages/(list-types)/wpafcc-weekly-hearing-list/index.ts b/apps/web/src/pages/(list-types)/wpafcc-weekly-hearing-list/index.ts new file mode 100644 index 000000000..7a86e0f1d --- /dev/null +++ b/apps/web/src/pages/(list-types)/wpafcc-weekly-hearing-list/index.ts @@ -0,0 +1,26 @@ +import { createJsonValidator } from "@hmcts/list-types-common"; +import { + wpafccWeeklyHearingListCy as cy, + wpafccWeeklyHearingListEn as en, + renderWpafccWeeklyHearingListData, + type WpafccWeeklyHearingList +} from "@hmcts/wpafcc-weekly-hearing-list"; +import { schemaPath } from "@hmcts/wpafcc-weekly-hearing-list/config"; +import { createSimpleListTypeHandler, createWeeklyHearingListRender, LIST_LOAD_SERVER_ERROR } from "../list-type-handler.js"; + +const validate = createJsonValidator(schemaPath); + +export const GET = createSimpleListTypeHandler({ + en, + cy, + validate, + logPrefix: "wpafcc-weekly-hearing-list", + serverError: LIST_LOAD_SERVER_ERROR, + render: createWeeklyHearingListRender( + renderWpafccWeeklyHearingListData, + "First-tier Tribunal (War Pensions and Armed Forces Compensation)", + "wpafcc-weekly-hearing-list", + en, + cy + ) +}); diff --git a/apps/web/src/pages/(list-types)/wpafcc-weekly-hearing-list/wpafcc-weekly-hearing-list.njk b/apps/web/src/pages/(list-types)/wpafcc-weekly-hearing-list/wpafcc-weekly-hearing-list.njk new file mode 100644 index 000000000..57329ecca --- /dev/null +++ b/apps/web/src/pages/(list-types)/wpafcc-weekly-hearing-list/wpafcc-weekly-hearing-list.njk @@ -0,0 +1,79 @@ +{% extends "layouts/base-template.njk" %} + +{% block page_content %} +
+
+ +

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForWeekCommencing }} {{ header.weekCommencingDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+ +
+ + + {{ t.importantInformationTitle }} + + +
+

{{ t.importantInformationText }}

+

+ + {{ t.importantInformationLinkText }} + +

+
+
+ +
+

{{ t.searchCasesTitle }}

+ + +
+ +
+ + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.date }}{{ t.tableHeaders.hearingTime }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.caseName }}{{ t.tableHeaders.panel }}{{ t.tableHeaders.modeOfHearing }}{{ t.tableHeaders.venue }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.date }}{{ hearing.hearingTime }}{{ hearing.caseReferenceNumber }}{{ hearing.caseName }}{{ hearing.panel }}{{ hearing.modeOfHearing }}{{ hearing.venue }}{{ hearing.additionalInformation }}
+
+ +

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

+ + + +
+
+{% endblock %} diff --git a/docs/GITHUB_MCP.md b/docs/GITHUB_MCP.md new file mode 100644 index 000000000..8cdb8233f --- /dev/null +++ b/docs/GITHUB_MCP.md @@ -0,0 +1,48 @@ +# GitHub MCP Server (local) + +This repo ships a [Model Context Protocol](https://modelcontextprotocol.io) server +definition that gives Claude Code **typed GitHub tools** (read issues, pull +requests, repository contents, and Project boards) instead of relying on +hand-written `gh api graphql` calls and `jq` parsing. + +It is **local-only**: it runs in developer Claude Code sessions. The CI workflows +(`requirements-sync.yml`, `claude.yml`) are unchanged and continue to use the `gh` +CLI directly. + +## How it works + +- **Definition:** [`.mcp.json`](../.mcp.json) at the repo root. It points at + GitHub's **remote hosted** MCP server (`https://api.githubcopilot.com/mcp/`) over + HTTP — there is no local process or Docker container to run. +- **Auto-enabled:** `.claude/settings.json` sets `enableAllProjectMcpServers: true`, + so the server is picked up automatically — no per-developer enable step. +- **Read-only:** the `X-MCP-Readonly` header restricts it to the `issues`, + `pull_requests`, `repos`, and `projects` toolsets in read mode. + +## Setup + +The server authenticates with a token read from your already-authenticated `gh` +CLI. There is **no new secret to create**. + +1. Make sure you are logged in: `gh auth status` (run `gh auth login` if not). +2. Start Claude Code via [`.claude/run.sh`](../.claude/run.sh) — it exports + `GITHUB_MCP_TOKEN="$(gh auth token)"` before launching, which `.mcp.json` + interpolates into the `Authorization` header. + +That's it. In a session, run `/mcp` to confirm the `github` server is connected. + +The token is read fresh each session and never written to disk. + +## Projects (v2) scope + +Project-board tools need the `read:project` scope. The standard `gh` OAuth token +includes it. If a project-board read returns `401`/`403`, your token lacks the +scope — re-authenticate with it: + +```bash +gh auth refresh -s read:project +``` + +Alternatively, export a [fine-grained PAT](https://github.com/settings/tokens) with +`read:project` plus repository contents/issues/PRs as `GITHUB_MCP_TOKEN` instead of +the `gh` token. diff --git a/docs/tickets/428/plan.md b/docs/tickets/428/plan.md new file mode 100644 index 000000000..fe62d0892 --- /dev/null +++ b/docs/tickets/428/plan.md @@ -0,0 +1,368 @@ +# Technical Plan: Issue #428 — Non-strategic list types for SIAC, POAC, PAAC, FTT Tax, FTT LRT and FTT RPT + +## 1. Technical Approach + +All 10 new list types follow the identical pattern established by `libs/list-types/care-standards-tribunal-weekly-hearing-list`. Each list type becomes an independent workspace package under `libs/list-types/`. The page controller and Nunjucks template live in `apps/web/src/pages/(list-types)/`. + +### Reference module: `@hmcts/care-standards-tribunal-weekly-hearing-list` + +Every new module replicates this exact file structure: + +``` +libs/list-types// + package.json + tsconfig.json + src/ + config.ts # moduleRoot, assets, schemaPath + index.ts # re-exports: locales, model types, renderer, PDF generator, email summary + conversion/-config.ts # Excel field config, registerConverter(id), registerConverterByName(name) + email-summary/summary-builder.ts # extractCaseSummary + email-summary/summary-builder.test.ts + locales/en.ts + locales/cy.ts + models/types.ts # hearing interface + list type alias + pdf/pdf-generator.ts + pdf/pdf-generator.test.ts + pdf/pdf-template.njk + rendering/renderer.ts + rendering/renderer.test.ts + schemas/.json # JSON Schema (draft-07) + +apps/web/src/pages/(list-types)// + index.ts # GET controller (mirrors CST controller exactly) + index.test.ts + .njk # HTML template +``` + +Because all 10 lists share the same pattern, the only differences between modules are: +- Field names and headers (driving the model, schema, converter config, renderer, and template) +- Important-information accordion content (locale strings) +- Converter registration IDs and names +- `courtName` string passed to the renderer +- Email summary fields (always: Date, Time, Case Reference Number) + +### SIAC / POAC / PAAC — shared field set + +All three share the same 7 fields: + +| Excel header | Field name | Required | Validation | +|---|---|---|---| +| Date | `date` | yes | `DD_MM_YYYY_PATTERN` | +| Time | `time` | yes | no-HTML | +| Appellant | `appellant` | yes | no-HTML | +| Case Reference Number | `caseReferenceNumber` | yes | no-HTML | +| Hearing Type | `hearingType` | yes | no-HTML | +| Courtroom | `courtroom` | yes | no-HTML | +| Additional information | `additionalInformation` | yes | no-HTML | + +Because the field sets are identical across SIAC, POAC, and PAAC, a single Excel converter config object can be created and reused by all three converters (registered under different IDs and names). + +### FTT Tax Chamber — field set + +| Excel header | Field name | Required | Validation | +|---|---|---|---| +| Date | `date` | yes | `DD_MM_YYYY_PATTERN` | +| Hearing Time | `hearingTime` | yes | no-HTML | +| Case Name | `caseName` | yes | no-HTML | +| Case Reference Number | `caseReferenceNumber` | yes | no-HTML | +| Judge(s) | `judges` | yes | no-HTML | +| Member(s) | `members` | yes | no-HTML | +| Venue/Platform | `venuePlatform` | yes | no-HTML | + +### FTT LRT (Lands Registration Tribunal) — field set + +| Excel header | Field name | Required | Validation | +|---|---|---|---| +| Date | `date` | yes | `DD_MM_YYYY_PATTERN` | +| Hearing Time | `hearingTime` | yes | no-HTML | +| Case Name | `caseName` | yes | no-HTML | +| Case Reference Number | `caseReferenceNumber` | yes | no-HTML | +| Judge | `judge` | yes | no-HTML | +| Venue/Platform | `venuePlatform` | yes | no-HTML | + +### FTT RPT (Residential and Property Tribunal) — field set + +Identical across all 5 regional variants: + +| Excel header | Field name | Required | Validation | +|---|---|---|---| +| Date | `date` | yes | `DD_MM_YYYY_PATTERN` | +| Time | `time` | yes | no-HTML | +| Venue | `venue` | yes | no-HTML | +| Case Type | `caseType` | yes | no-HTML | +| Case Reference Number | `caseReferenceNumber` | yes | no-HTML | +| Judge(s) | `judges` | yes | no-HTML | +| Member(s) | `members` | yes | no-HTML | +| Hearing Method | `hearingMethod` | yes | no-HTML | +| Additional Information | `additionalInformation` | yes | no-HTML | + +The same Excel converter config can be reused across all 5 RPT regional variants (registered under different IDs and names). + +--- + +## 2. List Type Registry + +Ten new entries are appended to `libs/location/src/list-type-data.ts`. The current highest ID in that file is 27 (`SJP_DELTA_PUBLIC_LIST`). The canonical IDs for the new types are 28–37 (the IDs 24–33 in the ticket comment refer to the database auto-increment at seed time; the canonical registry-level IDs assigned here are what matters for `registerConverter`). + +**Note:** The ticket's spec comment (which was machine-generated) assigns IDs 24–33, but IDs 24–27 are already taken in `list-type-data.ts` by the SJP list types. The next available IDs in `list-type-data.ts` start at **28**. The `registerConverter(id, ...)` call in each `-config.ts` file must match the `id` field in `list-type-data.ts`. + +| id | name | englishFriendlyName | shortenedFriendlyName | urlPath | provenance | isNonStrategic | defaultSensitivity | subJurisdictionIds | +|----|------|---------------------|----------------------|---------|------------|----------------|-------------------|--------------------| +| 28 | `SIAC_WEEKLY_HEARING_LIST` | Special Immigration Appeals Commission Weekly Hearing List | SIAC Weekly Hearing List | `siac-weekly-hearing-list` | `MANUAL_UPLOAD` | true | TBD (see open questions) | [25] | +| 29 | `POAC_WEEKLY_HEARING_LIST` | Proscribed Organisations Appeal Commission Weekly Hearing List | POAC Weekly Hearing List | `poac-weekly-hearing-list` | `MANUAL_UPLOAD` | true | TBD | [23] | +| 30 | `PAAC_WEEKLY_HEARING_LIST` | Pathogens Access Appeal Commission Weekly Hearing List | PACC Weekly Hearing List | `paac-weekly-hearing-list` | `MANUAL_UPLOAD` | true | TBD | [21] | +| 31 | `FTT_TAX_CHAMBER_WEEKLY_HEARING_LIST` | First-tier Tribunal (Tax Chamber) Weekly Hearing List | FFT Tax Weekly Hearing List | `ftt-tax-chamber-weekly-hearing-list` | `MANUAL_UPLOAD` | true | `Public` | [16] | +| 32 | `FTT_LANDS_REGISTRATION_TRIBUNAL_WEEKLY_HEARING_LIST` | First-tier Tribunal (Lands Registration Tribunal) Weekly Hearing List | FFT (LR) Weekly Hearing List | `ftt-lands-registration-tribunal-weekly-hearing-list` | `MANUAL_UPLOAD` | true | `Public` | [15] | +| 33 | `FTT_RPT_EASTERN_WEEKLY_HEARING_LIST` | First-tier Tribunal (Residential and Property Tribunal) Eastern Region Weekly Hearing List | RPT Eastern Weekly Hearing List | `ftt-rpt-eastern-weekly-hearing-list` | `MANUAL_UPLOAD` | true | `Public` | [24] | +| 34 | `FTT_RPT_LONDON_WEEKLY_HEARING_LIST` | First-tier Tribunal (Residential and Property Tribunal) London Region Weekly Hearing List | RPT London Weekly Hearing List | `ftt-rpt-london-weekly-hearing-list` | `MANUAL_UPLOAD` | true | `Public` | [24] | +| 35 | `FTT_RPT_MIDLANDS_WEEKLY_HEARING_LIST` | First-tier Tribunal (Residential and Property Tribunal) Midlands Region Weekly Hearing List | RPT Midlands Weekly Hearing List | `ftt-rpt-midlands-weekly-hearing-list` | `MANUAL_UPLOAD` | true | `Public` | [24] | +| 36 | `FTT_RPT_NORTHERN_WEEKLY_HEARING_LIST` | First-tier Tribunal (Residential and Property Tribunal) Northern Region Weekly Hearing List | RPT Northern Weekly Hearing List | `ftt-rpt-northern-weekly-hearing-list` | `MANUAL_UPLOAD` | true | `Public` | [24] | +| 37 | `FTT_RPT_SOUTHERN_WEEKLY_HEARING_LIST` | First-tier Tribunal (Residential and Property Tribunal) Southern Region Weekly Hearing List | RPT Southern Weekly Hearing List | `ftt-rpt-southern-weekly-hearing-list` | `MANUAL_UPLOAD` | true | `Public` | [24] | + +SubJurisdiction IDs come from `libs/location/src/location-data.ts`: +- 25 = Special Immigration Appeals Commission +- 23 = Proscribed Organisations Appeal Commission +- 21 = Pathogens Access Appeal Commission +- 16 = First-Tier Tribunal (Tax Chamber) +- 15 = First-Tier Tribunal (Lands Registration Tribunal) +- 24 = Residential Property Tribunal (used for all 5 RPT regional variants) + +Welsh friendly names are placeholders (English text) pending translation — see open questions. + +--- + +## 3. New Module Packages + +### 3a. `@hmcts/siac-poac-paac-weekly-hearing-list` + +Because SIAC, POAC, and PAAC share identical field definitions and page layout, they can share a **single lib package**. The single converter config is registered three times (once per list type ID and name). Three separate page controllers and templates are created in `apps/web/src/pages/(list-types)/` to give each list type its own URL and locale content. + +**Module path:** `libs/list-types/siac-poac-paac-weekly-hearing-list/` + +Key files: +- `src/models/types.ts` — `SiacPoacPaacHearing` interface (7 fields), `SiacPoacPaacHearingList` type alias +- `src/conversion/siac-poac-paac-config.ts` — single `SIAC_POAC_PAAC_EXCEL_CONFIG`, three `registerConverter` / `registerConverterByName` calls +- `src/rendering/renderer.ts` — `renderSiacPoacPaacData(list, options): RenderedData` +- `src/email-summary/summary-builder.ts` — `extractCaseSummary` returns `[Date, Time, Case Reference Number]` +- `src/pdf/pdf-generator.ts` — `generateSiacPoacPaacWeeklyHearingListPdf(options)`; accepts a `courtName` option so the same generator serves all three courts +- `src/pdf/pdf-template.njk` — shared PDF template (7-column table) +- `src/locales/en.ts` — shared label strings (table headers, search label, common copy) +- `src/locales/cy.ts` — Welsh placeholders (English text until translations are provided) +- `src/schemas/siac-poac-paac-weekly-hearing-list.json` — JSON Schema for the shared field set + +Three locale variants for the important-information accordion are stored as named exports within the locales files, keyed by tribunal abbreviation (`siacImportantInfo`, `poacImportantInfo`, `paacImportantInfo`), so each page controller can select the correct text without importing from three separate modules. + +**Page controllers and templates in `apps/web/src/pages/(list-types)/`:** +- `siac-weekly-hearing-list/index.ts` + `siac-weekly-hearing-list.njk` +- `poac-weekly-hearing-list/index.ts` + `poac-weekly-hearing-list.njk` +- `paac-weekly-hearing-list/index.ts` + `paac-weekly-hearing-list.njk` + +### 3b. `@hmcts/ftt-tax-chamber-weekly-hearing-list` + +**Module path:** `libs/list-types/ftt-tax-chamber-weekly-hearing-list/` + +Key differences from CST: +- 7-column table (Date, Hearing Time, Case Name, Case Reference Number, Judge(s), Member(s), Venue/Platform) +- Multi-paragraph important-information accordion with external link +- `extractCaseSummary` returns `[Date, Hearing Time, Case Reference Number]` + +**Page:** `apps/web/src/pages/(list-types)/ftt-tax-chamber-weekly-hearing-list/` + +### 3c. `@hmcts/ftt-lands-registration-tribunal-weekly-hearing-list` + +**Module path:** `libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/` + +Key differences: +- 6-column table (Date, Hearing Time, Case Name, Case Reference Number, Judge, Venue/Platform) +- Important-information accordion contains a placeholder office email (`[insert office email]`) +- `extractCaseSummary` returns `[Date, Hearing Time, Case Reference Number]` + +**Page:** `apps/web/src/pages/(list-types)/ftt-lands-registration-tribunal-weekly-hearing-list/` + +### 3d. `@hmcts/ftt-rpt-weekly-hearing-list` + +Because all 5 RPT regional variants share the same field definitions, page layout, and important-information text, they share a **single lib package** — the same strategy used for SIAC/POAC/PAAC. + +**Module path:** `libs/list-types/ftt-rpt-weekly-hearing-list/` + +Key details: +- `src/conversion/ftt-rpt-config.ts` — single `FTT_RPT_EXCEL_CONFIG`, five `registerConverter` / `registerConverterByName` calls +- Important-information accordion is identical across all 5 regions (same placeholder email) +- `extractCaseSummary` returns `[Date, Time, Case Reference Number]` +- 9-column table (Date, Time, Venue, Case Type, Case Reference Number, Judge(s), Member(s), Hearing Method, Additional Information) + +**Page controllers and templates in `apps/web/src/pages/(list-types)/`:** +- `ftt-rpt-eastern-weekly-hearing-list/` +- `ftt-rpt-london-weekly-hearing-list/` +- `ftt-rpt-midlands-weekly-hearing-list/` +- `ftt-rpt-northern-weekly-hearing-list/` +- `ftt-rpt-southern-weekly-hearing-list/` + +--- + +## 4. Files to Create Per Module + +The table below lists the source files to create. Build output (`dist/`) is generated — not created manually. + +### `@hmcts/siac-poac-paac-weekly-hearing-list` + +| File | Notes | +|------|-------| +| `libs/list-types/siac-poac-paac-weekly-hearing-list/package.json` | Same scripts as CST; deps: `@hmcts/list-types-common`, `@hmcts/pdf-generation`, `@hmcts/postgres-prisma`, `luxon`, `nunjucks` | +| `libs/list-types/siac-poac-paac-weekly-hearing-list/tsconfig.json` | Extends `../../../tsconfig.json`; `resolveJsonModule: true` | +| `libs/list-types/siac-poac-paac-weekly-hearing-list/src/config.ts` | `moduleRoot`, `assets`, `schemaPath` | +| `libs/list-types/siac-poac-paac-weekly-hearing-list/src/index.ts` | Re-exports from all sub-modules | +| `libs/list-types/siac-poac-paac-weekly-hearing-list/src/conversion/siac-poac-paac-config.ts` | Field config + 3x register calls | +| `libs/list-types/siac-poac-paac-weekly-hearing-list/src/models/types.ts` | 7-field interface + list type | +| `libs/list-types/siac-poac-paac-weekly-hearing-list/src/locales/en.ts` | Shared labels + 3 accordion text variants | +| `libs/list-types/siac-poac-paac-weekly-hearing-list/src/locales/cy.ts` | Welsh placeholders | +| `libs/list-types/siac-poac-paac-weekly-hearing-list/src/rendering/renderer.ts` | `renderSiacPoacPaacData` | +| `libs/list-types/siac-poac-paac-weekly-hearing-list/src/rendering/renderer.test.ts` | | +| `libs/list-types/siac-poac-paac-weekly-hearing-list/src/email-summary/summary-builder.ts` | Date + Time + Case Reference Number | +| `libs/list-types/siac-poac-paac-weekly-hearing-list/src/email-summary/summary-builder.test.ts` | | +| `libs/list-types/siac-poac-paac-weekly-hearing-list/src/pdf/pdf-generator.ts` | Accepts `courtName` param | +| `libs/list-types/siac-poac-paac-weekly-hearing-list/src/pdf/pdf-generator.test.ts` | | +| `libs/list-types/siac-poac-paac-weekly-hearing-list/src/pdf/pdf-template.njk` | 7-column PDF table | +| `libs/list-types/siac-poac-paac-weekly-hearing-list/src/schemas/siac-poac-paac-weekly-hearing-list.json` | JSON Schema | +| `libs/list-types/siac-poac-paac-weekly-hearing-list/src/config.test.ts` | moduleRoot + assets checks | +| `apps/web/src/pages/(list-types)/siac-weekly-hearing-list/index.ts` | | +| `apps/web/src/pages/(list-types)/siac-weekly-hearing-list/index.test.ts` | | +| `apps/web/src/pages/(list-types)/siac-weekly-hearing-list/siac-weekly-hearing-list.njk` | | +| `apps/web/src/pages/(list-types)/poac-weekly-hearing-list/index.ts` | | +| `apps/web/src/pages/(list-types)/poac-weekly-hearing-list/index.test.ts` | | +| `apps/web/src/pages/(list-types)/poac-weekly-hearing-list/poac-weekly-hearing-list.njk` | | +| `apps/web/src/pages/(list-types)/paac-weekly-hearing-list/index.ts` | | +| `apps/web/src/pages/(list-types)/paac-weekly-hearing-list/index.test.ts` | | +| `apps/web/src/pages/(list-types)/paac-weekly-hearing-list/paac-weekly-hearing-list.njk` | | + +### `@hmcts/ftt-tax-chamber-weekly-hearing-list` + +| File | Notes | +|------|-------| +| `libs/list-types/ftt-tax-chamber-weekly-hearing-list/package.json` | | +| `libs/list-types/ftt-tax-chamber-weekly-hearing-list/tsconfig.json` | | +| `libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/config.ts` | | +| `libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/index.ts` | | +| `libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/conversion/ftt-tax-config.ts` | | +| `libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/models/types.ts` | | +| `libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/locales/en.ts` | Multi-paragraph accordion, external link | +| `libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/locales/cy.ts` | Placeholder | +| `libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/rendering/renderer.ts` | | +| `libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/rendering/renderer.test.ts` | | +| `libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/email-summary/summary-builder.ts` | | +| `libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/email-summary/summary-builder.test.ts` | | +| `libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/pdf/pdf-generator.ts` | | +| `libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/pdf/pdf-generator.test.ts` | | +| `libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/pdf/pdf-template.njk` | | +| `libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/schemas/ftt-tax-chamber-weekly-hearing-list.json` | | +| `libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/config.test.ts` | | +| `apps/web/src/pages/(list-types)/ftt-tax-chamber-weekly-hearing-list/index.ts` | | +| `apps/web/src/pages/(list-types)/ftt-tax-chamber-weekly-hearing-list/index.test.ts` | | +| `apps/web/src/pages/(list-types)/ftt-tax-chamber-weekly-hearing-list/ftt-tax-chamber-weekly-hearing-list.njk` | | + +### `@hmcts/ftt-lands-registration-tribunal-weekly-hearing-list` + +| File | Notes | +|------|-------| +| `libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/package.json` | | +| `libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/tsconfig.json` | | +| `libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/config.ts` | | +| `libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/index.ts` | | +| `libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/conversion/ftt-lrt-config.ts` | | +| `libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/models/types.ts` | | +| `libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/locales/en.ts` | Placeholder office email in accordion | +| `libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/locales/cy.ts` | Placeholder | +| `libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/rendering/renderer.ts` | | +| `libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/rendering/renderer.test.ts` | | +| `libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/email-summary/summary-builder.ts` | | +| `libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/email-summary/summary-builder.test.ts` | | +| `libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/pdf/pdf-generator.ts` | | +| `libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/pdf/pdf-generator.test.ts` | | +| `libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/pdf/pdf-template.njk` | | +| `libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/schemas/ftt-lands-registration-tribunal-weekly-hearing-list.json` | | +| `libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/config.test.ts` | | +| `apps/web/src/pages/(list-types)/ftt-lands-registration-tribunal-weekly-hearing-list/index.ts` | | +| `apps/web/src/pages/(list-types)/ftt-lands-registration-tribunal-weekly-hearing-list/index.test.ts` | | +| `apps/web/src/pages/(list-types)/ftt-lands-registration-tribunal-weekly-hearing-list/ftt-lands-registration-tribunal-weekly-hearing-list.njk` | | + +### `@hmcts/ftt-rpt-weekly-hearing-list` + +| File | Notes | +|------|-------| +| `libs/list-types/ftt-rpt-weekly-hearing-list/package.json` | | +| `libs/list-types/ftt-rpt-weekly-hearing-list/tsconfig.json` | | +| `libs/list-types/ftt-rpt-weekly-hearing-list/src/config.ts` | | +| `libs/list-types/ftt-rpt-weekly-hearing-list/src/index.ts` | | +| `libs/list-types/ftt-rpt-weekly-hearing-list/src/conversion/ftt-rpt-config.ts` | 5x register calls | +| `libs/list-types/ftt-rpt-weekly-hearing-list/src/models/types.ts` | | +| `libs/list-types/ftt-rpt-weekly-hearing-list/src/locales/en.ts` | | +| `libs/list-types/ftt-rpt-weekly-hearing-list/src/locales/cy.ts` | Placeholder | +| `libs/list-types/ftt-rpt-weekly-hearing-list/src/rendering/renderer.ts` | | +| `libs/list-types/ftt-rpt-weekly-hearing-list/src/rendering/renderer.test.ts` | | +| `libs/list-types/ftt-rpt-weekly-hearing-list/src/email-summary/summary-builder.ts` | | +| `libs/list-types/ftt-rpt-weekly-hearing-list/src/email-summary/summary-builder.test.ts` | | +| `libs/list-types/ftt-rpt-weekly-hearing-list/src/pdf/pdf-generator.ts` | Accepts `regionName` + `courtName` | +| `libs/list-types/ftt-rpt-weekly-hearing-list/src/pdf/pdf-generator.test.ts` | | +| `libs/list-types/ftt-rpt-weekly-hearing-list/src/pdf/pdf-template.njk` | | +| `libs/list-types/ftt-rpt-weekly-hearing-list/src/schemas/ftt-rpt-weekly-hearing-list.json` | | +| `libs/list-types/ftt-rpt-weekly-hearing-list/src/config.test.ts` | | +| `apps/web/src/pages/(list-types)/ftt-rpt-eastern-weekly-hearing-list/index.ts` | | +| `apps/web/src/pages/(list-types)/ftt-rpt-eastern-weekly-hearing-list/index.test.ts` | | +| `apps/web/src/pages/(list-types)/ftt-rpt-eastern-weekly-hearing-list/ftt-rpt-eastern-weekly-hearing-list.njk` | | +| `apps/web/src/pages/(list-types)/ftt-rpt-london-weekly-hearing-list/index.ts` | | +| `apps/web/src/pages/(list-types)/ftt-rpt-london-weekly-hearing-list/index.test.ts` | | +| `apps/web/src/pages/(list-types)/ftt-rpt-london-weekly-hearing-list/ftt-rpt-london-weekly-hearing-list.njk` | | +| `apps/web/src/pages/(list-types)/ftt-rpt-midlands-weekly-hearing-list/index.ts` | | +| `apps/web/src/pages/(list-types)/ftt-rpt-midlands-weekly-hearing-list/index.test.ts` | | +| `apps/web/src/pages/(list-types)/ftt-rpt-midlands-weekly-hearing-list/ftt-rpt-midlands-weekly-hearing-list.njk` | | +| `apps/web/src/pages/(list-types)/ftt-rpt-northern-weekly-hearing-list/index.ts` | | +| `apps/web/src/pages/(list-types)/ftt-rpt-northern-weekly-hearing-list/index.test.ts` | | +| `apps/web/src/pages/(list-types)/ftt-rpt-northern-weekly-hearing-list/ftt-rpt-northern-weekly-hearing-list.njk` | | +| `apps/web/src/pages/(list-types)/ftt-rpt-southern-weekly-hearing-list/index.ts` | | +| `apps/web/src/pages/(list-types)/ftt-rpt-southern-weekly-hearing-list/index.test.ts` | | +| `apps/web/src/pages/(list-types)/ftt-rpt-southern-weekly-hearing-list/ftt-rpt-southern-weekly-hearing-list.njk` | | + +--- + +## 5. Existing Files to Modify + +| File | Change | +|------|--------| +| `libs/location/src/list-type-data.ts` | Add 10 new entries (IDs 28–37) | +| `apps/web/src/app.ts` | Import `moduleRoot` from each of the 4 new packages' `/config` and add to `modulePaths` array | +| `tsconfig.json` (root) | Add `paths` entries for the 4 new packages (and their `/config` sub-paths) | + +The `vite.build.ts` in `apps/web` does not need a change — none of the new modules have frontend assets (same as CST). The seed mechanism automatically picks up new `listTypeData` entries via `seedListTypes()` — no seed file change is needed. + +--- + +## 6. Error Handling + +All page controllers follow the CST pattern: +- Missing `artefactId` query parameter → 400 with `errors/common` template +- Artefact not found in DB → 404 +- JSON file not found on disk → 404 +- JSON Schema validation failure → 400 +- Any uncaught exception → 500 + +This is handled by the try/catch structure in each controller's `GET` handler, identical to `apps/web/src/pages/(list-types)/care-standards-tribunal-weekly-hearing-list/index.ts`. + +--- + +## 7. Open Questions / Assumptions + +1. **ID collision with existing IDs 24–27.** The ticket comment assigns IDs 24–33 to the new list types, but `list-type-data.ts` already uses IDs 24–27 for the SJP lists. The plan uses IDs 28–37 (next available). If the DB has already had IDs 24–33 created in a non-local environment, the `registerConverter(id, ...)` calls will need to match whatever IDs exist there. Name-based registration (`registerConverterByName`) is the safer fallback and must always be included. + +2. **SIAC / POAC / PAAC access classification.** `defaultSensitivity` is marked as TBD. If these lists contain information about persons whose identities need protection (which the accordion text implies), `Classified` or `Private` may be more appropriate than `Public`. Awaiting confirmation. + +3. **Welsh translations.** No Welsh strings were supplied for any of the 10 new list types. All `cy.ts` files will use English text as placeholders, matching the same value as `en.ts`, until translations are provided. + +4. **FTT LRT and FTT RPT office email.** The accordion text contains `[insert office email]`. The placeholder string `[insert office email]` will be used verbatim in `en.ts` until real addresses are confirmed. + +5. **PAAC upload label — double-C.** The ticket specifies "PACC Weekly Hearing List" (double-C) as the upload form label. This is used as-is in `shortenedFriendlyName`. If this is a typo for "PAAC" it should be raised with the product owner before implementation. + +6. **PDF generation scope.** The CST reference implementation includes a fully working PDF generator. This plan includes PDF generators for all 10 new list types following the same pattern. If PDF generation is intended to be deferred, the `pdf-generator.ts` and `pdf-template.njk` files can be stubbed. + +7. **FTT RPT `caseType`.** The ticket does not specify whether `caseType` is free text or an enumerated value. The schema and converter will treat it as free text with no-HTML validation. If an enum is required, the schema and validator will need updating. + +8. **Shared lib packaging.** This plan groups SIAC/POAC/PAAC into one package and all 5 RPT variants into one package, following the principle of sharing code when field definitions are identical. If the product owner requires strict per-list isolation, each list type can instead be given its own package — at the cost of significant code duplication. diff --git a/docs/tickets/428/review.md b/docs/tickets/428/review.md new file mode 100644 index 000000000..af8b5e901 --- /dev/null +++ b/docs/tickets/428/review.md @@ -0,0 +1,195 @@ +# Code Review: Issue #428 + +## Summary + +This implementation adds 10 new non-strategic hearing list types across 4 new lib modules and 10 new page controllers, following the established pattern of `@hmcts/care-standards-tribunal-weekly-hearing-list`. The architecture is sound and the code quality is generally high. However, there are two bugs that will cause silent rendering failures in production — one in the SIAC/POAC/PAAC PDF template and one in the SIAC/POAC/PAAC web template — and a product decision about SIAC/POAC/PAAC sensitivity level that is currently resolved in the wrong direction. The verification tasks in Group 12 are not yet complete. + +--- + +## CRITICAL Issues + +### 1. SIAC/POAC/PAAC PDF template references an undefined key `t.importantInformation` + +**Files:** +- `libs/list-types/siac-poac-paac-weekly-hearing-list/src/pdf/pdf-template.njk` line 21 +- `libs/list-types/siac-poac-paac-weekly-hearing-list/src/locales/en.ts` + +**Problem:** The PDF template renders `{{ t.importantInformation }}` but the `en.ts` and `cy.ts` locale objects have no `importantInformation` key. The locale only exports `siacImportantInformation`, `poacImportantInformation`, and `paacImportantInformation`. Nunjucks silently outputs an empty string for undefined variables, so all three PDF outputs will show a blank important information section with no error. + +**Impact:** Every generated PDF for SIAC, POAC, and PAAC will have an empty "Important information" box, silently dropping legally required content from the downloadable document. + +**Solution:** The PDF generator accepts a `courtName` parameter and a `listTitle` parameter, which identifies which tribunal is being rendered. The generator should resolve the correct accordion text and pass it explicitly to the template. Either add a `importantInformation` parameter to `PdfGenerationOptions` in `pdf-generator.ts` and require callers to supply it, or resolve it inside the generator using a map keyed on `courtName`. The template variable name can remain `importantInformation`. + +--- + +### 2. SIAC/POAC/PAAC web templates render the important information link as literal markdown text + +**Files:** +- `apps/web/src/pages/(list-types)/siac-weekly-hearing-list/siac-weekly-hearing-list.njk` line 32 +- `apps/web/src/pages/(list-types)/poac-weekly-hearing-list/poac-weekly-hearing-list.njk` line 32 +- `apps/web/src/pages/(list-types)/paac-weekly-hearing-list/paac-weekly-hearing-list.njk` line 32 + +**Problem:** The `siacImportantInformation` locale string is stored as a multi-paragraph string containing a Markdown-style hyperlink: `[Find out what to expect coming to a court or tribunal](https://www.gov.uk/guidance/what-to-expect-coming-to-a-court-or-tribunal)`. The template renders this via `{{ importantInformation }}` which outputs the raw text, so users see the literal markdown syntax rather than a working anchor link. + +Contrast this with the FTT Tax, FTT LRT, and FTT RPT modules, which correctly separate the link into distinct locale keys (`importantInformationText`, `importantInformationLinkText`, `importantInformationLinkUrl`) and render the anchor explicitly in the template. + +**Impact:** The external "Find out what to expect" link required by the ticket specification is broken for all three SIAC/POAC/PAAC pages. Users see raw text like `[Find out what to expect coming to a court or tribunal](https://...)` rather than a clickable link. This is an accessibility failure (WCAG 2.1 SC 4.1.3 — status messages and interactive content must be programmatically determinable). + +**Solution:** Refactor the `siacImportantInformation`, `poacImportantInformation`, and `paacImportantInformation` locale keys to follow the same pattern used in `ftt-tax-chamber-weekly-hearing-list`. Replace the single string with three keys per tribunal variant: `siacImportantInformationText`, `siacImportantInformationLinkText`, `siacImportantInformationLinkUrl`. Update the SIAC, POAC, and PAAC templates to render the link explicitly with ``. The cy.ts locale stubs must mirror the same key structure. + +--- + +### 3. SIAC/POAC/PAAC `defaultSensitivity` set to `Public` — unresolved open question + +**File:** `libs/location/src/list-type-data.ts` lines 327, 340, 352 + +**Problem:** The plan explicitly flagged this as an open question (plan.md Open Question 2): *"If these lists contain information about persons whose identities need protection (which the accordion text implies), `Classified` or `Private` may be more appropriate than `Public`."* The implementation has resolved this as `Public` without documented confirmation from the product owner. + +The important information text itself states *"The tribunal sometimes uses reference numbers or initials to protect the anonymity of those involved in the appeal"*, which strongly implies these are not straightforwardly public lists. + +**Impact:** Setting the wrong sensitivity level means access control may be incorrectly applied, potentially exposing hearing information about individuals under anonymity protection to users who should not have access. + +**Solution:** Obtain explicit written confirmation from the product owner about the intended sensitivity level for IDs 28, 29, and 30 before these entries go live. If `Public` is confirmed, document the confirmation in the ticket. + +--- + +## HIGH PRIORITY Issues + +### 4. `@hmcts/postgres-prisma` listed as a dependency but is never imported + +**Files:** +- `libs/list-types/siac-poac-paac-weekly-hearing-list/package.json` line 29 +- `libs/list-types/ftt-tax-chamber-weekly-hearing-list/package.json` line 29 +- `libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/package.json` line 29 +- `libs/list-types/ftt-rpt-weekly-hearing-list/package.json` line 29 + +**Problem:** All four new packages declare `"@hmcts/postgres-prisma": "workspace:*"` as a dependency. No file within any of these modules imports from `@hmcts/postgres-prisma`. This appears to be copied directly from the reference module (`care-standards-tribunal-weekly-hearing-list`), which also carries this unused dependency. None of the new modules query the database directly — all DB access goes through `@hmcts/publication` via `getArtefactById`. + +**Impact:** Unnecessary dependency increases bundle size and can trigger Prisma client initialisation in environments where a database connection is not available, adding latency and potential startup errors to unrelated test runs. + +**Solution:** Remove `@hmcts/postgres-prisma` from the `dependencies` section of all four new `package.json` files. + +### 5. Inline ` +``` +This pattern is directly copied from the CST reference module, which has the same issue. The CLAUDE.md frontend rules state: "Never use inline styles — use GOV.UK classes only." Injecting a ` + + +
+

{{ header.listTitle }}

+ +

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

+ +

{% for line in t.venueAddressLines %}{{ line }}{% if not loop.last %}
{% endif %}{% endfor %}

+ +

{{ t.listForDate }} {{ header.listForDate }}

+ +

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+
+ +
+

{{ t.importantInformationTitle }}

+ {% for paragraph in t.importantInformationParagraphs %} +

{{ paragraph }}

+ {% endfor %} +

{{ t.importantInformationLinkPrefix }} {{ t.importantInformationLinkText }}

+
+ + {% if hearings.length > 0 %} + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.appellant }}{{ t.tableHeaders.appealReferenceNumber }}{{ t.tableHeaders.caseType }}{{ t.tableHeaders.hearingType }}{{ t.tableHeaders.hearingTime }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.appellant }}{{ hearing.appealReferenceNumber }}{{ hearing.caseType }}{{ hearing.hearingType }}{{ hearing.hearingTime }}{{ hearing.additionalInformation }}
+ {% else %} +

No hearings scheduled.

+ {% endif %} + + + + diff --git a/libs/list-types/ast-daily-hearing-list/src/rendering/renderer.test.ts b/libs/list-types/ast-daily-hearing-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..c48eb8cea --- /dev/null +++ b/libs/list-types/ast-daily-hearing-list/src/rendering/renderer.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import type { AstDailyHearingList } from "../models/types.js"; +import { renderAstDailyHearingListData } from "./renderer.js"; + +describe("renderAstDailyHearingListData", () => { + it("should render hearing list with header and hearings", () => { + const hearingList: AstDailyHearingList = [ + { + appellant: "A Smith", + appealReferenceNumber: "AST/2025/001", + caseType: "Section 4", + hearingType: "Substantive", + hearingTime: "10am", + additionalInformation: "Remote hearing" + } + ]; + + const options = { + locale: "en", + contentDate: new Date("2025-06-20"), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "Asylum Support Tribunal Daily Hearing List" + }; + + const result = renderAstDailyHearingListData(hearingList, options); + + expect(result.header.listTitle).toBe("Asylum Support Tribunal Daily Hearing List"); + expect(result.header.listForDate).toBe("20 June 2025"); + expect(result.header.lastUpdatedDate).toBe("01 January 2025"); + expect(result.header.lastUpdatedTime).toContain("am"); + expect(result.hearings).toHaveLength(1); + expect(result.hearings[0].appellant).toBe("A Smith"); + expect(result.hearings[0].appealReferenceNumber).toBe("AST/2025/001"); + expect(result.hearings[0].caseType).toBe("Section 4"); + expect(result.hearings[0].hearingType).toBe("Substantive"); + expect(result.hearings[0].hearingTime).toBe("10am"); + expect(result.hearings[0].additionalInformation).toBe("Remote hearing"); + }); + + it("should handle empty hearing list", () => { + const hearingList: AstDailyHearingList = []; + const options = { + locale: "en", + contentDate: new Date("2025-01-01"), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "AST Daily Hearing List" + }; + + const result = renderAstDailyHearingListData(hearingList, options); + + expect(result.hearings).toHaveLength(0); + expect(result.header.listTitle).toBe("AST Daily Hearing List"); + }); + + it("should use Welsh locale", () => { + const hearingList: AstDailyHearingList = []; + const options = { + locale: "cy", + contentDate: new Date("2025-01-01"), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "Rhestr Gwrandawiadau Dyddiol y Tribiwnlys Cymorth Lloches" + }; + + const result = renderAstDailyHearingListData(hearingList, options); + + expect(result.header.listTitle).toBe("Rhestr Gwrandawiadau Dyddiol y Tribiwnlys Cymorth Lloches"); + }); + + it("should format PM times correctly", () => { + const hearingList: AstDailyHearingList = []; + const options = { + locale: "en", + contentDate: new Date("2025-01-01"), + lastReceivedDate: "2025-01-01T14:30:00Z", + listTitle: "AST Daily Hearing List" + }; + + const result = renderAstDailyHearingListData(hearingList, options); + + expect(result.header.lastUpdatedTime).toBe("2:30pm"); + }); +}); diff --git a/libs/list-types/ast-daily-hearing-list/src/rendering/renderer.ts b/libs/list-types/ast-daily-hearing-list/src/rendering/renderer.ts new file mode 100644 index 000000000..8d154ed3e --- /dev/null +++ b/libs/list-types/ast-daily-hearing-list/src/rendering/renderer.ts @@ -0,0 +1,34 @@ +import { formatDisplayDate, formatLastUpdatedDateTime } from "@hmcts/list-types-common"; +import type { AstDailyHearing, AstDailyHearingList } from "../models/types.js"; + +export interface RenderOptions { + locale: string; + contentDate: Date; + lastReceivedDate: string; + listTitle: string; +} + +export interface RenderedData { + header: { + listTitle: string; + listForDate: string; + lastUpdatedDate: string; + lastUpdatedTime: string; + }; + hearings: AstDailyHearing[]; +} + +export function renderAstDailyHearingListData(hearingList: AstDailyHearingList, options: RenderOptions): RenderedData { + const { date: lastUpdatedDate, time: lastUpdatedTime } = formatLastUpdatedDateTime(options.lastReceivedDate, options.locale); + const listForDate = formatDisplayDate(options.contentDate, options.locale); + + return { + header: { + listTitle: options.listTitle, + listForDate, + lastUpdatedDate, + lastUpdatedTime + }, + hearings: hearingList.map((hearing) => ({ ...hearing })) + }; +} diff --git a/libs/list-types/ast-daily-hearing-list/src/schemas/ast-daily-hearing-list.json b/libs/list-types/ast-daily-hearing-list/src/schemas/ast-daily-hearing-list.json new file mode 100644 index 000000000..63f47b230 --- /dev/null +++ b/libs/list-types/ast-daily-hearing-list/src/schemas/ast-daily-hearing-list.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AST Daily Hearing List", + "description": "Schema for Asylum Support Tribunal Daily Hearing List", + "type": "array", + "items": { + "type": "object", + "required": ["appellant", "appealReferenceNumber", "caseType", "hearingType", "hearingTime", "additionalInformation"], + "properties": { + "appellant": { + "title": "Appellant", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "appealReferenceNumber": { + "title": "Appeal reference number", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "caseType": { + "title": "Case type", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "hearingType": { + "title": "Hearing type", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "hearingTime": { + "title": "Hearing time", + "type": "string", + "pattern": "^\\d{1,2}([:.]\\d{2})?[ap]m\\s*$", + "examples": ["10am", "2:30pm"] + }, + "additionalInformation": { + "title": "Additional information", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + } + } +} diff --git a/libs/list-types/ast-daily-hearing-list/tsconfig.json b/libs/list-types/ast-daily-hearing-list/tsconfig.json new file mode 100644 index 000000000..a029ee937 --- /dev/null +++ b/libs/list-types/ast-daily-hearing-list/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules", "src/assets/"] +} diff --git a/libs/list-types/cic-weekly-hearing-list/package.json b/libs/list-types/cic-weekly-hearing-list/package.json new file mode 100644 index 000000000..16e0c6d8b --- /dev/null +++ b/libs/list-types/cic-weekly-hearing-list/package.json @@ -0,0 +1,42 @@ +{ + "name": "@hmcts/cic-weekly-hearing-list", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.ts" + } + }, + "scripts": { + "build": "tsc && yarn build:nunjucks && yarn build:schemas", + "build:nunjucks": "mkdir -p dist/pdf && cd src/pdf && find . -name '*.njk' -exec sh -c 'mkdir -p ../../dist/pdf/$(dirname {}) && cp {} ../../dist/pdf/{}' \\;", + "build:schemas": "mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ." + }, + "dependencies": { + "@hmcts/list-types-common": "workspace:*", + "@hmcts/pdf-generation": "workspace:*", + "@hmcts/postgres-prisma": "workspace:*", + "luxon": "3.7.2", + "nunjucks": "3.2.4" + }, + "devDependencies": { + "@types/luxon": "3.7.1", + "@types/node": "24.10.4", + "typescript": "6.0.3", + "vitest": "4.1.9" + }, + "peerDependencies": { + "express": "^5.1.0" + } +} diff --git a/libs/list-types/cic-weekly-hearing-list/src/config.test.ts b/libs/list-types/cic-weekly-hearing-list/src/config.test.ts new file mode 100644 index 000000000..c2e9aafa1 --- /dev/null +++ b/libs/list-types/cic-weekly-hearing-list/src/config.test.ts @@ -0,0 +1,26 @@ +import { existsSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { assets, moduleRoot, schemaPath } from "./config.js"; + +describe("cic-weekly-hearing-list config", () => { + it("should export a valid moduleRoot", () => { + expect(moduleRoot).toBeDefined(); + expect(typeof moduleRoot).toBe("string"); + expect(moduleRoot).toMatch(/[/\\]/); + expect(existsSync(moduleRoot)).toBe(true); + }); + + it("should export a valid assets path", () => { + expect(assets).toBeDefined(); + expect(typeof assets).toBe("string"); + expect(assets).toContain("assets"); + expect(assets.endsWith("/") || assets.endsWith("\\")).toBe(true); + expect(assets.startsWith(moduleRoot)).toBe(true); + }); + + it("should export a valid schemaPath", () => { + expect(schemaPath).toBeDefined(); + expect(schemaPath).toContain("cic-weekly-hearing-list.json"); + expect(existsSync(schemaPath)).toBe(true); + }); +}); diff --git a/libs/list-types/cic-weekly-hearing-list/src/config.ts b/libs/list-types/cic-weekly-hearing-list/src/config.ts new file mode 100644 index 000000000..ca246606e --- /dev/null +++ b/libs/list-types/cic-weekly-hearing-list/src/config.ts @@ -0,0 +1,9 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const moduleRoot = __dirname; +export const assets = path.join(__dirname, "assets/"); +export const schemaPath = path.join(__dirname, "schemas/cic-weekly-hearing-list.json"); diff --git a/libs/list-types/cic-weekly-hearing-list/src/conversion/cic-config.ts b/libs/list-types/cic-weekly-hearing-list/src/conversion/cic-config.ts new file mode 100644 index 000000000..acd3830d1 --- /dev/null +++ b/libs/list-types/cic-weekly-hearing-list/src/conversion/cic-config.ts @@ -0,0 +1,68 @@ +import { + createConverter, + DD_MM_YYYY_PATTERN, + type ExcelConverterConfig, + registerConverter, + registerConverterByName, + validateDateFormat, + validateNoHtmlTags, + validateTimeFormat +} from "@hmcts/list-types-common"; + +export const CIC_EXCEL_CONFIG: ExcelConverterConfig = { + fields: [ + { + header: "Date", + fieldName: "date", + required: true, + validators: [validateDateFormat(DD_MM_YYYY_PATTERN, "dd/MM/yyyy (e.g., 02/01/2025)")] + }, + { + header: "Hearing time", + fieldName: "hearingTime", + required: true, + validators: [(value, rowNumber) => validateTimeFormat(value, rowNumber)] + }, + { + header: "Case reference number", + fieldName: "caseReferenceNumber", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case reference number", rowNumber)] + }, + { + header: "Case name", + fieldName: "caseName", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case name", rowNumber)] + }, + { + header: "Venue/platform", + fieldName: "venue/platform", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Venue/platform", rowNumber)] + }, + { + header: "Judge(s)", + fieldName: "judges", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Judge(s)", rowNumber)] + }, + { + header: "Member(s)", + fieldName: "members", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Member(s)", rowNumber)] + }, + { + header: "Additional information", + fieldName: "additionalInformation", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Additional information", rowNumber)] + } + ], + minRows: 1 +}; + +const cicConverter = createConverter(CIC_EXCEL_CONFIG); +registerConverter(29, cicConverter); +registerConverterByName("CIC_WEEKLY_HEARING_LIST", cicConverter); diff --git a/libs/list-types/cic-weekly-hearing-list/src/email-summary/summary-builder.test.ts b/libs/list-types/cic-weekly-hearing-list/src/email-summary/summary-builder.test.ts new file mode 100644 index 000000000..62ff2dcf8 --- /dev/null +++ b/libs/list-types/cic-weekly-hearing-list/src/email-summary/summary-builder.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import type { CicWeeklyHearingList } from "../models/types.js"; +import { extractCaseSummary, formatCaseSummaryForEmail } from "./summary-builder.js"; + +describe("extractCaseSummary", () => { + it("should extract date, hearingTime, caseReferenceNumber and caseName fields", () => { + const hearingList: CicWeeklyHearingList = [ + { + date: "02/01/2025", + hearingTime: "10am", + caseReferenceNumber: "CIC/2025/001", + caseName: "Smith v CICA", + "venue/platform": "Remote", + judges: "Judge Smith", + members: "Member A", + additionalInformation: "Video hearing" + } + ]; + + const result = extractCaseSummary(hearingList); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual([ + { label: "Date", value: "02/01/2025" }, + { label: "Hearing time", value: "10am" }, + { label: "Case reference number", value: "CIC/2025/001" }, + { label: "Case name", value: "Smith v CICA" } + ]); + }); + + it("should handle empty list", () => { + expect(extractCaseSummary([])).toHaveLength(0); + }); + + it("should handle missing values with empty string", () => { + const hearingList: CicWeeklyHearingList = [ + { + date: "", + hearingTime: "", + caseReferenceNumber: "", + caseName: "", + "venue/platform": "", + judges: "", + members: "", + additionalInformation: "" + } + ]; + + const result = extractCaseSummary(hearingList); + expect(result[0]).toEqual([ + { label: "Date", value: "" }, + { label: "Hearing time", value: "" }, + { label: "Case reference number", value: "" }, + { label: "Case name", value: "" } + ]); + }); +}); + +describe("formatCaseSummaryForEmail", () => { + it("should return no cases message for empty list", () => { + expect(formatCaseSummaryForEmail([])).toBe("No cases scheduled."); + }); + + it("should format a single case summary with field labels", () => { + const hearingList: CicWeeklyHearingList = [ + { + date: "02/01/2025", + hearingTime: "10am", + caseReferenceNumber: "CIC/2025/001", + caseName: "Smith v CICA", + "venue/platform": "Remote", + judges: "Judge Smith", + members: "", + additionalInformation: "" + } + ]; + const result = formatCaseSummaryForEmail(extractCaseSummary(hearingList)); + + expect(result).toContain("Date - 02/01/2025"); + expect(result).toContain("Hearing time - 10am"); + expect(result).toContain("Case reference number - CIC/2025/001"); + expect(result).toContain("Case name - Smith v CICA"); + }); + + it("should separate multiple cases with dividers", () => { + const hearingList: CicWeeklyHearingList = [ + { + date: "02/01/2025", + hearingTime: "10am", + caseReferenceNumber: "CIC/2025/001", + caseName: "Smith v CICA", + "venue/platform": "", + judges: "", + members: "", + additionalInformation: "" + }, + { + date: "03/01/2025", + hearingTime: "2pm", + caseReferenceNumber: "CIC/2025/002", + caseName: "Jones v CICA", + "venue/platform": "", + judges: "", + members: "", + additionalInformation: "" + } + ]; + const result = formatCaseSummaryForEmail(extractCaseSummary(hearingList)); + + expect(result).toContain("Case name - Smith v CICA"); + expect(result).toContain("Case name - Jones v CICA"); + expect(result.split("---").length).toBeGreaterThan(2); + }); +}); diff --git a/libs/list-types/cic-weekly-hearing-list/src/email-summary/summary-builder.ts b/libs/list-types/cic-weekly-hearing-list/src/email-summary/summary-builder.ts new file mode 100644 index 000000000..db5a84ee1 --- /dev/null +++ b/libs/list-types/cic-weekly-hearing-list/src/email-summary/summary-builder.ts @@ -0,0 +1,13 @@ +import { type CaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "@hmcts/list-types-common"; +import type { CicWeeklyHearingList } from "../models/types.js"; + +export { formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING }; + +export function extractCaseSummary(jsonData: CicWeeklyHearingList): CaseSummary[] { + return jsonData.map((hearing) => [ + { label: "Date", value: hearing.date || "" }, + { label: "Hearing time", value: hearing.hearingTime || "" }, + { label: "Case reference number", value: hearing.caseReferenceNumber || "" }, + { label: "Case name", value: hearing.caseName || "" } + ]); +} diff --git a/libs/list-types/cic-weekly-hearing-list/src/index.ts b/libs/list-types/cic-weekly-hearing-list/src/index.ts new file mode 100644 index 000000000..2d5d96d3a --- /dev/null +++ b/libs/list-types/cic-weekly-hearing-list/src/index.ts @@ -0,0 +1,9 @@ +import "./conversion/cic-config.js"; // Register converter on module load + +export type { ValidationResult } from "@hmcts/publication"; +export * from "./email-summary/summary-builder.js"; +export { cy as cicWeeklyHearingListCy } from "./locales/cy.js"; +export { en as cicWeeklyHearingListEn } from "./locales/en.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export * from "./rendering/renderer.js"; diff --git a/libs/list-types/cic-weekly-hearing-list/src/locales/cy.ts b/libs/list-types/cic-weekly-hearing-list/src/locales/cy.ts new file mode 100644 index 000000000..809cfe001 --- /dev/null +++ b/libs/list-types/cic-weekly-hearing-list/src/locales/cy.ts @@ -0,0 +1,38 @@ +import { provenanceLabelsCy as provenanceLabels } from "@hmcts/list-types-common"; + +export const cy = { + pageTitle: "Rhestr Gwrandawiadau Wythnosol y Tribiwnlys Digolledu am Anafiadau Troseddol", + listForWeekCommencing: "Rhestr ar gyfer yr wythnos yn dechrau ar", + lastUpdated: "Diweddarwyd ddiwethaf", + at: "am", + factLinkText: "Dod o hyd i fanylion cyswllt a gwybodaeth arall am lysoedd a thribiwnlysoedd yng Nghymru a Lloegr", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "a rhai tribiwnlysoedd heb eu datganoli yn yr Alban.", + importantInformationTitle: "Gwybodaeth bwysig", + importantInformationParagraphs: [ + "Mae cyfiawnder agored yn egwyddor sylaenol ein system gyfiawnder.", + "Wrth ystyried defnyddio technoleg ffôn a fideo, bydd y farnwriaeth yn rhoi sylw i egwyddorion cyfiawnder agored. Gall barnwyr benderfynu cynnal gwrandawiad yn breifat os oes angen hynny er mwyn sicrhau'r broses o weinyddu cyfiawnder yn briodol.", + "Bydd partïon a chynrychiolwyr y Tribiwnlys Digolledu am Anafiadau Troseddol yn cael gwybod yn uniongyrchol am y trefniadau ar gyfer gwrando achosion o bell. Dylai unrhyw un arall sydd â diddordeb mewn ymuno â'r gwrandawiad o bell gysylltu â Swyddfa'r Tribiwnlys Digolledu am Anafiadau Troseddol yn uniongyrchol, cyn dyddiad y gwrandawiad, trwy e-bostio (insert relevant office mailbox email address) fel y gellir gwneud trefniadau. Dylai'r manylion canlynol gael eu cynnwys yn llinell pwnc yr e-bost [OBSERVER/MEDIA] REQUEST – [AN Other v CICA] – [hearing date]. Os yw'r achos yn cael ei wrando yn breifat neu os yw'n destun cyfyngiad adrodd, bydd hyn yn cael ei nodi." + ], + restrictedReportingOrdersTitle: "Gorchymyn Adrodd Cyfyngedig", + restrictedReportingOrdersText: + "Nid yw'r ffaith bod achos wedi ei gynnwys yn Rhestr y Wasg yn gwarantu na fydd yn destun gorchymyn adrodd cyfyngedig. Dylai aelodau'r Wasg sicrhau nad oes gorchymyn yn bodoli ar achos unigol cyn cyflwyno deunydd i'w gyhoeddi.", + importantInformationLinkPrefix: "Am fwy o wybodaeth, ewch i", + importantInformationLinkText: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + importantInformationLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + searchCasesTitle: "Chwilio achosion", + searchCasesLabel: "Chwilio yn ôl cyfeirnod achos, enw achos, lleoliad, neu fanylion eraill", + tableHeaders: { + date: "Dyddiad", + hearingTime: "Amser y gwrandawiad", + caseReferenceNumber: "Cyfeirnod yr achos", + caseName: "Enw'r achos", + venuePlatform: "Lleoliad/Platfform", + judges: "Barnwyr", + members: "Aelod(au)", + additionalInformation: "Gwybodaeth ychwanegol" + }, + dataSource: "Ffynhonnell Data", + backToTop: "Yn ôl i frig y dudalen", + provenanceLabels +}; diff --git a/libs/list-types/cic-weekly-hearing-list/src/locales/en.ts b/libs/list-types/cic-weekly-hearing-list/src/locales/en.ts new file mode 100644 index 000000000..2045c578a --- /dev/null +++ b/libs/list-types/cic-weekly-hearing-list/src/locales/en.ts @@ -0,0 +1,38 @@ +import { provenanceLabelsEn as provenanceLabels } from "@hmcts/list-types-common"; + +export const en = { + pageTitle: "Criminal Injuries Compensation Weekly Hearing List", + listForWeekCommencing: "List for week commencing", + lastUpdated: "Last updated", + at: "at", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + importantInformationTitle: "Important information", + importantInformationParagraphs: [ + "Open justice is a fundamental principle of our justice system.", + "When considering the use of telephone and video technology, the judiciary will have regard to the principles of open justice. Judges may determine that a hearing should be held in private if this is necessary to secure the proper administration of justice.", + "Criminal Injuries Compensation Tribunal parties and representatives will be informed directly as to the arrangements for hearing cases remotely. Any other person interested in joining the hearing remotely should contact the Criminal Injuries Compensation Tribunal Office direct, in advance of the hearing date, by emailing CIC.enquiries@Justice.gov.uk so that arrangements can be made. The following details should be included in the subject line of the email [OBSERVER/MEDIA] REQUEST – [AN Other v CICA] – [hearing date]. If the case is to be heard in private or is subject to a reporting restriction, this will be notified." + ], + restrictedReportingOrdersTitle: "Restricted Reporting Orders", + restrictedReportingOrdersText: + "The inclusion of a case in the Press List is no guarantee that it is not subject to a restricted reporting order. Members of the press should ensure that no order exists on an individual case before submitting material for publication.", + importantInformationLinkPrefix: "For more information, please visit", + importantInformationLinkText: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + importantInformationLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + searchCasesTitle: "Search Cases", + searchCasesLabel: "Search by case reference, case name, venue, or other details", + tableHeaders: { + date: "Date", + hearingTime: "Hearing time", + caseReferenceNumber: "Case reference number", + caseName: "Case name", + venuePlatform: "Venue/Platform", + judges: "Judge(s)", + members: "Member(s)", + additionalInformation: "Additional information" + }, + dataSource: "Data source", + backToTop: "Back to top", + provenanceLabels +}; diff --git a/libs/list-types/cic-weekly-hearing-list/src/models/types.ts b/libs/list-types/cic-weekly-hearing-list/src/models/types.ts new file mode 100644 index 000000000..edcd9de1b --- /dev/null +++ b/libs/list-types/cic-weekly-hearing-list/src/models/types.ts @@ -0,0 +1,12 @@ +export interface CicWeeklyHearing { + date: string; + hearingTime: string; + caseReferenceNumber: string; + caseName: string; + "venue/platform": string; + judges: string; + members: string; + additionalInformation: string; +} + +export type CicWeeklyHearingList = CicWeeklyHearing[]; diff --git a/libs/list-types/cic-weekly-hearing-list/src/pdf/pdf-generator.test.ts b/libs/list-types/cic-weekly-hearing-list/src/pdf/pdf-generator.test.ts new file mode 100644 index 000000000..b52bf75cd --- /dev/null +++ b/libs/list-types/cic-weekly-hearing-list/src/pdf/pdf-generator.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CicWeeklyHearingList } from "../models/types.js"; +import { generateCicWeeklyHearingListPdf } from "./pdf-generator.js"; + +vi.mock("@hmcts/list-types-common", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateListPdf: vi.fn() + }; +}); + +vi.mock("@hmcts/publication", () => ({ + PROVENANCE_LABELS: { + MANUAL_UPLOAD: "Manual Upload", + SNL: "ListAssist" + } +})); + +import { generateListPdf } from "@hmcts/list-types-common"; + +const mockHearingList: CicWeeklyHearingList = [ + { + date: "02/01/2025", + hearingTime: "10am", + caseReferenceNumber: "CIC/2025/001", + caseName: "Smith v CICA", + "venue/platform": "Remote", + judges: "Judge Smith", + members: "Member A", + additionalInformation: "Video hearing" + } +]; + +const baseOptions = { + artefactId: "test-artefact-id", + locale: "en", + locationId: "14", + contentDate: new Date("2025-06-20"), + jsonData: mockHearingList +}; + +describe("generateCicWeeklyHearingListPdf", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(generateListPdf).mockResolvedValue({ success: true, pdfPath: "/tmp/test.pdf", sizeBytes: 1024 }); + }); + + it("should generate PDF successfully", async () => { + const result = await generateCicWeeklyHearingListPdf(baseOptions); + + expect(result.success).toBe(true); + expect(generateListPdf).toHaveBeenCalledWith(expect.objectContaining({ artefactId: "test-artefact-id", provenanceLabel: "" })); + }); + + it("should resolve known provenance to label", async () => { + await generateCicWeeklyHearingListPdf({ ...baseOptions, provenance: "MANUAL_UPLOAD" }); + + expect(generateListPdf).toHaveBeenCalledWith(expect.objectContaining({ provenanceLabel: "Manual Upload" })); + }); + + it("should fall back to raw provenance string for unknown provenance", async () => { + await generateCicWeeklyHearingListPdf({ ...baseOptions, provenance: "UNKNOWN_SOURCE" }); + + expect(generateListPdf).toHaveBeenCalledWith(expect.objectContaining({ provenanceLabel: "UNKNOWN_SOURCE" })); + }); + + it("should pass Welsh locale to generateListPdf", async () => { + await generateCicWeeklyHearingListPdf({ ...baseOptions, locale: "cy" }); + + expect(generateListPdf).toHaveBeenCalledWith(expect.objectContaining({ locale: "cy" })); + }); + + it("should return failure when generateListPdf returns failure", async () => { + vi.mocked(generateListPdf).mockResolvedValue({ success: false, error: "PDF generation failed" }); + + const result = await generateCicWeeklyHearingListPdf(baseOptions); + + expect(result.success).toBe(false); + expect(result.error).toBe("PDF generation failed"); + }); + + it("should use the correct list title", async () => { + await generateCicWeeklyHearingListPdf(baseOptions); + + expect(generateListPdf).toHaveBeenCalledWith( + expect.objectContaining({ + listTitle: "Criminal Injuries Compensation Weekly Hearing List" + }) + ); + }); + + it("should provide working importEn and importCy callbacks", async () => { + await generateCicWeeklyHearingListPdf(baseOptions); + + const callArgs = vi.mocked(generateListPdf).mock.calls[0][0]; + const enModule = await callArgs.importEn(); + const cyModule = await callArgs.importCy(); + + expect(enModule.en).toBeDefined(); + expect(cyModule.cy).toBeDefined(); + }); +}); diff --git a/libs/list-types/cic-weekly-hearing-list/src/pdf/pdf-generator.ts b/libs/list-types/cic-weekly-hearing-list/src/pdf/pdf-generator.ts new file mode 100644 index 000000000..57b4d9c02 --- /dev/null +++ b/libs/list-types/cic-weekly-hearing-list/src/pdf/pdf-generator.ts @@ -0,0 +1,21 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { type BasePdfGenerationOptions, generateListPdf, type PdfGenerationResult } from "@hmcts/list-types-common"; +import { PROVENANCE_LABELS } from "@hmcts/publication"; +import type { CicWeeklyHearingList } from "../models/types.js"; +import { renderCicWeeklyHearingListData } from "../rendering/renderer.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export async function generateCicWeeklyHearingListPdf(options: BasePdfGenerationOptions): Promise { + return generateListPdf({ + ...options, + listTitle: "Criminal Injuries Compensation Weekly Hearing List", + provenanceLabel: options.provenance ? PROVENANCE_LABELS[options.provenance as keyof typeof PROVENANCE_LABELS] || options.provenance : "", + templateDir: __dirname, + renderData: renderCicWeeklyHearingListData, + importEn: () => import("../locales/en.js"), + importCy: () => import("../locales/cy.js") + }); +} diff --git a/libs/list-types/cic-weekly-hearing-list/src/pdf/pdf-template.njk b/libs/list-types/cic-weekly-hearing-list/src/pdf/pdf-template.njk new file mode 100644 index 000000000..3cb33ca16 --- /dev/null +++ b/libs/list-types/cic-weekly-hearing-list/src/pdf/pdf-template.njk @@ -0,0 +1,68 @@ + + + + + + {{ header.listTitle }} + + + +
+

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForWeekCommencing }} {{ header.weekCommencingDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+
+ +
+

{{ t.importantInformationTitle }}

+ {% for paragraph in t.importantInformationParagraphs %} +

{{ paragraph }}

+ {% endfor %} +

{{ t.restrictedReportingOrdersTitle }}

+

{{ t.restrictedReportingOrdersText }}

+

{{ t.importantInformationLinkPrefix }} {{ t.importantInformationLinkText }}.

+
+ + {% if hearings.length > 0 %} + + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.date }}{{ t.tableHeaders.hearingTime }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.caseName }}{{ t.tableHeaders.venuePlatform }}{{ t.tableHeaders.judges }}{{ t.tableHeaders.members }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.date }}{{ hearing.hearingTime }}{{ hearing.caseReferenceNumber }}{{ hearing.caseName }}{{ hearing.venuePlatform }}{{ hearing.judges }}{{ hearing.members }}{{ hearing.additionalInformation }}
+ {% else %} +

No hearings scheduled.

+ {% endif %} + + + + diff --git a/libs/list-types/cic-weekly-hearing-list/src/rendering/renderer.test.ts b/libs/list-types/cic-weekly-hearing-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..348a53e3d --- /dev/null +++ b/libs/list-types/cic-weekly-hearing-list/src/rendering/renderer.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import type { CicWeeklyHearingList } from "../models/types.js"; +import { renderCicWeeklyHearingListData } from "./renderer.js"; + +describe("renderCicWeeklyHearingListData", () => { + it("should render hearing list with header and hearings", () => { + const hearingList: CicWeeklyHearingList = [ + { + date: "02/01/2025", + hearingTime: "10am", + caseReferenceNumber: "CIC/2025/001", + caseName: "Smith v CICA", + "venue/platform": "Remote", + judges: "Judge Smith", + members: "Member A", + additionalInformation: "Video hearing" + } + ]; + + const options = { + locale: "en", + contentDate: new Date(2025, 0, 6), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "Criminal Injuries Compensation Weekly Hearing List" + }; + + const result = renderCicWeeklyHearingListData(hearingList, options); + + expect(result.header.listTitle).toBe("Criminal Injuries Compensation Weekly Hearing List"); + expect(result.header.weekCommencingDate).toBe("06 January 2025"); + expect(result.header.lastUpdatedDate).toBe("01 January 2025"); + expect(result.header.lastUpdatedTime).toContain("am"); + expect(result.hearings).toHaveLength(1); + expect(result.hearings[0].date).toBe("02 January 2025"); + expect(result.hearings[0].hearingTime).toBe("10am"); + expect(result.hearings[0].caseReferenceNumber).toBe("CIC/2025/001"); + expect(result.hearings[0].caseName).toBe("Smith v CICA"); + expect(result.hearings[0].venuePlatform).toBe("Remote"); + expect(result.hearings[0].judges).toBe("Judge Smith"); + expect(result.hearings[0].members).toBe("Member A"); + expect(result.hearings[0].additionalInformation).toBe("Video hearing"); + }); + + it("should handle empty hearing list", () => { + const hearingList: CicWeeklyHearingList = []; + const options = { + locale: "en", + contentDate: new Date(2025, 0, 6), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "CIC Weekly Hearing List" + }; + + const result = renderCicWeeklyHearingListData(hearingList, options); + + expect(result.hearings).toHaveLength(0); + expect(result.header.listTitle).toBe("CIC Weekly Hearing List"); + }); + + it("should use Welsh locale", () => { + const hearingList: CicWeeklyHearingList = []; + const options = { + locale: "cy", + contentDate: new Date(2025, 0, 6), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "Rhestr Gwrandawiadau Wythnosol yr Iawndal am Anafiadau Troseddol" + }; + + const result = renderCicWeeklyHearingListData(hearingList, options); + + expect(result.header.listTitle).toBe("Rhestr Gwrandawiadau Wythnosol yr Iawndal am Anafiadau Troseddol"); + }); + + it("should format PM times correctly", () => { + const hearingList: CicWeeklyHearingList = []; + const options = { + locale: "en", + contentDate: new Date(2025, 0, 6), + lastReceivedDate: "2025-01-01T14:30:00Z", + listTitle: "CIC Weekly Hearing List" + }; + + const result = renderCicWeeklyHearingListData(hearingList, options); + + expect(result.header.lastUpdatedTime).toBe("2:30pm"); + }); + + it("should map venue/platform to venuePlatform", () => { + const hearingList: CicWeeklyHearingList = [ + { + date: "02/01/2025", + hearingTime: "10am", + caseReferenceNumber: "CIC/2025/001", + caseName: "Test v CICA", + "venue/platform": "London Tribunal Centre", + judges: "Judge Test", + members: "", + additionalInformation: "" + } + ]; + + const options = { + locale: "en", + contentDate: new Date(2025, 0, 6), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "CIC Weekly Hearing List" + }; + + const result = renderCicWeeklyHearingListData(hearingList, options); + + expect(result.hearings[0].venuePlatform).toBe("London Tribunal Centre"); + }); +}); diff --git a/libs/list-types/cic-weekly-hearing-list/src/rendering/renderer.ts b/libs/list-types/cic-weekly-hearing-list/src/rendering/renderer.ts new file mode 100644 index 000000000..6c909afb2 --- /dev/null +++ b/libs/list-types/cic-weekly-hearing-list/src/rendering/renderer.ts @@ -0,0 +1,54 @@ +import { formatDdMmYyyyDate, formatDisplayDate, formatLastUpdatedDateTime } from "@hmcts/list-types-common"; +import type { CicWeeklyHearingList } from "../models/types.js"; + +export interface RenderOptions { + locale: string; + contentDate: Date; + lastReceivedDate: string; + listTitle: string; +} + +export interface RenderedCicHearing { + date: string; + hearingTime: string; + caseReferenceNumber: string; + caseName: string; + venuePlatform: string; + judges: string; + members: string; + additionalInformation: string; +} + +export interface RenderedData { + header: { + listTitle: string; + weekCommencingDate: string; + lastUpdatedDate: string; + lastUpdatedTime: string; + }; + hearings: RenderedCicHearing[]; +} + +export function renderCicWeeklyHearingListData(hearingList: CicWeeklyHearingList, options: RenderOptions): RenderedData { + const weekCommencingDate = formatDisplayDate(options.contentDate, options.locale); + const { date: lastUpdatedDate, time: lastUpdatedTime } = formatLastUpdatedDateTime(options.lastReceivedDate, options.locale); + + return { + header: { + listTitle: options.listTitle, + weekCommencingDate, + lastUpdatedDate, + lastUpdatedTime + }, + hearings: hearingList.map((hearing) => ({ + date: formatDdMmYyyyDate(hearing.date, options.locale), + hearingTime: hearing.hearingTime, + caseReferenceNumber: hearing.caseReferenceNumber, + caseName: hearing.caseName, + venuePlatform: hearing["venue/platform"], + judges: hearing.judges, + members: hearing.members, + additionalInformation: hearing.additionalInformation + })) + }; +} diff --git a/libs/list-types/cic-weekly-hearing-list/src/schemas/cic-weekly-hearing-list.json b/libs/list-types/cic-weekly-hearing-list/src/schemas/cic-weekly-hearing-list.json new file mode 100644 index 000000000..c10c13921 --- /dev/null +++ b/libs/list-types/cic-weekly-hearing-list/src/schemas/cic-weekly-hearing-list.json @@ -0,0 +1,54 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CIC Weekly Hearing List", + "description": "Schema for Criminal Injuries Compensation Weekly Hearing List", + "type": "array", + "items": { + "type": "object", + "required": ["date", "hearingTime", "caseReferenceNumber", "caseName", "venue/platform", "judges", "members", "additionalInformation"], + "properties": { + "date": { + "title": "Date", + "type": "string", + "pattern": "^\\d{2}/\\d{2}/\\d{4}$", + "examples": ["02/01/2025"] + }, + "hearingTime": { + "title": "Hearing time", + "type": "string", + "pattern": "^\\d{1,2}([:.]\\d{2})?[ap]m\\s*$", + "examples": ["10am", "2:30pm"] + }, + "caseReferenceNumber": { + "title": "Case reference number", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "caseName": { + "title": "Case name", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "venue/platform": { + "title": "Venue or platform", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "judges": { + "title": "Judge(s)", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "members": { + "title": "Member(s)", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "additionalInformation": { + "title": "Additional information", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + } + } +} diff --git a/libs/list-types/cic-weekly-hearing-list/tsconfig.json b/libs/list-types/cic-weekly-hearing-list/tsconfig.json new file mode 100644 index 000000000..a029ee937 --- /dev/null +++ b/libs/list-types/cic-weekly-hearing-list/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules", "src/assets/"] +} diff --git a/libs/list-types/common/src/rendering/crown-utilities.test.ts b/libs/list-types/common/src/rendering/crown-utilities.test.ts new file mode 100644 index 000000000..148f9cfdb --- /dev/null +++ b/libs/list-types/common/src/rendering/crown-utilities.test.ts @@ -0,0 +1,488 @@ +import { describe, expect, it } from "vitest"; +import { createPartyDetails, extractPddaSittingsSummary, formatContentDate, formatPublicationDateTime, formatTime } from "./crown-utilities.js"; + +describe("createPartyDetails", () => { + it("should return individual full name with title, forenames, middle name, and surname", () => { + const result = createPartyDetails({ + partyRole: "defendant", + individualDetails: { title: "Mr", individualForenames: "John", individualMiddleName: "Paul", individualSurname: "Smith" } + }); + expect(result).toBe("Mr John Paul Smith"); + }); + + it("should return name without optional parts", () => { + const result = createPartyDetails({ + partyRole: "defendant", + individualDetails: { individualSurname: "Smith" } + }); + expect(result).toBe("Smith"); + }); + + it("should return organisation name", () => { + const result = createPartyDetails({ + partyRole: "claimant", + organisationDetails: { organisationName: "Acme Ltd" } + }); + expect(result).toBe("Acme Ltd"); + }); + + it("should return empty string when no individual or organisation", () => { + const result = createPartyDetails({ partyRole: "defendant" }); + expect(result).toBe(""); + }); + + it("should return empty string for organisation with no name", () => { + const result = createPartyDetails({ partyRole: "defendant", organisationDetails: {} }); + expect(result).toBe(""); + }); +}); + +describe("formatTime", () => { + it("should format morning time without minutes", () => { + expect(formatTime("2025-01-15T09:00:00.000Z")).toBe("9am"); + }); + + it("should format afternoon time without minutes", () => { + expect(formatTime("2025-01-15T14:00:00.000Z")).toBe("2pm"); + }); + + it("should format time with minutes", () => { + expect(formatTime("2025-01-15T09:30:00.000Z")).toBe("9:30am"); + }); + + it("should format noon as 12pm", () => { + expect(formatTime("2025-01-15T12:00:00.000Z")).toBe("12pm"); + }); + + it("should format midnight as 12am", () => { + expect(formatTime("2025-01-15T00:00:00.000Z")).toBe("12am"); + }); +}); + +describe("formatContentDate", () => { + it("should format date in English", () => { + const result = formatContentDate(new Date("2025-03-15"), "en"); + expect(result).toContain("March"); + expect(result).toContain("2025"); + }); + + it("should format date in Welsh", () => { + const result = formatContentDate(new Date("2025-03-15"), "cy"); + expect(result).toContain("2025"); + }); +}); + +describe("formatPublicationDateTime", () => { + it("should format datetime in English", () => { + const result = formatPublicationDateTime("2025-03-15T09:00:00.000Z", "en"); + expect(result).toContain("at"); + expect(result).toContain("2025"); + }); + + it("should include minutes when non-zero", () => { + const result = formatPublicationDateTime("2025-03-15T09:30:00.000Z", "en"); + expect(result).toContain(":30"); + }); +}); + +describe("extractPddaSittingsSummary", () => { + it("should return empty array when court lists are empty", () => { + expect(extractPddaSittingsSummary([])).toHaveLength(0); + }); + + it("should return empty array when sittings have no hearings", () => { + const result = extractPddaSittingsSummary([ + { + Sittings: [{ Hearings: undefined }] + } + ]); + expect(result).toHaveLength(0); + }); + + it("should return empty array when hearings array is empty", () => { + const result = extractPddaSittingsSummary([ + { + Sittings: [{ Hearings: [] }] + } + ]); + expect(result).toHaveLength(0); + }); + + it("should extract summary with unmasked defendant name", () => { + const result = extractPddaSittingsSummary([ + { + Sittings: [ + { + Hearings: [ + { + CaseNumber: "T20250001", + HearingDetails: { HearingDescription: "Trial" }, + Prosecution: { ProsecutingAuthority: "CPS" }, + Defendants: [ + { + PersonalDetails: { + IsMasked: "NO", + Name: { CitizenNameForename: ["Alice"], CitizenNameSurname: "Williams" } + } + } + ] + } + ] + } + ] + } + ]); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual([ + { label: "Defendant Name(s)", value: "Alice Williams" }, + { label: "Prosecuting Authority", value: "CPS" }, + { label: "Case Reference", value: "T20250001" }, + { label: "Hearing Type", value: "Trial" } + ]); + }); + + it("should use MaskedName when IsMasked is yes", () => { + const result = extractPddaSittingsSummary([ + { + Sittings: [ + { + Hearings: [ + { + CaseNumber: "T20250002", + HearingDetails: { HearingDescription: "Sentence" }, + Defendants: [ + { + PersonalDetails: { + IsMasked: "YES", + MaskedName: "Reporting Restriction Applied", + Name: { CitizenNameForename: ["Real"], CitizenNameSurname: "Name" } + } + } + ] + } + ] + } + ] + } + ]); + + expect(result[0][0]).toEqual({ label: "Defendant Name(s)", value: "Reporting Restriction Applied" }); + }); + + it("should use MaskedName over requested name when IsMasked is yes", () => { + const result = extractPddaSittingsSummary([ + { + Sittings: [ + { + Hearings: [ + { + CaseNumber: "T20250003", + HearingDetails: { HearingDescription: "Plea" }, + Defendants: [ + { + PersonalDetails: { + IsMasked: "YES", + MaskedName: "Reporting Restriction Applied", + Name: { CitizenNameRequestedName: "Requested Name", CitizenNameForename: ["Bob"], CitizenNameSurname: "Jones" } + } + } + ] + } + ] + } + ] + } + ]); + + expect(result[0][0]).toEqual({ label: "Defendant Name(s)", value: "Reporting Restriction Applied" }); + }); + + it("should use requested name when IsMasked is yes but no MaskedName", () => { + const result = extractPddaSittingsSummary([ + { + Sittings: [ + { + Hearings: [ + { + CaseNumber: "T20250004", + HearingDetails: { HearingDescription: "Plea" }, + Defendants: [ + { + PersonalDetails: { + IsMasked: "YES", + Name: { CitizenNameRequestedName: "Requested Name", CitizenNameForename: ["Bob"], CitizenNameSurname: "Jones" } + } + } + ] + } + ] + } + ] + } + ]); + + expect(result[0][0]).toEqual({ label: "Defendant Name(s)", value: "Requested Name" }); + }); + + it("should use requested name when IsMasked is no and requested name is present", () => { + const result = extractPddaSittingsSummary([ + { + Sittings: [ + { + Hearings: [ + { + CaseNumber: "T20250005", + HearingDetails: { HearingDescription: "Plea" }, + Defendants: [ + { + PersonalDetails: { + IsMasked: "NO", + Name: { CitizenNameRequestedName: "Requested Name", CitizenNameForename: ["Bob"], CitizenNameSurname: "Jones" } + } + } + ] + } + ] + } + ] + } + ]); + + expect(result[0][0]).toEqual({ label: "Defendant Name(s)", value: "Requested Name" }); + }); + + it("should use full name when IsMasked is yes but no MaskedName or requested name", () => { + const result = extractPddaSittingsSummary([ + { + Sittings: [ + { + Hearings: [ + { + CaseNumber: "T20250006", + HearingDetails: { HearingDescription: "Plea" }, + Defendants: [ + { + PersonalDetails: { + IsMasked: "YES", + Name: { CitizenNameForename: ["Bob"], CitizenNameSurname: "Jones" } + } + } + ] + } + ] + } + ] + } + ]); + + expect(result[0][0]).toEqual({ label: "Defendant Name(s)", value: "Bob Jones" }); + }); + + it("should include defendant field with empty value when no defendants", () => { + const result = extractPddaSittingsSummary([ + { + Sittings: [ + { + Hearings: [ + { + CaseNumber: "T20250004", + HearingDetails: { HearingDescription: "Mention" }, + Defendants: [] + } + ] + } + ] + } + ]); + + const summary = result[0]; + expect(summary.find((f) => f.label === "Defendant Name(s)")?.value).toBe(""); + expect(summary.find((f) => f.label === "Case Reference")?.value).toBe("T20250004"); + }); + + it("should include defendant field with empty value when defendants is undefined", () => { + const result = extractPddaSittingsSummary([ + { + Sittings: [ + { + Hearings: [ + { + CaseNumber: "T20250005", + HearingDetails: { HearingDescription: "Mention" }, + Defendants: undefined + } + ] + } + ] + } + ]); + + expect(result[0].find((f) => f.label === "Defendant Name(s)")?.value).toBe(""); + }); + + it("should fall back to HearingType when HearingDescription is absent", () => { + const result = extractPddaSittingsSummary([ + { + Sittings: [ + { + Hearings: [ + { + CaseNumber: "T20250006", + HearingDetails: { HearingType: "PCM" }, + Defendants: [] + } + ] + } + ] + } + ]); + + expect(result[0].find((f) => f.label === "Hearing Type")?.value).toBe("PCM"); + }); + + it("should use empty string for hearing type when both HearingDescription and HearingType absent", () => { + const result = extractPddaSittingsSummary([ + { + Sittings: [ + { + Hearings: [ + { + CaseNumber: "T20250007", + HearingDetails: {}, + Defendants: [] + } + ] + } + ] + } + ]); + + expect(result[0].find((f) => f.label === "Hearing Type")?.value).toBe(""); + }); + + it("should use empty string for prosecuting authority when Prosecution is absent", () => { + const result = extractPddaSittingsSummary([ + { + Sittings: [ + { + Hearings: [ + { + CaseNumber: "T20250008", + HearingDetails: { HearingDescription: "Mention" }, + Defendants: [] + } + ] + } + ] + } + ]); + + expect(result[0].find((f) => f.label === "Prosecuting Authority")?.value).toBe(""); + }); + + it("should handle multiple defendants and join their names", () => { + const result = extractPddaSittingsSummary([ + { + Sittings: [ + { + Hearings: [ + { + CaseNumber: "T20250009", + HearingDetails: { HearingDescription: "Trial" }, + Defendants: [ + { + PersonalDetails: { + IsMasked: "NO", + Name: { CitizenNameForename: ["Alice"], CitizenNameSurname: "Smith" } + } + }, + { + PersonalDetails: { + IsMasked: "NO", + Name: { CitizenNameForename: ["Bob"], CitizenNameSurname: "Jones" } + } + } + ] + } + ] + } + ] + } + ]); + + expect(result[0][0]).toEqual({ label: "Defendant Name(s)", value: "Alice Smith, Bob Jones" }); + }); + + it("should aggregate across multiple court lists and sittings", () => { + const result = extractPddaSittingsSummary([ + { + Sittings: [ + { + Hearings: [ + { + CaseNumber: "A1", + HearingDetails: { HearingDescription: "Trial" }, + Defendants: [] + } + ] + } + ] + }, + { + Sittings: [ + { + Hearings: [ + { + CaseNumber: "B1", + HearingDetails: { HearingDescription: "Sentence" }, + Defendants: [] + } + ] + }, + { + Hearings: [ + { + CaseNumber: "C1", + HearingDetails: { HearingDescription: "Mention" }, + Defendants: [] + } + ] + } + ] + } + ]); + + expect(result).toHaveLength(3); + expect(result[0].find((f) => f.label === "Case Reference")?.value).toBe("A1"); + expect(result[1].find((f) => f.label === "Case Reference")?.value).toBe("B1"); + expect(result[2].find((f) => f.label === "Case Reference")?.value).toBe("C1"); + }); + + it("should include defendant field with empty value when all defendant names are empty", () => { + const result = extractPddaSittingsSummary([ + { + Sittings: [ + { + Hearings: [ + { + CaseNumber: "T20250010", + HearingDetails: { HearingDescription: "Trial" }, + Defendants: [ + { + PersonalDetails: { + IsMasked: "NO", + Name: { CitizenNameForename: [], CitizenNameSurname: undefined } + } + } + ] + } + ] + } + ] + } + ]); + + expect(result[0].find((f) => f.label === "Defendant Name(s)")?.value).toBe(""); + }); +}); diff --git a/libs/list-types/common/src/rendering/crown-utilities.ts b/libs/list-types/common/src/rendering/crown-utilities.ts new file mode 100644 index 000000000..d086d70e4 --- /dev/null +++ b/libs/list-types/common/src/rendering/crown-utilities.ts @@ -0,0 +1,141 @@ +import { DateTime } from "luxon"; +import type { CaseSummary } from "../email-summary/case-summary-formatter.js"; + +export interface Party { + partyRole: string; + individualDetails?: { + title?: string; + individualForenames?: string; + individualMiddleName?: string; + individualSurname?: string; + }; + organisationDetails?: { + organisationName?: string; + }; +} + +export function createPartyDetails(party: Party): string { + if (party.individualDetails) { + const details = party.individualDetails; + const parts: string[] = []; + if (details.title) parts.push(details.title); + if (details.individualForenames) parts.push(details.individualForenames); + if (details.individualMiddleName) parts.push(details.individualMiddleName); + if (details.individualSurname) parts.push(details.individualSurname); + return parts.filter((n) => n.length > 0).join(" "); + } + if (party.organisationDetails?.organisationName) { + return party.organisationDetails.organisationName; + } + return ""; +} + +export function formatTime(isoDateTime: string): string { + const dt = DateTime.fromISO(isoDateTime).setZone("Europe/London"); + const hours = dt.hour; + const minutes = dt.minute; + const period = hours >= 12 ? "pm" : "am"; + const hour12 = hours % 12 || 12; + const minuteStr = minutes > 0 ? `:${minutes.toString().padStart(2, "0")}` : ""; + return `${hour12}${minuteStr}${period}`; +} + +export function formatContentDate(date: Date, locale: string): string { + const localeCode = locale === "cy" ? "cy-GB" : "en-GB"; + return date.toLocaleDateString(localeCode, { + day: "2-digit", + month: "long", + year: "numeric" + }); +} + +export function formatCrownLastUpdated(isoDateTime: string, locale: string): string { + const dt = DateTime.fromISO(isoDateTime).setZone("Europe/London").setLocale(locale); + const dateStr = dt.toFormat("dd MMMM yyyy"); + const hours = dt.hour; + const minutes = dt.minute; + const period = hours >= 12 ? "pm" : "am"; + const hour12 = hours % 12 || 12; + const minuteStr = minutes > 0 ? `:${minutes.toString().padStart(2, "0")}` : ""; + return `${dateStr} at ${hour12}${minuteStr}${period}`; +} + +export function formatPublicationDateTime(isoDateTime: string, locale: string): string { + const dt = DateTime.fromISO(isoDateTime).setZone("Europe/London").setLocale(locale); + const dateStr = dt.toFormat("d MMMM yyyy"); + const hours = dt.hour; + const minutes = dt.minute; + const period = hours >= 12 ? "pm" : "am"; + const hour12 = hours % 12 || 12; + const minuteStr = minutes > 0 ? `:${minutes.toString().padStart(2, "0")}` : ""; + return `${dateStr} at ${hour12}${minuteStr}${period}`; +} + +export interface PddaCitizenName { + CitizenNameTitle?: string; + CitizenNameForename?: string[]; + CitizenNameSurname?: string; + CitizenNameRequestedName?: string; + CitizenNameSuffix?: string; +} + +export function formatPddaCitizenName(name: PddaCitizenName): string { + if (name.CitizenNameRequestedName) { + return name.CitizenNameRequestedName; + } + const forenames = (name.CitizenNameForename ?? []).join(" "); + return [name.CitizenNameTitle, forenames, name.CitizenNameSurname, name.CitizenNameSuffix].filter(Boolean).join(" "); +} + +export function formatPddaDefendantName(personalDetails: { Name: PddaCitizenName; MaskedName?: string; IsMasked: "YES" | "NO" }): string { + if (personalDetails.IsMasked === "YES" && personalDetails.MaskedName) { + return personalDetails.MaskedName; + } + return formatPddaCitizenName(personalDetails.Name); +} + +export function formatPddaSittingTime(timeStr: string | undefined): string { + if (!timeStr) return ""; + const parts = timeStr.split(":"); + if (parts.length < 2) return timeStr; + const hours = parseInt(parts[0], 10); + const minutes = parseInt(parts[1], 10); + if (Number.isNaN(hours) || Number.isNaN(minutes)) return timeStr; + const ampm = hours >= 12 ? "pm" : "am"; + const displayHours = hours % 12 || 12; + if (minutes === 0) return `${displayHours}${ampm}`; + return `${displayHours}:${String(minutes).padStart(2, "0")}${ampm}`; +} + +interface PddaSittingLike { + Hearings?: Array<{ + Defendants?: Array<{ PersonalDetails: { Name: PddaCitizenName; MaskedName?: string; IsMasked: "YES" | "NO" } }>; + HearingDetails: { HearingDescription?: string; HearingType?: string }; + CaseNumber: string; + CaseNumberCaTH?: string; + Prosecution?: { ProsecutingAuthority?: string }; + }>; +} + +export function extractPddaSittingsSummary(courtLists: Array<{ Sittings: PddaSittingLike[] }>): CaseSummary[] { + const summaries: CaseSummary[] = []; + + for (const courtList of courtLists) { + for (const sitting of courtList.Sittings) { + for (const hearing of sitting.Hearings ?? []) { + const defendants = (hearing.Defendants ?? []).map((d) => formatPddaDefendantName(d.PersonalDetails)).filter((n) => n.length > 0); + const hearingType = hearing.HearingDetails.HearingDescription || hearing.HearingDetails.HearingType || ""; + const fields: CaseSummary = []; + + fields.push({ label: "Defendant Name(s)", value: defendants.join(", ") }); + fields.push({ label: "Prosecuting Authority", value: hearing.Prosecution?.ProsecutingAuthority || "" }); + fields.push({ label: "Case Reference", value: hearing.CaseNumberCaTH || hearing.CaseNumber }); + fields.push({ label: "Hearing Type", value: hearingType }); + + summaries.push(fields); + } + } + } + + return summaries; +} diff --git a/libs/list-types/common/src/rendering/pdda-name-formatting.test.ts b/libs/list-types/common/src/rendering/pdda-name-formatting.test.ts new file mode 100644 index 000000000..b5bcad1b9 --- /dev/null +++ b/libs/list-types/common/src/rendering/pdda-name-formatting.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { formatPddaCitizenName, formatPddaDefendantName, formatPddaSittingTime } from "./crown-utilities.js"; + +describe("formatPddaCitizenName", () => { + it("should return CitizenNameRequestedName alone when present", () => { + expect( + formatPddaCitizenName({ CitizenNameTitle: "Mr", CitizenNameForename: ["John"], CitizenNameSurname: "Smith", CitizenNameRequestedName: "Requested" }) + ).toBe("Requested"); + }); + + it("should format title, forenames, surname, suffix when no RequestedName", () => { + expect( + formatPddaCitizenName({ CitizenNameTitle: "Ms", CitizenNameForename: ["Alice", "Jane"], CitizenNameSurname: "Jones", CitizenNameSuffix: "Jr" }) + ).toBe("Ms Alice Jane Jones Jr"); + }); + + it("should skip absent optional parts", () => { + expect(formatPddaCitizenName({ CitizenNameForename: ["Bob"], CitizenNameSurname: "Brown" })).toBe("Bob Brown"); + }); + + it("should return empty string when all parts are absent", () => { + expect(formatPddaCitizenName({})).toBe(""); + }); +}); + +describe("formatPddaDefendantName", () => { + it("should use MaskedName when IsMasked is yes and no RequestedName", () => { + expect( + formatPddaDefendantName({ + IsMasked: "YES", + MaskedName: "Reporting Restriction Applied", + Name: { CitizenNameForename: ["Real"], CitizenNameSurname: "Name" } + }) + ).toBe("Reporting Restriction Applied"); + }); + + it("should use MaskedName over RequestedName when IsMasked is yes", () => { + expect( + formatPddaDefendantName({ + IsMasked: "YES", + MaskedName: "Masked", + Name: { CitizenNameRequestedName: "RequestedOverride", CitizenNameForename: ["Real"], CitizenNameSurname: "Name" } + }) + ).toBe("Masked"); + }); + + it("should use RequestedName when IsMasked is yes but no MaskedName", () => { + expect( + formatPddaDefendantName({ + IsMasked: "YES", + Name: { CitizenNameRequestedName: "RequestedOverride", CitizenNameForename: ["Real"], CitizenNameSurname: "Name" } + }) + ).toBe("RequestedOverride"); + }); + + it("should format citizen name when IsMasked is no", () => { + expect( + formatPddaDefendantName({ + IsMasked: "NO", + Name: { CitizenNameTitle: "Mr", CitizenNameForename: ["John"], CitizenNameSurname: "Doe" } + }) + ).toBe("Mr John Doe"); + }); + + it("should return citizen name when IsMasked is yes but no MaskedName", () => { + expect( + formatPddaDefendantName({ + IsMasked: "YES", + Name: { CitizenNameForename: ["Jane"], CitizenNameSurname: "Smith" } + }) + ).toBe("Jane Smith"); + }); +}); + +describe("formatPddaSittingTime", () => { + it("should return empty string when timeStr is undefined", () => { + expect(formatPddaSittingTime(undefined)).toBe(""); + }); + + it("should format morning time without minutes", () => { + expect(formatPddaSittingTime("10:00:00")).toBe("10am"); + }); + + it("should format afternoon time without minutes", () => { + expect(formatPddaSittingTime("14:00:00")).toBe("2pm"); + }); + + it("should format time with minutes", () => { + expect(formatPddaSittingTime("14:30:00")).toBe("2:30pm"); + }); + + it("should return original string when format is invalid", () => { + expect(formatPddaSittingTime("invalid")).toBe("invalid"); + }); +}); diff --git a/libs/list-types/crown-daily-list/package.json b/libs/list-types/crown-daily-list/package.json new file mode 100644 index 000000000..50a2c1d00 --- /dev/null +++ b/libs/list-types/crown-daily-list/package.json @@ -0,0 +1,43 @@ +{ + "name": "@hmcts/crown-daily-list", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.ts" + } + }, + "scripts": { + "build": "tsc && yarn build:pdf-templates && yarn build:schemas", + "build:pdf-templates": "mkdir -p dist/pdf && cp src/pdf/*.njk dist/pdf/", + "build:schemas": "mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ." + }, + "dependencies": { + "@hmcts/list-types-common": "workspace:*", + "@hmcts/pdf-generation": "workspace:*", + "@hmcts/postgres-prisma": "workspace:*", + "luxon": "3.7.2", + "nunjucks": "3.2.4" + }, + "devDependencies": { + "@types/luxon": "3.7.1", + "@types/node": "24.10.4", + "@types/nunjucks": "3.2.6", + "typescript": "6.0.3", + "vitest": "4.1.8" + }, + "peerDependencies": { + "express": "^5.2.0" + } +} diff --git a/libs/list-types/crown-daily-list/src/config.ts b/libs/list-types/crown-daily-list/src/config.ts new file mode 100644 index 000000000..b4c527259 --- /dev/null +++ b/libs/list-types/crown-daily-list/src/config.ts @@ -0,0 +1,8 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const moduleRoot = __dirname; +export const assets = path.join(__dirname, "../assets/"); diff --git a/libs/list-types/crown-daily-list/src/email-summary/summary-builder.test.ts b/libs/list-types/crown-daily-list/src/email-summary/summary-builder.test.ts new file mode 100644 index 000000000..992c6a25a --- /dev/null +++ b/libs/list-types/crown-daily-list/src/email-summary/summary-builder.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import type { CrownDailyListData } from "../models/types.js"; +import { extractCaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "./summary-builder.js"; + +const buildTestData = (overrides?: Partial): CrownDailyListData => ({ + DailyList: { + DocumentID: { UniqueID: "CDL-2025-001", DocumentType: "crown_daily_pdda_list" }, + ListHeader: { StartDate: "2025-01-28", PublishedTime: "2025-01-28T09:00:00", Version: "1.0" }, + CrownCourt: { CourtHouseName: "Crown Court at Leeds" }, + CourtLists: [], + ...overrides + } +}); + +describe("extractCaseSummary", () => { + it("should extract case summaries with defendant name and hearing type", () => { + const testData = buildTestData({ + CourtLists: [ + { + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { Judge: {} }, + Hearings: [ + { + HearingDetails: { HearingDescription: "Trial" }, + CaseNumber: "T20250001", + Prosecution: { ProsecutingAuthority: "CPS" }, + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["John"], CitizenNameSurname: "Smith" }, + IsMasked: "NO" + } + } + ] + } + ] + } + ] + } + ] + }); + + const result = extractCaseSummary(testData); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual([ + { label: "Defendant Name(s)", value: "John Smith" }, + { label: "Prosecuting Authority", value: "CPS" }, + { label: "Case Reference", value: "T20250001" }, + { label: "Hearing Type", value: "Trial" } + ]); + }); + + it("should include defendant field with empty value when no defendants present", () => { + const testData = buildTestData({ + CourtLists: [ + { + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { Judge: {} }, + Hearings: [ + { + HearingDetails: { HearingDescription: "Plea" }, + CaseNumber: "T20250002", + Defendants: [] + } + ] + } + ] + } + ] + }); + + const result = extractCaseSummary(testData); + + expect(result[0].find((f) => f.label === "Defendant Name(s)")?.value).toBe(""); + }); + + it("should use hearingType fallback when hearingDescription is absent", () => { + const testData = buildTestData({ + CourtLists: [ + { + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { Judge: {} }, + Hearings: [ + { + HearingDetails: { HearingType: "Sentence" }, + CaseNumber: "T20250003" + } + ] + } + ] + } + ] + }); + + const result = extractCaseSummary(testData); + + expect(result[0].find((f) => f.label === "Hearing Type")?.value).toBe("Sentence"); + }); + + it("should return empty array for data with no court lists", () => { + const result = extractCaseSummary(buildTestData()); + expect(result).toHaveLength(0); + }); +}); + +describe("formatCaseSummaryForEmail", () => { + it("should format case summaries for email", () => { + const summaries = [ + [ + { label: "Defendant Name(s)", value: "John Smith" }, + { label: "Case Reference", value: "T20250001" }, + { label: "Prosecuting Authority", value: "CPS" }, + { label: "Hearing Type", value: "Trial" } + ] + ]; + + const result = formatCaseSummaryForEmail(summaries); + + expect(result).toContain("Defendant Name(s) - John Smith"); + expect(result).toContain("Case Reference - T20250001"); + expect(result).toContain("Prosecuting Authority - CPS"); + expect(result).toContain("Hearing Type - Trial"); + }); + + it("should handle empty case list", () => { + const result = formatCaseSummaryForEmail([]); + expect(result).toBe("No cases scheduled."); + }); +}); + +describe("SPECIAL_CATEGORY_DATA_WARNING", () => { + it("should contain required warning text", () => { + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Special Category Data"); + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Data Protection Act 2018"); + }); +}); diff --git a/libs/list-types/crown-daily-list/src/email-summary/summary-builder.ts b/libs/list-types/crown-daily-list/src/email-summary/summary-builder.ts new file mode 100644 index 000000000..15215f1e5 --- /dev/null +++ b/libs/list-types/crown-daily-list/src/email-summary/summary-builder.ts @@ -0,0 +1,8 @@ +import { type CaseSummary, extractPddaSittingsSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "@hmcts/list-types-common"; +import type { CrownDailyListData } from "../models/types.js"; + +export { formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING }; + +export function extractCaseSummary(jsonData: CrownDailyListData): CaseSummary[] { + return extractPddaSittingsSummary(jsonData.DailyList.CourtLists); +} diff --git a/libs/list-types/crown-daily-list/src/index.ts b/libs/list-types/crown-daily-list/src/index.ts new file mode 100644 index 000000000..4eefc77fe --- /dev/null +++ b/libs/list-types/crown-daily-list/src/index.ts @@ -0,0 +1,10 @@ +// Business logic exports + +export type { ValidationResult } from "@hmcts/publication"; +export * from "./email-summary/summary-builder.js"; +export { cy as crownDailyListCy } from "./locales/cy.js"; +export { en as crownDailyListEn } from "./locales/en.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export * from "./rendering/renderer.js"; +export { validateCrownDailyList } from "./validation/json-validator.js"; diff --git a/libs/list-types/crown-daily-list/src/locales/cy.ts b/libs/list-types/crown-daily-list/src/locales/cy.ts new file mode 100644 index 000000000..207872ca3 --- /dev/null +++ b/libs/list-types/crown-daily-list/src/locales/cy.ts @@ -0,0 +1,42 @@ +export const cy = { + title: "Rhestr Ddyddiol y Goron", + pageTitle: "Rhestr Ddyddiol y Goron ar gyfer", + factLinkText: "Dod o hyd i fanylion cyswllt a gwybodaeth arall am lysoedd a thribiwnlysoedd", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "yng Nghymru a Lloegr, a rhai tribiwnlysoedd heb eu datganoli yn yr Alban.", + listFor: "Rhestr ar gyfer", + lastUpdated: "Diweddarwyd ddiwethaf", + version: "Fersiwn", + publicationDate: "Dyddiad cyhoeddi", + courtroom: "Ystafell Llys", + court: "LLYS", + beforeJudge: "Gerbron", + sittingAt: "Yn eistedd am", + hearingTime: "Amser gwrandawiad", + caseRef: "Cyfeirnod achos", + defendant: "Diffynnydd/Diffynyddion", + hearingType: "Math o wrandawiad", + prosecutingAuthority: "Awdurdod erlyn", + listingNotes: "Nodiadau rhestru", + reportingRestrictions: "Cyfyngiad Adrodd", + reportingRestrictionsTitle: "Cyfyngiadau ar gyhoeddi neu ysgrifennu am yr achosion hyn", + reportingRestrictionsBodyIntro: + "Rhaid i chi wirio a oes unrhyw gyfyngiadau adrodd yn berthnasol cyn cyhoeddi manylion am unrhyw un o'r achosion a restrir yma naill ai ar bapur, mewn darllediad neu dros y rhyngrwyd, gan gynnwys cyfryngau cymdeithasol.", + reportingRestrictionsWarning: + "Byddwch yn euog o ddirmyg llys os byddwch yn cyhoeddi unrhyw wybodaeth sydd wedi'i diogelu gan gyfyngiad adrodd. Gallech gael dirwy, dedfryd o garchar neu'r ddau.", + reportingRestrictionsBodySpecific: "Bydd cyfyngiadau penodol a orchmynnwyd gan y llys yn cael eu crybwyll ar yr achosion a restrir yma.", + reportingRestrictionsBodyHowever: + "Fodd bynnag, nid yw cyfyngiadau bob amser yn cael eu rhestru. Mae rhai yn berthnasol yn awtomatig. Er enghraifft, anhysbysrwydd a roddir i ddioddefwyr troseddau rhywiol penodol.", + reportingRestrictionsBodyContact: "I ddarganfod pa gyfyngiadau adrodd sy'n berthnasol i achos penodol, cysylltwch â:", + reportingRestrictionsContactCourt: "y llys yn uniongyrchol", + reportingRestrictionsContactHmcts: "Gwasanaeth Llysoedd a Thribiwnlysoedd Ei Fawrhydi ar 0330 808 4407", + searchCases: "Chwilio achosion", + backToTop: "Yn ôl i frig y dudalen", + courtHouseDetails: "Manylion y Llys", + dataSource: "Ffynhonnell Data", + errorTitle: "Cyhoeddiad ddim ar gael", + errorMessage: + "Ni ellir gweld y cyhoeddiad hwn ar hyn o bryd. Gwiriwch eto yn nes ymlaen. Os yw'r broblem yn parhau, cysylltwch â'r llys yn uniongyrchol am gymorth.", + error403Title: "Mynediad wedi'i Wrthod", + error403Message: "Nid oes gennych ganiatâd i weld y cyhoeddiad hwn." +}; diff --git a/libs/list-types/crown-daily-list/src/locales/en.ts b/libs/list-types/crown-daily-list/src/locales/en.ts new file mode 100644 index 000000000..fc7b618a3 --- /dev/null +++ b/libs/list-types/crown-daily-list/src/locales/en.ts @@ -0,0 +1,42 @@ +export const en = { + title: "Crown Daily List", + pageTitle: "Crown Daily List for", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + listFor: "List for", + lastUpdated: "Last updated", + version: "Version", + publicationDate: "Publication date", + courtroom: "Courtroom", + court: "COURT", + beforeJudge: "Before", + sittingAt: "Sitting at", + hearingTime: "Hearing Time", + caseRef: "Case Reference", + defendant: "Defendant Name(s)", + hearingType: "Hearing Type", + prosecutingAuthority: "Prosecuting Authority", + listingNotes: "Listing Notes", + reportingRestrictions: "Reporting Restriction", + reportingRestrictionsTitle: "Restrictions on publishing or writing about these cases", + reportingRestrictionsBodyIntro: + "You must check if any reporting restrictions apply before publishing details on any of the cases listed here either in writing, in a broadcast or by internet, including social media.", + reportingRestrictionsWarning: + "You'll be in contempt of court if you publish any information which is protected by a reporting restriction. You could get a fine, prison sentence or both.", + reportingRestrictionsBodySpecific: "Specific restrictions ordered by the court will be mentioned on the cases listed here.", + reportingRestrictionsBodyHowever: + "However, restrictions are not always listed. Some apply automatically. For example, anonymity given to the victims of certain sexual offences.", + reportingRestrictionsBodyContact: "To find out which reporting restrictions apply on a specific case, contact:", + reportingRestrictionsContactCourt: "the court directly", + reportingRestrictionsContactHmcts: "HM Courts and Tribunals Service on 0330 808 4407", + searchCases: "Search Cases", + backToTop: "Back to top", + courtHouseDetails: "Court House Details", + dataSource: "Data Source", + errorTitle: "Publication not available", + errorMessage: + "This publication cannot be viewed at the moment. Please check again later. If the problem persists, contact the court directly for assistance.", + error403Title: "Access Denied", + error403Message: "You do not have permission to view this publication." +}; diff --git a/libs/list-types/crown-daily-list/src/models/types.ts b/libs/list-types/crown-daily-list/src/models/types.ts new file mode 100644 index 000000000..c16149834 --- /dev/null +++ b/libs/list-types/crown-daily-list/src/models/types.ts @@ -0,0 +1,134 @@ +export interface CitizenName { + CitizenNameTitle?: string; + CitizenNameForename?: string[]; + CitizenNameSurname?: string; + CitizenNameRequestedName?: string; + CitizenNameSuffix?: string; +} + +export interface PersonalDetails { + Name: CitizenName; + MaskedName?: string; + IsMasked: "YES" | "NO"; + DateOfBirth?: string; + Age?: number; + Sex?: string; +} + +export interface PddaDefendant { + PersonalDetails: PersonalDetails; + URN?: string; + PrisonerID?: string; +} + +export interface PddaHearingDetails { + HearingDescription?: string; + HearingType?: string; +} + +export interface PddaHearing { + HearingSequenceNumber?: number; + HearingDetails: PddaHearingDetails; + CaseNumber: string; + CaseNumberCaTH?: string; + ListNote?: string; + TimeMarkingNote?: string; + Prosecution?: { + ProsecutingReference?: string; + ProsecutingOrganisation?: { OrganisationName?: string }; + ProsecutingAuthority?: string; + }; + Defendants?: PddaDefendant[]; +} + +export interface PddaJudiciary { + Judge: CitizenName; + Justice?: CitizenName[]; +} + +export interface PddaSitting { + CourtRoomNumber: number; + SittingAt?: string; + Judiciary: PddaJudiciary; + Hearings?: PddaHearing[]; +} + +export interface PddaAddress { + Line?: string[]; + PostCode?: string; +} + +export interface PddaCourtHouse { + CourtHouseName: string; + CourtHouseType?: string; + CourtHouseCode?: number; + CourtHouseAddress?: PddaAddress; + CourtHouseTelephone?: string; +} + +export interface PddaListHeader { + StartDate?: string; + EndDate?: string; + Version?: string; + PublishedTime?: string; +} + +export interface CrownDailyListData { + DailyList: { + DocumentID: { UniqueID: string; DocumentType: string }; + ListHeader: PddaListHeader; + CrownCourt: PddaCourtHouse; + CourtLists: Array<{ + CourtHouse: PddaCourtHouse; + Sittings: PddaSitting[]; + }>; + }; +} + +export interface RenderOptions { + locationId: string; + contentDate: Date; + locale: string; +} + +export interface CrownDailyCaseRendered { + caseNumber: string; + prosecutingAuthority: string; + listingNotes: string; + timeMarkingNote: string; + defendants: string; + representative: string; + formattedReportingRestriction: string; +} + +export interface CrownDailyHearingRendered { + displayHearingType: string; + case: CrownDailyCaseRendered[]; +} + +export interface CrownDailySittingRendered { + time: string; + hearing: CrownDailyHearingRendered[]; +} + +export interface CrownDailySessionRendered { + formattedJudiciaries: string; + hasListingNotes: boolean; + sittings: CrownDailySittingRendered[]; +} + +export interface CrownDailyCourtRoomRendered { + courtRoomName: string; + session: CrownDailySessionRendered[]; +} + +export interface CrownDailyListRendered { + courtLists: Array<{ + courtHouse: { + courtHouseName: string; + courtHouseAddressLines: string[]; + courtHousePhone: string; + courtRoom: CrownDailyCourtRoomRendered[]; + }; + }>; +} diff --git a/libs/list-types/crown-daily-list/src/pdf/pdf-generator.test.ts b/libs/list-types/crown-daily-list/src/pdf/pdf-generator.test.ts new file mode 100644 index 000000000..277c96af0 --- /dev/null +++ b/libs/list-types/crown-daily-list/src/pdf/pdf-generator.test.ts @@ -0,0 +1,342 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockSavePdfToStorage, mockCreatePdfErrorResult, mockConfigureNunjucks, mockLoadTranslations } = vi.hoisted(() => ({ + mockSavePdfToStorage: vi.fn(), + mockCreatePdfErrorResult: vi.fn(), + mockConfigureNunjucks: vi.fn(), + mockLoadTranslations: vi.fn() +})); + +vi.mock("@hmcts/list-types-common", () => ({ + savePdfToStorage: mockSavePdfToStorage, + createPdfErrorResult: mockCreatePdfErrorResult, + configureNunjucks: mockConfigureNunjucks, + loadTranslations: mockLoadTranslations, + PDF_BASE_STYLES: "/* base styles */", + PDF_CIVIL_FAMILY_STYLES: "/* civil family styles */" +})); + +vi.mock("@hmcts/pdf-generation", () => ({ + generatePdfFromHtml: vi.fn() +})); + +vi.mock("../rendering/renderer.js", () => ({ + renderCrownDailyListData: vi.fn() +})); + +vi.mock("@hmcts/publication", () => ({ + PROVENANCE_LABELS: { + MANUAL_UPLOAD: "Manual Upload", + SNL: "SNL" + } +})); + +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { renderCrownDailyListData } from "../rendering/renderer.js"; +import { generateCrownDailyListPdf } from "./pdf-generator.js"; + +const mockRenderedData = { + header: { + locationName: "Crown Court at Manchester", + addressLines: ["Crown Square", "M3 3FL"], + contentDate: "12 November 2025", + lastUpdated: "12 November 2025 at 9am", + version: "1.0" + }, + openJustice: { + venueName: "Crown Court at Manchester", + email: "", + phone: "0161 954 1800" + }, + listData: null, + groupedListData: [] +}; + +const mockJsonData = { + DailyList: { + DocumentID: { UniqueID: "CDL-2025-001", DocumentType: "crown_daily_pdda_list" }, + ListHeader: { StartDate: "2025-11-12", PublishedTime: "2025-11-12T09:00:00", Version: "1.0" }, + CrownCourt: { CourtHouseName: "Crown Court at Manchester" }, + CourtLists: [] + } +}; + +describe("generateCrownDailyListPdf", () => { + const mockNunjucksEnv = { + render: vi.fn().mockReturnValue("PDF HTML") + }; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(renderCrownDailyListData).mockResolvedValue(mockRenderedData); + mockConfigureNunjucks.mockReturnValue(mockNunjucksEnv); + mockLoadTranslations.mockResolvedValue({ pageTitle: "Crown Daily List" }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "/storage/temp/uploads/test.pdf", + sizeBytes: 1024, + exceedsMaxSize: false + }); + mockCreatePdfErrorResult.mockImplementation((error: unknown) => ({ + success: false, + error: `Failed to generate PDF: ${error instanceof Error ? error.message : "Unknown error"}` + })); + }); + + it("should generate PDF successfully", async () => { + const pdfBuffer = Buffer.from("PDF content"); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer, + sizeBytes: 1024 + }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "/storage/temp/uploads/test-artefact-123.pdf", + sizeBytes: 1024, + exceedsMaxSize: false + }); + + const result = await generateCrownDailyListPdf({ + artefactId: "test-artefact-123", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(true); + expect(result.pdfPath).toContain("test-artefact-123.pdf"); + expect(mockSavePdfToStorage).toHaveBeenCalledWith("test-artefact-123", pdfBuffer, 1024); + }); + + it("should return error when PDF generation fails", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: false, + error: "Puppeteer crashed" + }); + + const result = await generateCrownDailyListPdf({ + artefactId: "failed-pdf", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Puppeteer crashed"); + }); + + it("should return default error when PDF generation fails without error message", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: false + }); + + const result = await generateCrownDailyListPdf({ + artefactId: "failed-pdf", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("PDF generation failed"); + }); + + it("should return error when PDF buffer is missing", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: undefined, + sizeBytes: 0 + }); + + const result = await generateCrownDailyListPdf({ + artefactId: "no-buffer", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("PDF generation failed"); + }); + + it("should handle renderer errors gracefully", async () => { + vi.mocked(renderCrownDailyListData).mockRejectedValue(new Error("Renderer failed")); + + const result = await generateCrownDailyListPdf({ + artefactId: "renderer-error", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Failed to generate PDF: Renderer failed"); + }); + + it("should handle non-Error exceptions", async () => { + vi.mocked(renderCrownDailyListData).mockRejectedValue("String error"); + + const result = await generateCrownDailyListPdf({ + artefactId: "string-error", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Failed to generate PDF: Unknown error"); + }); + + it("should handle file system errors", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + mockSavePdfToStorage.mockRejectedValue(new Error("Disk full")); + + const result = await generateCrownDailyListPdf({ + artefactId: "fs-error", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Failed to generate PDF: Disk full"); + }); + + it("should pass correct options to renderer", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + const contentDate = new Date("2025-11-12"); + + await generateCrownDailyListPdf({ + artefactId: "test-options", + contentDate, + locale: "cy", + locationId: "999", + jsonData: mockJsonData as any + }); + + expect(renderCrownDailyListData).toHaveBeenCalledWith(mockJsonData, { + contentDate, + locale: "cy", + locationId: "999" + }); + }); + + it("should use MANUAL_UPLOAD provenance label", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + await generateCrownDailyListPdf({ + artefactId: "provenance-test", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any, + provenance: "MANUAL_UPLOAD" + }); + + expect(mockNunjucksEnv.render).toHaveBeenCalledWith("pdf-template.njk", expect.objectContaining({ dataSource: "Manual Upload" })); + }); + + it("should use raw provenance value when label not found", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + await generateCrownDailyListPdf({ + artefactId: "unknown-provenance", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any, + provenance: "UNKNOWN_SOURCE" + }); + + expect(mockNunjucksEnv.render).toHaveBeenCalledWith("pdf-template.njk", expect.objectContaining({ dataSource: "UNKNOWN_SOURCE" })); + }); + + it("should handle missing provenance", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + const result = await generateCrownDailyListPdf({ + artefactId: "no-provenance", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(true); + expect(mockNunjucksEnv.render).toHaveBeenCalledWith("pdf-template.njk", expect.objectContaining({ dataSource: "" })); + }); + + it("should generate PDF for Welsh locale", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + const result = await generateCrownDailyListPdf({ + artefactId: "welsh-pdf", + contentDate: new Date("2025-11-12"), + locale: "cy", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(true); + expect(renderCrownDailyListData).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ locale: "cy" })); + }); + + it("should return exceedsMaxSize true when PDF is over 2MB", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.alloc(3 * 1024 * 1024), + sizeBytes: 3 * 1024 * 1024 + }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "/storage/temp/uploads/large.pdf", + sizeBytes: 3 * 1024 * 1024, + exceedsMaxSize: true + }); + + const result = await generateCrownDailyListPdf({ + artefactId: "large-pdf", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(true); + expect(result.exceedsMaxSize).toBe(true); + }); +}); diff --git a/libs/list-types/crown-daily-list/src/pdf/pdf-generator.ts b/libs/list-types/crown-daily-list/src/pdf/pdf-generator.ts new file mode 100644 index 000000000..c060c1d20 --- /dev/null +++ b/libs/list-types/crown-daily-list/src/pdf/pdf-generator.ts @@ -0,0 +1,66 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + type BasePdfGenerationOptions, + configureNunjucks, + createPdfErrorResult, + loadTranslations, + PDF_BASE_STYLES, + PDF_CIVIL_FAMILY_STYLES, + type PdfGenerationResult, + savePdfToStorage +} from "@hmcts/list-types-common"; +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { PROVENANCE_LABELS } from "@hmcts/publication"; +import type { CrownDailyListData, RenderOptions } from "../models/types.js"; +import { renderCrownDailyListData } from "../rendering/renderer.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface PdfGenerationOptions extends BasePdfGenerationOptions { + contentDate: Date; +} + +export async function generateCrownDailyListPdf(options: PdfGenerationOptions): Promise { + try { + const renderOptions: RenderOptions = { + contentDate: options.contentDate, + locale: options.locale, + locationId: options.locationId + }; + + const renderedData = await renderCrownDailyListData(options.jsonData, renderOptions); + + const translations = await loadTranslations( + options.locale, + () => import("../locales/en.js"), + () => import("../locales/cy.js") + ); + + const provenanceLabel = options.provenance ? PROVENANCE_LABELS[options.provenance as keyof typeof PROVENANCE_LABELS] || options.provenance : ""; + + const env = configureNunjucks(__dirname); + const html = env.render("pdf-template.njk", { + header: renderedData.header, + openJustice: renderedData.openJustice, + listData: renderedData.listData, + dataSource: provenanceLabel, + t: translations, + pdfStyles: PDF_BASE_STYLES + PDF_CIVIL_FAMILY_STYLES + }); + + const pdfResult = await generatePdfFromHtml(html); + + if (!pdfResult.success || !pdfResult.pdfBuffer) { + return { + success: false, + error: pdfResult.error || "PDF generation failed" + }; + } + + return await savePdfToStorage(options.artefactId, pdfResult.pdfBuffer, pdfResult.sizeBytes!); + } catch (error) { + return createPdfErrorResult(error); + } +} diff --git a/libs/list-types/crown-daily-list/src/pdf/pdf-template.njk b/libs/list-types/crown-daily-list/src/pdf/pdf-template.njk new file mode 100644 index 000000000..a0ec2ee30 --- /dev/null +++ b/libs/list-types/crown-daily-list/src/pdf/pdf-template.njk @@ -0,0 +1,145 @@ + + + + + + {{ t.pageTitle }} {{ header.locationName }} + + + + +
+

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

+ +

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

+ +

{{ t.listFor }} {{ header.contentDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdated }}

+ + {% if header.version | length %} +

{{ t.version }} {{ header.version }}

+ {% endif %} + +
+ {% for line in header.addressLines %} +

{{ line }}

+ {% endfor %} +
+
+ +
+

{{ t.reportingRestrictionsTitle }}

+

{{ t.reportingRestrictionsBodyIntro }}

+
+ + {{ t.reportingRestrictionsWarning }} +
+

{{ t.reportingRestrictionsBodySpecific }}

+

{{ t.reportingRestrictionsBodyHowever }}

+

{{ t.reportingRestrictionsBodyContact }}

+
    +
  • {{ t.reportingRestrictionsContactCourt }}
  • +
  • {{ t.reportingRestrictionsContactHmcts }}
  • +
+
+ + {% for courtList in listData.courtLists %} + {% set courtHouse = courtList.courtHouse %} +
+

{{ courtHouse.courtHouseName }}

+ {% if courtHouse.courtHouseAddressLines | length or courtHouse.courtHousePhone | length %} +
+ {% for line in courtHouse.courtHouseAddressLines %} +

{{ line }}

+ {% endfor %} + {% if courtHouse.courtHousePhone | length %} +

{{ courtHouse.courtHousePhone }}

+ {% endif %} +
+ {% endif %} + + {% for courtRoom in courtHouse.courtRoom %} + {% for session in courtRoom.session %} +
+
+ {% if session.formattedJudiciaries | length %} + {{ t.court }} {{ courtRoom.courtRoomName }}: {{ session.formattedJudiciaries }} + {% else %} + {{ t.court }} {{ courtRoom.courtRoomName }} + {% endif %} +
+ + {% for sitting in session.sittings %} +

{{ t.sittingAt }} {{ sitting.time }}

+ + + + + + + + + {% if session.hasListingNotes %} + + {% endif %} + + + + {% for hearing in sitting.hearing %} + {% for case in hearing.case %} + + + + + + + {% if session.hasListingNotes %} + + {% endif %} + + {% if case.formattedReportingRestriction | length %} + + + + {% endif %} + {% endfor %} + {% endfor %} + +
{{ t.hearingTime }}{{ t.caseRef }}{{ t.defendant }}{{ t.hearingType }}{{ t.prosecutingAuthority }}{{ t.listingNotes }}
{{ case.timeMarkingNote }}{{ case.caseNumber }}{{ case.defendants }}{{ hearing.displayHearingType }}{{ case.prosecutingAuthority }}{{ case.listingNotes }}
+ {{ t.reportingRestrictions }}: {{ case.formattedReportingRestriction }} +
+ {% endfor %} +
+ {% endfor %} + {% endfor %} +
+ {% endfor %} + + + + diff --git a/libs/list-types/crown-daily-list/src/rendering/renderer.test.ts b/libs/list-types/crown-daily-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..b93094002 --- /dev/null +++ b/libs/list-types/crown-daily-list/src/rendering/renderer.test.ts @@ -0,0 +1,576 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderCrownDailyListData } from "./renderer.js"; + +vi.mock("@hmcts/location", () => ({ + getLocationById: vi.fn() +})); + +import { getLocationById } from "@hmcts/location"; + +const baseInput = { + DailyList: { + DocumentID: { UniqueID: "CDPL-2025-001", DocumentType: "crown_daily_pdda_list" }, + ListHeader: { + StartDate: "2025-11-12", + PublishedTime: "2025-11-12T09:00:00", + Version: "1.0" + }, + CrownCourt: { + CourtHouseName: "Crown Court at Leeds", + CourtHouseTelephone: "0113 306 2500", + CourtHouseAddress: { + Line: ["1 Oxford Row"], + PostCode: "LS1 3BG" + } + }, + CourtLists: [] + } +}; + +describe("renderCrownDailyListData", () => { + beforeEach(() => { + vi.clearAllMocks(); + (getLocationById as ReturnType).mockResolvedValue(undefined); + }); + + it("should render header with location name from CrownCourt when no DB location", async () => { + const result = await renderCrownDailyListData(baseInput, { + locationId: "100", + contentDate: new Date("2025-01-01"), + locale: "en" + }); + + expect(result.header.locationName).toBe("Crown Court at Leeds"); + expect(result.header.addressLines).toEqual(["1 Oxford Row", "LS1 3BG"]); + expect(result.header.contentDate).toBe("12 November 2025"); + }); + + it("should use Welsh location name when locale is cy and welshName is present", async () => { + (getLocationById as ReturnType).mockResolvedValue({ + name: "Crown Court at Leeds", + welshName: "Llys y Goron yn Leeds" + }); + + const result = await renderCrownDailyListData(baseInput, { + locationId: "100", + contentDate: new Date("2025-01-01"), + locale: "cy" + }); + + expect(result.header.locationName).toBe("Llys y Goron yn Leeds"); + }); + + it("should render open justice contact details from CrownCourt.CourtHouseAddress", async () => { + const result = await renderCrownDailyListData(baseInput, { + locationId: "100", + contentDate: new Date("2025-01-01"), + locale: "en" + }); + + expect(result.openJustice.venueName).toBe("Crown Court at Leeds"); + expect(result.openJustice.email).toBe(""); + expect(result.openJustice.phone).toBe("0113 306 2500"); + }); + + it("should group sittings by CourtRoomNumber into courtRoom", async () => { + const input = { + ...baseInput, + DailyList: { + ...baseInput.DailyList, + CourtLists: [ + { + Sittings: [ + { + CourtRoomNumber: 1, + SittingAt: "10:00:00", + Judiciary: { Judge: { CitizenNameForename: ["HHJ"], CitizenNameSurname: "Smith" } }, + Hearings: [] + }, + { + CourtRoomNumber: 2, + SittingAt: "11:00:00", + Judiciary: { Judge: { CitizenNameForename: ["HHJ"], CitizenNameSurname: "Jones" } }, + Hearings: [] + }, + { + CourtRoomNumber: 1, + SittingAt: "14:00:00", + Judiciary: { Judge: { CitizenNameForename: ["HHJ"], CitizenNameSurname: "Smith" } }, + Hearings: [] + } + ] + } + ] + } + }; + + const result = await renderCrownDailyListData(input, { + locationId: "100", + contentDate: new Date("2025-01-01"), + locale: "en" + }); + + const courtRooms = result.listData.courtLists[0].courtHouse.courtRoom; + expect(courtRooms).toHaveLength(2); + expect(courtRooms[0].courtRoomName).toBe("1"); + expect(courtRooms[0].session).toHaveLength(2); + expect(courtRooms[1].courtRoomName).toBe("2"); + expect(courtRooms[1].session).toHaveLength(1); + }); + + it("should format sitting time from SittingAt HH:MM:SS", async () => { + const input = { + ...baseInput, + DailyList: { + ...baseInput.DailyList, + CourtLists: [ + { + Sittings: [ + { + CourtRoomNumber: 1, + SittingAt: "10:00:00", + Judiciary: { + Judge: { CitizenNameForename: ["John"], CitizenNameSurname: "Smith" } + }, + Hearings: [ + { + HearingDetails: { HearingDescription: "Trial" }, + CaseNumber: "T20250001", + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["Jane"], CitizenNameSurname: "Doe" }, + IsMasked: "NO" as const + } + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownDailyListData(input, { + locationId: "100", + contentDate: new Date("2025-01-01"), + locale: "en" + }); + + const session = result.listData.courtLists[0].courtHouse.courtRoom[0].session[0]; + expect(session.sittings[0].time).toBe("10am"); + }); + + it("should format afternoon sitting time with minutes from SittingAt", async () => { + const input = { + ...baseInput, + DailyList: { + ...baseInput.DailyList, + CourtLists: [ + { + Sittings: [ + { + CourtRoomNumber: 1, + SittingAt: "14:30:00", + Judiciary: { Judge: {} }, + Hearings: [] + } + ] + } + ] + } + }; + + const result = await renderCrownDailyListData(input, { + locationId: "100", + contentDate: new Date("2025-01-01"), + locale: "en" + }); + + expect(result.listData.courtLists[0].courtHouse.courtRoom[0].session[0].sittings[0].time).toBe("2:30pm"); + }); + + it("should extract defendant names from Defendants[].PersonalDetails", async () => { + const input = { + ...baseInput, + DailyList: { + ...baseInput.DailyList, + CourtLists: [ + { + Sittings: [ + { + CourtRoomNumber: 1, + SittingAt: "10:00:00", + Judiciary: { Judge: { CitizenNameForename: ["HHJ"], CitizenNameSurname: "Smith" } }, + Hearings: [ + { + HearingDetails: { HearingDescription: "Trial" }, + CaseNumber: "T20250001", + Prosecution: { ProsecutingAuthority: "CPS" }, + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["John"], CitizenNameSurname: "Smith" }, + IsMasked: "NO" as const + } + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownDailyListData(input, { + locationId: "100", + contentDate: new Date("2025-01-01"), + locale: "en" + }); + + const caseItem = result.listData.courtLists[0].courtHouse.courtRoom[0].session[0].sittings[0].hearing[0].case[0]; + expect(caseItem.defendants).toBe("John Smith"); + expect(caseItem.prosecutingAuthority).toBe("CPS"); + expect(caseItem.caseNumber).toBe("T20250001"); + }); + + it("should use MaskedName when IsMasked is yes", async () => { + const input = { + ...baseInput, + DailyList: { + ...baseInput.DailyList, + CourtLists: [ + { + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { Judge: {} }, + Hearings: [ + { + HearingDetails: {}, + CaseNumber: "T20250002", + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["John"], CitizenNameSurname: "Smith" }, + MaskedName: "Defendant A", + IsMasked: "YES" as const + } + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownDailyListData(input, { + locationId: "100", + contentDate: new Date("2025-01-01"), + locale: "en" + }); + + const caseItem = result.listData.courtLists[0].courtHouse.courtRoom[0].session[0].sittings[0].hearing[0].case[0]; + expect(caseItem.defendants).toBe("Defendant A"); + }); + + it("should use MaskedName over CitizenNameRequestedName when IsMasked is yes", async () => { + const input = { + ...baseInput, + DailyList: { + ...baseInput.DailyList, + CourtLists: [ + { + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { Judge: {} }, + Hearings: [ + { + HearingDetails: {}, + CaseNumber: "T20250003", + Defendants: [ + { + PersonalDetails: { + Name: { + CitizenNameForename: ["John"], + CitizenNameSurname: "Smith", + CitizenNameRequestedName: "RequestedName" + }, + MaskedName: "Defendant A", + IsMasked: "YES" as const + } + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownDailyListData(input, { + locationId: "100", + contentDate: new Date("2025-01-01"), + locale: "en" + }); + + const caseItem = result.listData.courtLists[0].courtHouse.courtRoom[0].session[0].sittings[0].hearing[0].case[0]; + expect(caseItem.defendants).toBe("Defendant A"); + }); + + it("should format judiciary names from Judge and Justice[]", async () => { + const input = { + ...baseInput, + DailyList: { + ...baseInput.DailyList, + CourtLists: [ + { + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { + Judge: { + CitizenNameTitle: "HHJ", + CitizenNameForename: ["James"], + CitizenNameSurname: "Smith" + }, + Justice: [ + { CitizenNameForename: ["Alice"], CitizenNameSurname: "Jones" }, + { CitizenNameForename: ["Bob"], CitizenNameSurname: "Brown" } + ] + }, + Hearings: [] + } + ] + } + ] + } + }; + + const result = await renderCrownDailyListData(input, { + locationId: "100", + contentDate: new Date("2025-01-01"), + locale: "en" + }); + + const session = result.listData.courtLists[0].courtHouse.courtRoom[0].session[0]; + expect(session.formattedJudiciaries).toBe("HHJ James Smith, Alice Jones, Bob Brown"); + }); + + it("should use Welsh locale for content date", async () => { + const result = await renderCrownDailyListData(baseInput, { + locationId: "100", + contentDate: new Date("2025-01-01"), + locale: "cy" + }); + + expect(result.header.contentDate).toContain("Tachwedd"); + }); + + it("should include version in header from ListHeader.Version", async () => { + const result = await renderCrownDailyListData(baseInput, { + locationId: "100", + contentDate: new Date("2025-01-01"), + locale: "en" + }); + + expect(result.header.version).toBe("1.0"); + }); + + it("should use CitizenNameRequestedName as primary name when present", async () => { + const input = { + ...baseInput, + DailyList: { + ...baseInput.DailyList, + CourtLists: [ + { + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { + Judge: { + CitizenNameTitle: "HHJ", + CitizenNameForename: ["James"], + CitizenNameSurname: "Smith", + CitizenNameRequestedName: "JudgeRequested" + } + }, + Hearings: [] + } + ] + } + ] + } + }; + + const result = await renderCrownDailyListData(input, { + locationId: "100", + contentDate: new Date("2025-01-01"), + locale: "en" + }); + + const session = result.listData.courtLists[0].courtHouse.courtRoom[0].session[0]; + expect(session.formattedJudiciaries).toBe("JudgeRequested"); + }); + + it("should append CitizenNameSuffix when present", async () => { + const input = { + ...baseInput, + DailyList: { + ...baseInput.DailyList, + CourtLists: [ + { + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { + Judge: {}, + Justice: [ + { + CitizenNameTitle: "Ms", + CitizenNameForename: ["Alice"], + CitizenNameSurname: "Jones", + CitizenNameSuffix: "Sr" + } + ] + }, + Hearings: [] + } + ] + } + ] + } + }; + + const result = await renderCrownDailyListData(input, { + locationId: "100", + contentDate: new Date("2025-01-01"), + locale: "en" + }); + + const session = result.listData.courtLists[0].courtHouse.courtRoom[0].session[0]; + expect(session.formattedJudiciaries).toBe("Ms Alice Jones Sr"); + }); + + it("should include timeMarkingNote in rendered case", async () => { + const input = { + ...baseInput, + DailyList: { + ...baseInput.DailyList, + CourtLists: [ + { + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { Judge: {} }, + Hearings: [ + { + HearingDetails: { HearingDescription: "Trial" }, + CaseNumber: "T20250001", + TimeMarkingNote: "10:00 FIXED", + Defendants: [] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownDailyListData(input, { + locationId: "100", + contentDate: new Date("2025-01-01"), + locale: "en" + }); + + const caseItem = result.listData.courtLists[0].courtHouse.courtRoom[0].session[0].sittings[0].hearing[0].case[0]; + expect(caseItem.timeMarkingNote).toBe("10:00 FIXED"); + }); + + it("should set hasListingNotes true when a hearing has ListNote", async () => { + const input = { + ...baseInput, + DailyList: { + ...baseInput.DailyList, + CourtLists: [ + { + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { Judge: {} }, + Hearings: [ + { + HearingDetails: {}, + CaseNumber: "T20250001", + ListNote: "Custody time limit expires", + Defendants: [] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownDailyListData(input, { + locationId: "100", + contentDate: new Date("2025-01-01"), + locale: "en" + }); + + const session = result.listData.courtLists[0].courtHouse.courtRoom[0].session[0]; + expect(session.hasListingNotes).toBe(true); + }); + + it("should set hasListingNotes false when no hearings have ListNote", async () => { + const result = await renderCrownDailyListData(baseInput, { + locationId: "100", + contentDate: new Date("2025-01-01"), + locale: "en" + }); + + const session = result.listData.courtLists[0]?.courtHouse.courtRoom[0]?.session[0]; + expect(session?.hasListingNotes ?? false).toBe(false); + }); + + it("should include courtHouseAddressLines and courtHousePhone from CourtHouse", async () => { + const input = { + ...baseInput, + DailyList: { + ...baseInput.DailyList, + CourtLists: [ + { + CourtHouse: { + CourtHouseName: "Crown Court at Leeds", + CourtHouseAddress: { Line: ["1 Oxford Row"], PostCode: "LS1 3BG" }, + CourtHouseTelephone: "0113 306 2500" + }, + Sittings: [] + } + ] + } + }; + + const result = await renderCrownDailyListData(input as any, { + locationId: "100", + contentDate: new Date("2025-01-01"), + locale: "en" + }); + + const courtHouse = result.listData.courtLists[0].courtHouse; + expect(courtHouse.courtHouseAddressLines).toEqual(["1 Oxford Row", "LS1 3BG"]); + expect(courtHouse.courtHousePhone).toBe("0113 306 2500"); + }); +}); diff --git a/libs/list-types/crown-daily-list/src/rendering/renderer.ts b/libs/list-types/crown-daily-list/src/rendering/renderer.ts new file mode 100644 index 000000000..ca6ffe190 --- /dev/null +++ b/libs/list-types/crown-daily-list/src/rendering/renderer.ts @@ -0,0 +1,146 @@ +import { formatContentDate, formatCrownLastUpdated, formatPddaCitizenName, formatPddaDefendantName, formatPddaSittingTime } from "@hmcts/list-types-common"; +import { getLocationById } from "@hmcts/location"; +import type { + CrownDailyCaseRendered, + CrownDailyCourtRoomRendered, + CrownDailyHearingRendered, + CrownDailyListData, + CrownDailyListRendered, + CrownDailySessionRendered, + CrownDailySittingRendered, + PddaDefendant, + PddaHearing, + PddaJudiciary, + PddaSitting, + RenderOptions +} from "../models/types.js"; + +export async function renderCrownDailyListData(jsonData: CrownDailyListData, options: RenderOptions) { + const location = await getLocationById(Number.parseInt(options.locationId, 10)); + const locationName = options.locale === "cy" && location?.welshName ? location.welshName : location?.name || jsonData.DailyList.CrownCourt.CourtHouseName; + + const crownCourt = jsonData.DailyList.CrownCourt; + const address = crownCourt.CourtHouseAddress; + const addressLines = formatAddress(address); + + const publishedTime = jsonData.DailyList.ListHeader.PublishedTime; + const lastUpdated = publishedTime ? formatCrownLastUpdated(publishedTime, options.locale) : ""; + + const startDate = jsonData.DailyList.ListHeader.StartDate; + const contentDate = startDate ? formatContentDate(new Date(startDate), options.locale) : formatContentDate(options.contentDate, options.locale); + + const header = { + locationName, + addressLines, + contentDate, + lastUpdated, + version: jsonData.DailyList.ListHeader.Version || "" + }; + + const openJustice = { + venueName: crownCourt.CourtHouseName, + email: "", + phone: crownCourt.CourtHouseTelephone || "" + }; + + const listData: CrownDailyListRendered = { + courtLists: jsonData.DailyList.CourtLists.map((courtList) => { + const courtHouseName = courtList.CourtHouse?.CourtHouseName || jsonData.DailyList.CrownCourt.CourtHouseName; + const courtHouseAddressLines = formatAddress(courtList.CourtHouse?.CourtHouseAddress); + const courtHousePhone = courtList.CourtHouse?.CourtHouseTelephone || ""; + const courtRoom = groupSittingsByCourtRoom(courtList.Sittings); + return { + courtHouse: { + courtHouseName, + courtHouseAddressLines, + courtHousePhone, + courtRoom + } + }; + }) + }; + + return { header, openJustice, listData }; +} + +function formatAddress(address: CrownDailyListData["DailyList"]["CrownCourt"]["CourtHouseAddress"]): string[] { + if (!address) return []; + const parts: string[] = []; + for (const line of address.Line ?? []) { + if (line) parts.push(line); + } + if (address.PostCode) parts.push(address.PostCode); + return parts; +} + +function groupSittingsByCourtRoom(sittings: PddaSitting[]): CrownDailyCourtRoomRendered[] { + const roomMap = new Map(); + for (const sitting of sittings) { + const room = String(sitting.CourtRoomNumber); + const existing = roomMap.get(room); + if (existing) { + existing.push(sitting); + } else { + roomMap.set(room, [sitting]); + } + } + const result: CrownDailyCourtRoomRendered[] = []; + for (const [roomName, roomSittings] of roomMap) { + result.push({ + courtRoomName: roomName, + session: roomSittings.map(renderSession) + }); + } + return result; +} + +function renderSession(sitting: PddaSitting): CrownDailySessionRendered { + const hasListingNotes = (sitting.Hearings ?? []).some((h) => !!h.ListNote); + return { + formattedJudiciaries: formatJudiciary(sitting.Judiciary), + hasListingNotes, + sittings: [renderSitting(sitting)] + }; +} + +function renderSitting(sitting: PddaSitting): CrownDailySittingRendered { + return { + time: formatPddaSittingTime(sitting.SittingAt), + hearing: (sitting.Hearings ?? []).map(renderHearing) + }; +} + +function renderHearing(hearing: PddaHearing): CrownDailyHearingRendered { + return { + displayHearingType: hearing.HearingDetails.HearingDescription || hearing.HearingDetails.HearingType || "", + case: [renderCase(hearing)] + }; +} + +function renderCase(hearing: PddaHearing): CrownDailyCaseRendered { + const defendants = (hearing.Defendants ?? []).map(formatDefendantName).filter(Boolean).join(", "); + return { + caseNumber: hearing.CaseNumberCaTH || hearing.CaseNumber, + prosecutingAuthority: hearing.Prosecution?.ProsecutingAuthority || "", + listingNotes: hearing.ListNote || "", + timeMarkingNote: hearing.TimeMarkingNote || "", + defendants, + representative: "", + formattedReportingRestriction: "" + }; +} + +function formatJudiciary(judiciary: PddaJudiciary): string { + const names: string[] = []; + const judgeName = formatPddaCitizenName(judiciary.Judge).trim(); + if (judgeName) names.push(judgeName); + for (const justice of judiciary.Justice ?? []) { + const justiceName = formatPddaCitizenName(justice).trim(); + if (justiceName) names.push(justiceName); + } + return names.join(", "); +} + +function formatDefendantName(defendant: PddaDefendant): string { + return formatPddaDefendantName(defendant.PersonalDetails); +} diff --git a/libs/list-types/crown-daily-list/src/schemas/crown-daily-list.json b/libs/list-types/crown-daily-list/src/schemas/crown-daily-list.json new file mode 100644 index 000000000..00bad17da --- /dev/null +++ b/libs/list-types/crown-daily-list/src/schemas/crown-daily-list.json @@ -0,0 +1,296 @@ +{ + "$defs": { + "CitizenName": { + "type": "object", + "properties": { + "CitizenNameTitle": { + "type": "string", + "minLength": 1, + "maxLength": 35 + }, + "CitizenNameForename": { + "type": "array", + "items": { "type": "string" } + }, + "CitizenNameSurname": { + "type": "string", + "minLength": 1, + "maxLength": 35 + }, + "CitizenNameSuffix": { + "type": "string", + "minLength": 1, + "maxLength": 35 + }, + "CitizenNameRequestedName": { + "type": "string", + "minLength": 1, + "maxLength": 70 + } + } + }, + "PersonalDetails": { + "type": "object", + "required": ["Name", "IsMasked"], + "properties": { + "Name": { + "$ref": "#/$defs/CitizenName" + }, + "MaskedName": { "type": "string" }, + "IsMasked": { "type": "string", "enum": ["yes", "no"] }, + "DateOfBirth": { + "type": "object", + "properties": { + "BirthDate": { "type": "string", "format": "date" }, + "VerifiedBy": { + "type": "string", + "enum": [ + "not verified", + "accepted on balance of probabilities", + "secondary certificate", + "certified copy of birth certificate", + "short form birth certificate or certificate of registration of birth", + "birth certificate" + ] + } + } + }, + "Age": { "type": "integer" }, + "Sex": { + "type": "string", + "enum": ["unknown", "male", "female", "indeterminate"] + }, + "Address": { + "$ref": "#/$defs/Address" + }, + "Nationality": { "type": "string" } + } + }, + "Address": { + "type": "object", + "properties": { + "Line": { + "type": "array", + "maxItems": 5, + "items": { "type": "string" } + }, + "PostCode": { "type": "string" } + } + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Root", + "type": "object", + "required": ["DailyList"], + "properties": { + "DailyList": { + "type": "object", + "required": ["DocumentID", "ListHeader", "CrownCourt", "CourtLists"], + "properties": { + "DocumentID": { + "type": "object", + "required": ["UniqueID", "DocumentType"], + "properties": { + "UniqueID": { "type": "string" }, + "DocumentType": { "type": "string" } + } + }, + "ListHeader": { + "type": "object", + "required": ["StartDate", "Version", "PublishedTime"], + "properties": { + "StartDate": { "type": "string", "format": "date" }, + "EndDate": { "type": "string", "format": "date" }, + "Version": { "type": "string" }, + "PublishedTime": { "type": "string", "format": "date-time" } + } + }, + "CrownCourt": { + "type": "object", + "required": ["CourtHouseType", "CourtHouseCode", "CourtHouseName"], + "properties": { + "CourtHouseType": { + "type": "string", + "enum": ["Crown Court", "Magistrates Court", "Youth Court"] + }, + "CourtHouseCode": { "type": "integer" }, + "CourtHouseName": { "type": "string" }, + "CourtHouseAddress": { + "$ref": "#/$defs/Address" + }, + "CourtHouseTelephone": { "type": "string" } + } + }, + "CourtLists": { + "type": "array", + "items": { + "type": "object", + "required": ["CourtHouse", "Sittings"], + "properties": { + "CourtHouse": { + "type": "object", + "required": ["CourtHouseType", "CourtHouseCode", "CourtHouseName"], + "properties": { + "CourtHouseType": { + "type": "string", + "enum": ["Crown Court", "Magistrates Court", "Youth Court"] + }, + "CourtHouseCode": { "type": "integer" }, + "CourtHouseShortName": { "type": "string" }, + "CourtHouseName": { "type": "string" }, + "CourtHouseAddress": { + "$ref": "#/$defs/Address" + }, + "CourtHouseTelephone": { "type": "string" } + } + }, + "Sittings": { + "type": "array", + "items": { + "type": "object", + "required": ["CourtRoomNumber", "Judiciary"], + "properties": { + "CourtRoomNumber": { "type": "integer" }, + "SittingAt": { "type": "string", "format": "time" }, + "SittingPriority": { + "type": "string", + "enum": ["T", "F", "R"] + }, + "SittingNote": { "type": "string" }, + "Judiciary": { + "type": "object", + "required": ["Judge"], + "properties": { + "Judge": { + "$ref": "#/$defs/CitizenName", + "required": ["CitizenNameSurname"] + }, + "Justice": { + "type": "array", + "maxItems": 4, + "items": { + "$ref": "#/$defs/CitizenName", + "required": ["CitizenNameSurname"] + } + } + } + }, + "Hearings": { + "type": "array", + "items": { + "type": "object", + "required": ["HearingSequenceNumber", "HearingDetails", "CaseNumber", "CaseNumberCaTH"], + "properties": { + "HearingSequenceNumber": { "type": "integer" }, + "HearingDetails": { + "type": "object", + "required": ["HearingDescription"], + "properties": { + "HearingDescription": { "type": "string" }, + "HearingType": { "type": "string" } + } + }, + "TimeMarkingNote": { "type": "string" }, + "CaseNumber": { "type": "string" }, + "CaseNumberCaTH": { "type": "string" }, + "Prosecution": { + "type": "object", + "properties": { + "ProsecutingReference": { "type": "string" }, + "ProsecutingOrganisation": { + "type": "object", + "required": ["OrganisationName"], + "properties": { + "OrganisationName": { "type": "string" } + } + }, + "ProsecutingAuthority": { + "type": "string", + "enum": [ + "Crown Prosecution Service", + "Customs and Excise", + "Department of Trade and Industry", + "Inland Revenue", + "Other Prosecutor" + ] + }, + "Advocate": { + "type": "object", + "required": ["PersonalDetails"], + "properties": { + "PersonalDetails": { + "description": "Personal details of the advocate", + "$ref": "#/$defs/PersonalDetails" + } + } + } + } + }, + "CommittingCourt": { + "type": "object", + "required": ["CourtHouseType", "CourtHouseCode", "CourtHouseName"], + "properties": { + "CourtHouseType": { + "type": "string", + "enum": ["Crown Court", "Magistrates Court", "Youth Court"] + }, + "CourtHouseCode": { "type": "integer" }, + "CourtHouseShortName": { "type": "string" }, + "CourtHouseName": { "type": "string" } + } + }, + "ListNote": { "type": "string" }, + "Defendants": { + "type": "array", + "items": { + "type": "object", + "required": ["PersonalDetails"], + "properties": { + "PersonalDetails": { + "description": "Personal details of the defendant", + "$ref": "#/$defs/PersonalDetails" + }, + "URN": { "type": "string" }, + "PrisonerID": { "type": "string" }, + "PrisonLocation": { + "type": "object", + "required": ["Location"], + "properties": { + "Location": { + "type": "array", + "items": { "type": "string" } + } + } + }, + "Charges": { + "type": "array", + "items": { + "type": "object", + "required": ["OffenceStatement"], + "properties": { + "OffenceStatement": { "type": "string" }, + "IndictmentCountNumber": { "type": "integer" }, + "CJSoffenceCode": { "type": "string" } + } + } + } + } + } + }, + "Respondent": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/libs/list-types/crown-daily-list/src/validation/json-validator.test.ts b/libs/list-types/crown-daily-list/src/validation/json-validator.test.ts new file mode 100644 index 000000000..c1138d8c3 --- /dev/null +++ b/libs/list-types/crown-daily-list/src/validation/json-validator.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; +import { validateCrownDailyList } from "./json-validator.js"; + +describe("validateCrownDailyList", () => { + it("should validate a correct crown daily list", () => { + const validData = { + DailyList: { + DocumentID: { UniqueID: "CDPL-2025-001", DocumentType: "crown_daily_pdda_list" }, + ListHeader: { + StartDate: "2025-11-12", + PublishedTime: "2025-11-12T09:00:00", + Version: "1.0" + }, + CrownCourt: { + CourtHouseType: "Crown Court", + CourtHouseCode: 1001, + CourtHouseName: "Crown Court at Leeds", + CourtHouseAddress: { + Line: ["1 Oxford Row"], + PostCode: "LS1 3BG" + } + }, + CourtLists: [ + { + CourtHouse: { + CourtHouseType: "Crown Court", + CourtHouseCode: 1001, + CourtHouseName: "Crown Court at Leeds" + }, + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { + Judge: { + CitizenNameForename: ["James"], + CitizenNameSurname: "Smith" + } + } + } + ] + } + ] + } + }; + + const result = validateCrownDailyList(validData); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.schemaVersion).toBe("1.0"); + }); + + it("should return errors for missing required DailyList", () => { + const invalidData = {}; + + const result = validateCrownDailyList(invalidData); + + expect(result.isValid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it("should return errors for missing DocumentID", () => { + const invalidData = { + DailyList: { + ListHeader: {}, + CrownCourt: { CourtHouseName: "Crown Court at Leeds" }, + CourtLists: [] + } + }; + + const result = validateCrownDailyList(invalidData); + + expect(result.isValid).toBe(false); + }); + + it("should return errors for missing CourtHouseName in CrownCourt", () => { + const invalidData = { + DailyList: { + DocumentID: "CDPL-2025-001", + ListHeader: {}, + CrownCourt: {}, + CourtLists: [] + } + }; + + const result = validateCrownDailyList(invalidData); + + expect(result.isValid).toBe(false); + }); + + it("should return errors for missing Judiciary in Sittings", () => { + const invalidData = { + DailyList: { + DocumentID: "CDPL-2025-001", + ListHeader: {}, + CrownCourt: { CourtHouseName: "Crown Court at Leeds" }, + CourtLists: [ + { + Sittings: [ + { + CourtRoomNumber: "Court 1" + } + ] + } + ] + } + }; + + const result = validateCrownDailyList(invalidData); + + expect(result.isValid).toBe(false); + }); + + it("should return errors for missing CourtRoomNumber in Sittings", () => { + const invalidData = { + DailyList: { + DocumentID: "CDPL-2025-001", + ListHeader: {}, + CrownCourt: { CourtHouseName: "Crown Court at Leeds" }, + CourtLists: [ + { + Sittings: [ + { + Judiciary: { Judge: {} } + } + ] + } + ] + } + }; + + const result = validateCrownDailyList(invalidData); + + expect(result.isValid).toBe(false); + }); +}); diff --git a/libs/list-types/crown-daily-list/src/validation/json-validator.ts b/libs/list-types/crown-daily-list/src/validation/json-validator.ts new file mode 100644 index 000000000..210794fff --- /dev/null +++ b/libs/list-types/crown-daily-list/src/validation/json-validator.ts @@ -0,0 +1,6 @@ +import { type ValidationResult, validateJson } from "@hmcts/publication"; +import schema from "../schemas/crown-daily-list.json" with { type: "json" }; + +export function validateCrownDailyList(jsonData: unknown): ValidationResult { + return validateJson(jsonData, schema, "1.0"); +} diff --git a/libs/list-types/crown-daily-list/tsconfig.json b/libs/list-types/crown-daily-list/tsconfig.json new file mode 100644 index 000000000..a029ee937 --- /dev/null +++ b/libs/list-types/crown-daily-list/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules", "src/assets/"] +} diff --git a/libs/list-types/crown-firm-list/package.json b/libs/list-types/crown-firm-list/package.json new file mode 100644 index 000000000..b2a0e9c3c --- /dev/null +++ b/libs/list-types/crown-firm-list/package.json @@ -0,0 +1,43 @@ +{ + "name": "@hmcts/crown-firm-list", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.ts" + } + }, + "scripts": { + "build": "tsc && yarn build:pdf-templates && yarn build:schemas", + "build:pdf-templates": "mkdir -p dist/pdf && cp src/pdf/*.njk dist/pdf/", + "build:schemas": "mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ." + }, + "dependencies": { + "@hmcts/list-types-common": "workspace:*", + "@hmcts/pdf-generation": "workspace:*", + "@hmcts/postgres-prisma": "workspace:*", + "luxon": "3.7.2", + "nunjucks": "3.2.4" + }, + "devDependencies": { + "@types/luxon": "3.7.1", + "@types/node": "24.10.4", + "@types/nunjucks": "3.2.6", + "typescript": "6.0.3", + "vitest": "4.1.8" + }, + "peerDependencies": { + "express": "^5.2.0" + } +} diff --git a/libs/list-types/crown-firm-list/src/config.ts b/libs/list-types/crown-firm-list/src/config.ts new file mode 100644 index 000000000..b4c527259 --- /dev/null +++ b/libs/list-types/crown-firm-list/src/config.ts @@ -0,0 +1,8 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const moduleRoot = __dirname; +export const assets = path.join(__dirname, "../assets/"); diff --git a/libs/list-types/crown-firm-list/src/email-summary/summary-builder.test.ts b/libs/list-types/crown-firm-list/src/email-summary/summary-builder.test.ts new file mode 100644 index 000000000..d8864c217 --- /dev/null +++ b/libs/list-types/crown-firm-list/src/email-summary/summary-builder.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import type { CrownFirmListData } from "../models/types.js"; +import { extractCaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "./summary-builder.js"; + +const testCourtHouse = { CourtHouseName: "Crown Court at Manchester" }; + +const buildTestData = (overrides?: Partial): CrownFirmListData => ({ + FirmList: { + DocumentID: { UniqueID: "CFPL-2025-001", DocumentType: "crown_firm_pdda_list" }, + ListHeader: { StartDate: "2025-01-28", PublishedTime: "2025-01-28T09:00:00", Version: "1.0" }, + CrownCourt: { CourtHouseName: "Crown Court at Manchester" }, + CourtLists: [], + ...overrides + } +}); + +describe("extractCaseSummary", () => { + it("should extract case summaries with defendant name, case number, prosecuting authority and hearing type", () => { + const testData = buildTestData({ + CourtLists: [ + { + SittingDate: "2025-01-28", + CourtHouse: testCourtHouse, + Sittings: [ + { + CourtRoomNumber: 3, + Judiciary: { Judge: {} }, + Hearings: [ + { + HearingDetails: { HearingDescription: "Plea" }, + CaseNumber: "M20250001", + Prosecution: { ProsecutingAuthority: "CPS" }, + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["Jane"], CitizenNameSurname: "Doe" }, + IsMasked: "NO" + } + } + ] + } + ] + } + ] + } + ] + }); + + const result = extractCaseSummary(testData); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual([ + { label: "Defendant Name(s)", value: "Jane Doe" }, + { label: "Prosecuting Authority", value: "CPS" }, + { label: "Case Reference", value: "M20250001" }, + { label: "Hearing Type", value: "Plea" } + ]); + }); + + it("should include defendant field with empty value when no defendants present", () => { + const testData = buildTestData({ + CourtLists: [ + { + SittingDate: "2025-01-28", + CourtHouse: testCourtHouse, + Sittings: [ + { + CourtRoomNumber: 3, + Judiciary: { Judge: {} }, + Hearings: [ + { + HearingDetails: { HearingDescription: "Trial" }, + CaseNumber: "M20250002", + Defendants: [] + } + ] + } + ] + } + ] + }); + + const result = extractCaseSummary(testData); + + expect(result[0].find((f) => f.label === "Defendant Name(s)")?.value).toBe(""); + }); + + it("should return empty array when no court lists", () => { + expect(extractCaseSummary(buildTestData())).toHaveLength(0); + }); +}); + +describe("formatCaseSummaryForEmail", () => { + it("should format summaries for email", () => { + const result = formatCaseSummaryForEmail([ + [ + { label: "Defendant Name(s)", value: "Jane Doe" }, + { label: "Case number", value: "M20250001" } + ] + ]); + + expect(result).toContain("Defendant Name(s) - Jane Doe"); + expect(result).toContain("Case number - M20250001"); + }); + + it("should handle empty list", () => { + expect(formatCaseSummaryForEmail([])).toBe("No cases scheduled."); + }); +}); + +describe("SPECIAL_CATEGORY_DATA_WARNING", () => { + it("should contain required warning text", () => { + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Special Category Data"); + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Data Protection Act 2018"); + }); +}); diff --git a/libs/list-types/crown-firm-list/src/email-summary/summary-builder.ts b/libs/list-types/crown-firm-list/src/email-summary/summary-builder.ts new file mode 100644 index 000000000..6dc408a24 --- /dev/null +++ b/libs/list-types/crown-firm-list/src/email-summary/summary-builder.ts @@ -0,0 +1,8 @@ +import { type CaseSummary, extractPddaSittingsSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "@hmcts/list-types-common"; +import type { CrownFirmListData } from "../models/types.js"; + +export { formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING }; + +export function extractCaseSummary(jsonData: CrownFirmListData): CaseSummary[] { + return extractPddaSittingsSummary(jsonData.FirmList.CourtLists); +} diff --git a/libs/list-types/crown-firm-list/src/index.ts b/libs/list-types/crown-firm-list/src/index.ts new file mode 100644 index 000000000..2c07b3bb3 --- /dev/null +++ b/libs/list-types/crown-firm-list/src/index.ts @@ -0,0 +1,10 @@ +// Business logic exports + +export type { ValidationResult } from "@hmcts/publication"; +export * from "./email-summary/summary-builder.js"; +export { cy as crownFirmListCy } from "./locales/cy.js"; +export { en as crownFirmListEn } from "./locales/en.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export * from "./rendering/renderer.js"; +export { validateCrownFirmList } from "./validation/json-validator.js"; diff --git a/libs/list-types/crown-firm-list/src/locales/cy.ts b/libs/list-types/crown-firm-list/src/locales/cy.ts new file mode 100644 index 000000000..48d050a48 --- /dev/null +++ b/libs/list-types/crown-firm-list/src/locales/cy.ts @@ -0,0 +1,44 @@ +export const cy = { + title: "Rhestr Gadarn y Goron", + pageTitle: "Rhestr Gadarn y Goron ar gyfer", + factLinkText: "Dod o hyd i fanylion cyswllt a gwybodaeth arall am lysoedd a thribiwnlysoedd", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "yng Nghymru a Lloegr, a rhai tribiwnlysoedd heb eu datganoli yn yr Alban.", + listFor: "Rhestr ar gyfer", + lastUpdated: "Diweddarwyd ddiwethaf", + version: "Fersiwn", + publicationDate: "Dyddiad cyhoeddi", + day: "Diwrnod", + court: "LLYS", + courtroom: "Ystafell Llys", + beforeJudge: "Gerbron", + sittingAt: "Yn eistedd am", + hearingTime: "Amser Gwrandawiad", + caseNumber: "Rhif yr Achos", + defendant: "Enw'r Diffynnydd/Diffynyddion", + hearingType: "Math o Wrandawiad", + representative: "Cynrychiolydd", + prosecutingAuthority: "Awdurdod Erlyn", + listingNotes: "Nodiadau Rhestru", + reportingRestrictions: "Cyfyngiad Adrodd", + reportingRestrictionsTitle: "Cyfyngiadau ar gyhoeddi neu ysgrifennu am yr achosion hyn", + reportingRestrictionsBodyIntro: + "Rhaid i chi wirio a oes unrhyw gyfyngiadau adrodd yn berthnasol cyn cyhoeddi manylion am unrhyw un o'r achosion a restrir yma naill ai ar bapur, mewn darllediad neu dros y rhyngrwyd, gan gynnwys cyfryngau cymdeithasol.", + reportingRestrictionsWarning: + "Byddwch yn euog o ddirmyg llys os byddwch yn cyhoeddi unrhyw wybodaeth sydd wedi'i diogelu gan gyfyngiad adrodd. Gallech gael dirwy, dedfryd o garchar neu'r ddau.", + reportingRestrictionsBodySpecific: "Bydd cyfyngiadau penodol a orchmynnwyd gan y llys yn cael eu crybwyll ar yr achosion a restrir yma.", + reportingRestrictionsBodyHowever: + "Fodd bynnag, nid yw cyfyngiadau bob amser yn cael eu rhestru. Mae rhai yn berthnasol yn awtomatig. Er enghraifft, anhysbysrwydd a roddir i ddioddefwyr troseddau rhywiol penodol.", + reportingRestrictionsBodyContact: "I ddarganfod pa gyfyngiadau adrodd sy'n berthnasol i achos penodol, cysylltwch â:", + reportingRestrictionsContactCourt: "y llys yn uniongyrchol", + reportingRestrictionsContactHmcts: "Gwasanaeth Llysoedd a Thribiwnlysoedd Ei Fawrhydi ar 0330 808 4407", + searchCases: "Chwilio achosion", + backToTop: "Yn ôl i frig y dudalen", + courtHouseDetails: "Manylion y Llys", + dataSource: "Ffynhonnell Data", + errorTitle: "Cyhoeddiad ddim ar gael", + errorMessage: + "Ni ellir gweld y cyhoeddiad hwn ar hyn o bryd. Gwiriwch eto yn nes ymlaen. Os yw'r broblem yn parhau, cysylltwch â'r llys yn uniongyrchol am gymorth.", + error403Title: "Mynediad wedi'i Wrthod", + error403Message: "Nid oes gennych ganiatâd i weld y cyhoeddiad hwn." +}; diff --git a/libs/list-types/crown-firm-list/src/locales/en.ts b/libs/list-types/crown-firm-list/src/locales/en.ts new file mode 100644 index 000000000..0ffb374a7 --- /dev/null +++ b/libs/list-types/crown-firm-list/src/locales/en.ts @@ -0,0 +1,44 @@ +export const en = { + title: "Crown Firm List", + pageTitle: "Crown Firm List for", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + listFor: "List for", + lastUpdated: "Last updated", + version: "Version", + publicationDate: "Publication date", + day: "Day", + court: "COURT", + courtroom: "Courtroom", + beforeJudge: "Before", + sittingAt: "Sitting at", + hearingTime: "Hearing Time", + caseNumber: "Case Number", + defendant: "Defendant Name(s)", + hearingType: "Hearing Type", + representative: "Representative", + prosecutingAuthority: "Prosecuting Authority", + listingNotes: "Listing Notes", + reportingRestrictions: "Reporting Restriction", + reportingRestrictionsTitle: "Restrictions on publishing or writing about these cases", + reportingRestrictionsBodyIntro: + "You must check if any reporting restrictions apply before publishing details on any of the cases listed here either in writing, in a broadcast or by internet, including social media.", + reportingRestrictionsWarning: + "You'll be in contempt of court if you publish any information which is protected by a reporting restriction. You could get a fine, prison sentence or both.", + reportingRestrictionsBodySpecific: "Specific restrictions ordered by the court will be mentioned on the cases listed here.", + reportingRestrictionsBodyHowever: + "However, restrictions are not always listed. Some apply automatically. For example, anonymity given to the victims of certain sexual offences.", + reportingRestrictionsBodyContact: "To find out which reporting restrictions apply on a specific case, contact:", + reportingRestrictionsContactCourt: "the court directly", + reportingRestrictionsContactHmcts: "HM Courts and Tribunals Service on 0330 808 4407", + searchCases: "Search Cases", + backToTop: "Back to top", + courtHouseDetails: "Court House Details", + dataSource: "Data Source", + errorTitle: "Publication not available", + errorMessage: + "This publication cannot be viewed at the moment. Please check again later. If the problem persists, contact the court directly for assistance.", + error403Title: "Access Denied", + error403Message: "You do not have permission to view this publication." +}; diff --git a/libs/list-types/crown-firm-list/src/models/types.ts b/libs/list-types/crown-firm-list/src/models/types.ts new file mode 100644 index 000000000..ef4288d2f --- /dev/null +++ b/libs/list-types/crown-firm-list/src/models/types.ts @@ -0,0 +1,148 @@ +export interface CitizenName { + CitizenNameTitle?: string; + CitizenNameForename?: string[]; + CitizenNameSurname?: string; + CitizenNameRequestedName?: string; + CitizenNameSuffix?: string; +} + +export interface PersonalDetails { + Name: CitizenName; + MaskedName?: string; + IsMasked: "YES" | "NO"; + DateOfBirth?: string; + Age?: number; + Sex?: string; +} + +export interface SolicitorParty { + Person?: { + PersonalDetails?: { + Name: CitizenName; + MaskedName?: string; + IsMasked?: "YES" | "NO"; + }; + }; + Organisation?: { + OrganisationName?: string; + }; +} + +export interface PddaCounsel { + Solicitor?: Array<{ + Party?: SolicitorParty; + }>; +} + +export interface PddaDefendant { + PersonalDetails: PersonalDetails; + Counsel?: PddaCounsel[]; + URN?: string; + PrisonerID?: string; +} + +export interface PddaHearingDetails { + HearingDescription?: string; + HearingType?: string; +} + +export interface PddaHearing { + HearingSequenceNumber?: number; + HearingDetails: PddaHearingDetails; + CaseNumber: string; + CaseNumberCaTH?: string; + TimeMarkingNote?: string; + ListNote?: string; + Prosecution?: { + ProsecutingReference?: string; + ProsecutingOrganisation?: { OrganisationName?: string }; + ProsecutingAuthority?: string; + }; + Defendants?: PddaDefendant[]; +} + +export interface PddaJudiciary { + Judge: CitizenName; + Justice?: CitizenName[]; +} + +export interface PddaSitting { + CourtRoomNumber: number; + SittingAt?: string; + Judiciary: PddaJudiciary; + Hearings?: PddaHearing[]; +} + +export interface PddaAddress { + Line?: string[]; + PostCode?: string; +} + +export interface PddaCourtHouse { + CourtHouseName: string; + CourtHouseType?: string; + CourtHouseCode?: number; + CourtHouseAddress?: PddaAddress; + CourtHouseTelephone?: string; +} + +export interface PddaListHeader { + StartDate?: string; + EndDate?: string; + Version?: string; + PublishedTime?: string; +} + +export interface CrownFirmListData { + FirmList: { + DocumentID: { UniqueID: string; DocumentType: string }; + ListHeader: PddaListHeader; + CrownCourt: PddaCourtHouse; + ReserveList?: unknown[]; + CourtLists: Array<{ + SittingDate: string; + CourtHouse: PddaCourtHouse; + Sittings: PddaSitting[]; + }>; + }; +} + +export interface RenderOptions { + locationId: string; + contentDate: Date; + locale: string; +} + +export interface CrownFirmCaseRendered { + caseNumber: string; + timeMarkingNote: string; + prosecutingAuthority: string; + listingNotes: string; + defendants: string; + representative: string; + formattedReportingRestriction: string; +} + +export interface CrownFirmHearingRendered { + displayHearingType: string; + case: CrownFirmCaseRendered[]; +} + +export interface CrownFirmDaySitting { + courtRoomName: string; + formattedJudiciaries: string; + time: string; + hearing: CrownFirmHearingRendered[]; +} + +export interface CrownFirmCourtHouseInfo { + name: string; + addressLines: string[]; + phone: string; +} + +export interface CrownFirmGroupedDay { + day: string; + courtHouseInfo: CrownFirmCourtHouseInfo; + sittings: CrownFirmDaySitting[]; +} diff --git a/libs/list-types/crown-firm-list/src/pdf/pdf-generator.test.ts b/libs/list-types/crown-firm-list/src/pdf/pdf-generator.test.ts new file mode 100644 index 000000000..757cfa11c --- /dev/null +++ b/libs/list-types/crown-firm-list/src/pdf/pdf-generator.test.ts @@ -0,0 +1,352 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockSavePdfToStorage, mockCreatePdfErrorResult, mockConfigureNunjucks, mockLoadTranslations } = vi.hoisted(() => ({ + mockSavePdfToStorage: vi.fn(), + mockCreatePdfErrorResult: vi.fn(), + mockConfigureNunjucks: vi.fn(), + mockLoadTranslations: vi.fn() +})); + +vi.mock("@hmcts/list-types-common", () => ({ + savePdfToStorage: mockSavePdfToStorage, + createPdfErrorResult: mockCreatePdfErrorResult, + configureNunjucks: mockConfigureNunjucks, + loadTranslations: mockLoadTranslations, + PDF_BASE_STYLES: "/* base styles */", + PDF_CIVIL_FAMILY_STYLES: "/* civil family styles */" +})); + +vi.mock("@hmcts/pdf-generation", () => ({ + generatePdfFromHtml: vi.fn() +})); + +vi.mock("../rendering/renderer.js", () => ({ + renderCrownFirmListData: vi.fn() +})); + +vi.mock("@hmcts/publication", () => ({ + PROVENANCE_LABELS: { + MANUAL_UPLOAD: "Manual Upload", + SNL: "SNL" + } +})); + +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { renderCrownFirmListData } from "../rendering/renderer.js"; +import { generateCrownFirmListPdf } from "./pdf-generator.js"; + +const mockRenderedData = { + header: { + locationName: "Crown Court at Manchester", + addressLines: ["Crown Square", "M3 3FL"], + contentDate: "12 November 2025", + lastUpdated: "12 November 2025 at 9am", + version: "1.0" + }, + openJustice: { + venueName: "Crown Court at Manchester", + email: "", + phone: "0161 954 1800" + }, + listData: null, + groupedListData: [] +}; + +const mockJsonData = { + FirmList: { + DocumentID: { UniqueID: "CFL-2025-001", DocumentType: "crown_firm_pdda_list" }, + ListHeader: { StartDate: "2025-11-12", PublishedTime: "2025-11-12T09:00:00", Version: "1.0" }, + CrownCourt: { CourtHouseName: "Crown Court at Manchester" }, + CourtLists: [] + } +}; + +describe("generateCrownFirmListPdf", () => { + const mockNunjucksEnv = { + render: vi.fn().mockReturnValue("PDF HTML") + }; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(renderCrownFirmListData).mockResolvedValue(mockRenderedData); + mockConfigureNunjucks.mockReturnValue(mockNunjucksEnv); + mockLoadTranslations.mockResolvedValue({ pageTitle: "Crown Firm List" }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "/storage/temp/uploads/test.pdf", + sizeBytes: 1024, + exceedsMaxSize: false + }); + mockCreatePdfErrorResult.mockImplementation((error: unknown) => ({ + success: false, + error: `Failed to generate PDF: ${error instanceof Error ? error.message : "Unknown error"}` + })); + }); + + it("should generate PDF successfully", async () => { + const pdfBuffer = Buffer.from("PDF content"); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer, + sizeBytes: 1024 + }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "/storage/temp/uploads/firm-artefact-123.pdf", + sizeBytes: 1024, + exceedsMaxSize: false + }); + + const result = await generateCrownFirmListPdf({ + artefactId: "firm-artefact-123", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(true); + expect(result.pdfPath).toContain("firm-artefact-123.pdf"); + expect(mockSavePdfToStorage).toHaveBeenCalledWith("firm-artefact-123", pdfBuffer, 1024); + }); + + it("should pass groupedListData to template", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + await generateCrownFirmListPdf({ + artefactId: "test", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(mockNunjucksEnv.render).toHaveBeenCalledWith("pdf-template.njk", expect.objectContaining({ groupedListData: [] })); + }); + + it("should return error when PDF generation fails", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: false, + error: "Puppeteer crashed" + }); + + const result = await generateCrownFirmListPdf({ + artefactId: "failed-pdf", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Puppeteer crashed"); + }); + + it("should return default error when PDF generation fails without error message", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ success: false }); + + const result = await generateCrownFirmListPdf({ + artefactId: "failed-pdf", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("PDF generation failed"); + }); + + it("should return error when PDF buffer is missing", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: undefined, + sizeBytes: 0 + }); + + const result = await generateCrownFirmListPdf({ + artefactId: "no-buffer", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("PDF generation failed"); + }); + + it("should handle renderer errors gracefully", async () => { + vi.mocked(renderCrownFirmListData).mockRejectedValue(new Error("Renderer failed")); + + const result = await generateCrownFirmListPdf({ + artefactId: "renderer-error", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Failed to generate PDF: Renderer failed"); + }); + + it("should handle non-Error exceptions", async () => { + vi.mocked(renderCrownFirmListData).mockRejectedValue("String error"); + + const result = await generateCrownFirmListPdf({ + artefactId: "string-error", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Failed to generate PDF: Unknown error"); + }); + + it("should handle file system errors", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + mockSavePdfToStorage.mockRejectedValue(new Error("Disk full")); + + const result = await generateCrownFirmListPdf({ + artefactId: "fs-error", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Failed to generate PDF: Disk full"); + }); + + it("should pass correct options to renderer", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + const contentDate = new Date("2025-11-12"); + + await generateCrownFirmListPdf({ + artefactId: "test-options", + contentDate, + locale: "cy", + locationId: "999", + jsonData: mockJsonData as any + }); + + expect(renderCrownFirmListData).toHaveBeenCalledWith(mockJsonData, { + contentDate, + locale: "cy", + locationId: "999" + }); + }); + + it("should use MANUAL_UPLOAD provenance label", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + await generateCrownFirmListPdf({ + artefactId: "provenance-test", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any, + provenance: "MANUAL_UPLOAD" + }); + + expect(mockNunjucksEnv.render).toHaveBeenCalledWith("pdf-template.njk", expect.objectContaining({ dataSource: "Manual Upload" })); + }); + + it("should use SNL provenance label", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + await generateCrownFirmListPdf({ + artefactId: "provenance-snl", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any, + provenance: "SNL" + }); + + expect(mockNunjucksEnv.render).toHaveBeenCalledWith("pdf-template.njk", expect.objectContaining({ dataSource: "SNL" })); + }); + + it("should use raw provenance value when label not found", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + await generateCrownFirmListPdf({ + artefactId: "unknown-provenance", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any, + provenance: "UNKNOWN_SOURCE" + }); + + expect(mockNunjucksEnv.render).toHaveBeenCalledWith("pdf-template.njk", expect.objectContaining({ dataSource: "UNKNOWN_SOURCE" })); + }); + + it("should handle missing provenance", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + const result = await generateCrownFirmListPdf({ + artefactId: "no-provenance", + contentDate: new Date("2025-11-12"), + locale: "en", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(true); + expect(mockNunjucksEnv.render).toHaveBeenCalledWith("pdf-template.njk", expect.objectContaining({ dataSource: "" })); + }); + + it("should generate PDF for Welsh locale", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + const result = await generateCrownFirmListPdf({ + artefactId: "welsh-pdf", + contentDate: new Date("2025-11-12"), + locale: "cy", + locationId: "101", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(true); + expect(renderCrownFirmListData).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ locale: "cy" })); + }); +}); diff --git a/libs/list-types/crown-firm-list/src/pdf/pdf-generator.ts b/libs/list-types/crown-firm-list/src/pdf/pdf-generator.ts new file mode 100644 index 000000000..76f333a5b --- /dev/null +++ b/libs/list-types/crown-firm-list/src/pdf/pdf-generator.ts @@ -0,0 +1,66 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + type BasePdfGenerationOptions, + configureNunjucks, + createPdfErrorResult, + loadTranslations, + PDF_BASE_STYLES, + PDF_CIVIL_FAMILY_STYLES, + type PdfGenerationResult, + savePdfToStorage +} from "@hmcts/list-types-common"; +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { PROVENANCE_LABELS } from "@hmcts/publication"; +import type { CrownFirmListData, RenderOptions } from "../models/types.js"; +import { renderCrownFirmListData } from "../rendering/renderer.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface PdfGenerationOptions extends BasePdfGenerationOptions { + contentDate: Date; +} + +export async function generateCrownFirmListPdf(options: PdfGenerationOptions): Promise { + try { + const renderOptions: RenderOptions = { + contentDate: options.contentDate, + locale: options.locale, + locationId: options.locationId + }; + + const renderedData = await renderCrownFirmListData(options.jsonData, renderOptions); + + const translations = await loadTranslations( + options.locale, + () => import("../locales/en.js"), + () => import("../locales/cy.js") + ); + + const provenanceLabel = options.provenance ? PROVENANCE_LABELS[options.provenance as keyof typeof PROVENANCE_LABELS] || options.provenance : ""; + + const env = configureNunjucks(__dirname); + const html = env.render("pdf-template.njk", { + header: renderedData.header, + openJustice: renderedData.openJustice, + groupedListData: renderedData.groupedListData, + dataSource: provenanceLabel, + t: translations, + pdfStyles: PDF_BASE_STYLES + PDF_CIVIL_FAMILY_STYLES + }); + + const pdfResult = await generatePdfFromHtml(html); + + if (!pdfResult.success || !pdfResult.pdfBuffer) { + return { + success: false, + error: pdfResult.error || "PDF generation failed" + }; + } + + return await savePdfToStorage(options.artefactId, pdfResult.pdfBuffer, pdfResult.sizeBytes!); + } catch (error) { + return createPdfErrorResult(error); + } +} diff --git a/libs/list-types/crown-firm-list/src/pdf/pdf-template.njk b/libs/list-types/crown-firm-list/src/pdf/pdf-template.njk new file mode 100644 index 000000000..788022a30 --- /dev/null +++ b/libs/list-types/crown-firm-list/src/pdf/pdf-template.njk @@ -0,0 +1,139 @@ + + + + + + {{ t.pageTitle }} {{ header.locationName }} + + + + +
+

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

+ +

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

+ +

{{ t.listFor }} {{ header.contentDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdated }}

+ + {% if header.version | length %} +

{{ t.version }} {{ header.version }}

+ {% endif %} + +
+ {% for line in header.addressLines %} +

{{ line }}

+ {% endfor %} +
+
+ +
+

{{ t.reportingRestrictionsTitle }}

+

{{ t.reportingRestrictionsBodyIntro }}

+
+ + {{ t.reportingRestrictionsWarning }} +
+

{{ t.reportingRestrictionsBodySpecific }}

+

{{ t.reportingRestrictionsBodyHowever }}

+

{{ t.reportingRestrictionsBodyContact }}

+
    +
  • {{ t.reportingRestrictionsContactCourt }}
  • +
  • {{ t.reportingRestrictionsContactHmcts }}
  • +
+
+ + {% for dayGroup in groupedListData %} +
+
{{ dayGroup.day }}
+ +
+

{{ dayGroup.courtHouseInfo.name }}

+ {% for line in dayGroup.courtHouseInfo.addressLines %} +

{{ line }}

+ {% endfor %} + {% if dayGroup.courtHouseInfo.phone | length %} +

{{ dayGroup.courtHouseInfo.phone }}

+ {% endif %} +
+ + {% for sitting in dayGroup.sittings %} +
+
+ {% if sitting.formattedJudiciaries | length %} + {{ t.courtroom }} {{ sitting.courtRoomName }}: {{ sitting.formattedJudiciaries }} + {% else %} + {{ t.courtroom }} {{ sitting.courtRoomName }} + {% endif %} +
+ +

{{ t.sittingAt }} {{ sitting.time }}

+ + + + + + + + + + + + + + + {% for hearing in sitting.hearing %} + {% for case in hearing.case %} + + + + + + + + + + {% if case.formattedReportingRestriction | length %} + + + + {% endif %} + {% endfor %} + {% endfor %} + +
{{ t.hearingTime }}{{ t.caseNumber }}{{ t.defendant }}{{ t.hearingType }}{{ t.representative }}{{ t.prosecutingAuthority }}{{ t.listingNotes }}
{{ case.timeMarkingNote }}{{ case.caseNumber }}{{ case.defendants }}{{ hearing.displayHearingType }}{{ case.representative }}{{ case.prosecutingAuthority }}{{ case.listingNotes }}
+ {{ t.reportingRestrictions }}: {{ case.formattedReportingRestriction }} +
+
+ {% endfor %} +
+ {% endfor %} + + + + diff --git a/libs/list-types/crown-firm-list/src/rendering/renderer.test.ts b/libs/list-types/crown-firm-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..cbea5b5f4 --- /dev/null +++ b/libs/list-types/crown-firm-list/src/rendering/renderer.test.ts @@ -0,0 +1,868 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CrownFirmListData } from "../models/types.js"; +import { renderCrownFirmListData } from "./renderer.js"; + +vi.mock("@hmcts/location", () => ({ + getLocationById: vi.fn() +})); + +import { getLocationById } from "@hmcts/location"; + +const testCourtHouse = { + CourtHouseName: "Test Court House", + CourtHouseAddress: { Line: ["1 Test Street"], PostCode: "TE1 1ST" }, + CourtHouseTelephone: "01234567890" +}; + +const baseInput: CrownFirmListData = { + FirmList: { + DocumentID: { UniqueID: "CFPL-2025-001", DocumentType: "crown_firm_pdda_list" }, + ListHeader: { + StartDate: "2025-11-12", + PublishedTime: "2025-11-12T09:00:00", + Version: "1.0" + }, + CrownCourt: { + CourtHouseName: "Crown Court at Manchester", + CourtHouseTelephone: "0161 954 1800", + CourtHouseAddress: { + Line: ["Crown Square"], + PostCode: "M3 3FL" + } + }, + CourtLists: [] + } +}; + +describe("renderCrownFirmListData", () => { + beforeEach(() => { + vi.clearAllMocks(); + (getLocationById as ReturnType).mockResolvedValue(undefined); + }); + + it("should render header with location name from CrownCourt", async () => { + const result = await renderCrownFirmListData(baseInput, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + expect(result.header.locationName).toBe("Crown Court at Manchester"); + expect(result.header.addressLines).toEqual(["Crown Square", "M3 3FL"]); + expect(result.header.contentDate).toBe("12 November 2025"); + }); + + it("should format date range when EndDate is present", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + ListHeader: { ...baseInput.FirmList.ListHeader, StartDate: "2025-09-10", EndDate: "2025-09-11" } + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-09-10"), + locale: "en" + }); + + expect(result.header.contentDate).toBe("10 September 2025 to 11 September 2025"); + }); + + it("should group sittings by SittingDate into groupedListData with courtHouseInfo", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + CourtLists: [ + { + SittingDate: "2025-04-22", + CourtHouse: testCourtHouse, + Sittings: [ + { + CourtRoomNumber: 3, + SittingAt: "10:00:00", + Judiciary: { + Judge: { CitizenNameTitle: "HHJ", CitizenNameForename: [], CitizenNameSurname: "Brown" } + }, + Hearings: [ + { + HearingDetails: { HearingDescription: "Sentence" }, + CaseNumber: "M20250001", + Defendants: [] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + expect(result.groupedListData).toHaveLength(1); + expect(result.groupedListData[0].day).toBe("Tuesday 22 April 2025"); + expect(result.groupedListData[0].courtHouseInfo.name).toBe("Test Court House"); + expect(result.groupedListData[0].courtHouseInfo.addressLines).toEqual(["1 Test Street", "TE1 1ST"]); + expect(result.groupedListData[0].sittings).toHaveLength(1); + expect(result.groupedListData[0].sittings[0].courtRoomName).toBe("3"); + }); + + it("should format sitting time from SittingAt HH:MM:SS", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + CourtLists: [ + { + SittingDate: "2025-04-22", + CourtHouse: testCourtHouse, + Sittings: [ + { + CourtRoomNumber: 1, + SittingAt: "09:30:00", + Judiciary: { Judge: {} }, + Hearings: [] + } + ] + } + ] + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + expect(result.groupedListData[0].sittings[0].time).toBe("9:30am"); + }); + + it("should map TimeMarkingNote and ListNote to timeMarkingNote and listingNotes", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + CourtLists: [ + { + SittingDate: "2025-04-22", + CourtHouse: testCourtHouse, + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { Judge: {} }, + Hearings: [ + { + HearingDetails: { HearingDescription: "Trial" }, + CaseNumber: "M20250010", + TimeMarkingNote: "10am", + ListNote: "After lunch", + Defendants: [] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + const caseItem = result.groupedListData[0].sittings[0].hearing[0].case[0]; + expect(caseItem.timeMarkingNote).toBe("10am"); + expect(caseItem.listingNotes).toBe("After lunch"); + }); + + it("should format defendants from PersonalDetails", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + CourtLists: [ + { + SittingDate: "2025-04-22", + CourtHouse: testCourtHouse, + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { Judge: {} }, + Hearings: [ + { + HearingDetails: { HearingDescription: "Trial" }, + CaseNumber: "M20250005", + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["Bob"], CitizenNameSurname: "Green" }, + IsMasked: "NO" + } + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + const caseItem = result.groupedListData[0].sittings[0].hearing[0].case[0]; + expect(caseItem.defendants).toBe("Bob Green"); + }); + + it("should extract representative from Counsel Solicitor Party", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + CourtLists: [ + { + SittingDate: "2025-04-22", + CourtHouse: testCourtHouse, + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { Judge: {} }, + Hearings: [ + { + HearingDetails: { HearingDescription: "Sentence" }, + CaseNumber: "M20250006", + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["Alice"], CitizenNameSurname: "Smith" }, + IsMasked: "NO" + }, + Counsel: [ + { + Solicitor: [ + { + Party: { + Organisation: { OrganisationName: "Smith & Co Solicitors" } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + const caseItem = result.groupedListData[0].sittings[0].hearing[0].case[0]; + expect(caseItem.representative).toBe("Smith & Co Solicitors"); + }); + + it("should use Welsh locale for content date", async () => { + const result = await renderCrownFirmListData(baseInput, { + locationId: "101", + contentDate: new Date("2025-01-15"), + locale: "cy" + }); + + expect(result.header.contentDate).toContain("Tachwedd"); + }); + + it("should use Welsh dateSeparator 'i' when locale is cy and EndDate is present", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + ListHeader: { ...baseInput.FirmList.ListHeader, StartDate: "2025-09-10", EndDate: "2025-09-11" } + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-09-10"), + locale: "cy" + }); + + expect(result.header.contentDate).toContain(" i "); + }); + + it("should use location name from getLocationById when available", async () => { + (getLocationById as ReturnType).mockResolvedValue({ id: 101, name: "Manchester Crown Court", welshName: null }); + + const result = await renderCrownFirmListData(baseInput, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + expect(result.header.locationName).toBe("Manchester Crown Court"); + }); + + it("should use Welsh location name when locale is cy and welshName is available", async () => { + (getLocationById as ReturnType).mockResolvedValue({ id: 101, name: "Manchester Crown Court", welshName: "Llys y Goron Manceinion" }); + + const result = await renderCrownFirmListData(baseInput, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "cy" + }); + + expect(result.header.locationName).toBe("Llys y Goron Manceinion"); + }); + + it("should return empty lastUpdated when PublishedTime is absent", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + ListHeader: { StartDate: "2025-03-15", Version: "1.0" } + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + expect(result.header.lastUpdated).toBe(""); + }); + + it("should handle court with no address", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + CrownCourt: { CourtHouseName: "No Address Court" } + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + expect(result.header.addressLines).toEqual([]); + }); + + it("should include judiciary with Justice array", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + CourtLists: [ + { + SittingDate: "2025-04-22", + CourtHouse: testCourtHouse, + Sittings: [ + { + CourtRoomNumber: 1, + SittingAt: "10:00:00", + Judiciary: { + Judge: { CitizenNameTitle: "HHJ", CitizenNameForename: [], CitizenNameSurname: "Brown" }, + Justice: [{ CitizenNameTitle: "Mr", CitizenNameForename: ["John"], CitizenNameSurname: "Doe" }] + }, + Hearings: [] + } + ] + } + ] + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + expect(result.groupedListData[0].sittings[0].formattedJudiciaries).toContain("Brown"); + expect(result.groupedListData[0].sittings[0].formattedJudiciaries).toContain("John Doe"); + }); + + it("should extract representative from Counsel Solicitor Person", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + CourtLists: [ + { + SittingDate: "2025-04-22", + CourtHouse: testCourtHouse, + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { Judge: {} }, + Hearings: [ + { + HearingDetails: { HearingDescription: "Sentence" }, + CaseNumber: "M20250099", + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["Alice"], CitizenNameSurname: "Smith" }, + IsMasked: "NO" + }, + Counsel: [ + { + Solicitor: [ + { + Party: { + Person: { + PersonalDetails: { + Name: { + CitizenNameForename: ["Jane"], + CitizenNameSurname: "Counsel" + }, + IsMasked: "NO" + } + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + const caseItem = result.groupedListData[0].sittings[0].hearing[0].case[0]; + expect(caseItem.representative).toContain("Jane Counsel"); + }); + + it("should use HearingType when HearingDescription is absent", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + CourtLists: [ + { + SittingDate: "2025-04-22", + CourtHouse: testCourtHouse, + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { Judge: {} }, + Hearings: [ + { + HearingDetails: { HearingType: "PCM" }, + CaseNumber: "M20250098", + Defendants: [] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + expect(result.groupedListData[0].sittings[0].hearing[0].displayHearingType).toBe("PCM"); + }); + + it("should handle sitting at undefined time", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + CourtLists: [ + { + SittingDate: "2025-04-22", + CourtHouse: testCourtHouse, + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { Judge: {} }, + Hearings: [] + } + ] + } + ] + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + expect(result.groupedListData[0].sittings[0].time).toBe(""); + }); + + it("should accumulate sittings from same day across court lists", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + CourtLists: [ + { + SittingDate: "2025-04-22", + CourtHouse: testCourtHouse, + Sittings: [ + { + CourtRoomNumber: 1, + SittingAt: "09:00:00", + Judiciary: { Judge: {} }, + Hearings: [] + } + ] + }, + { + SittingDate: "2025-04-22", + CourtHouse: testCourtHouse, + Sittings: [ + { + CourtRoomNumber: 2, + SittingAt: "10:00:00", + Judiciary: { Judge: {} }, + Hearings: [] + } + ] + } + ] + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + expect(result.groupedListData).toHaveLength(1); + expect(result.groupedListData[0].sittings).toHaveLength(2); + }); + + it("should use MaskedName when IsMasked is yes", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + CourtLists: [ + { + SittingDate: "2025-04-22", + CourtHouse: testCourtHouse, + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { Judge: {} }, + Hearings: [ + { + HearingDetails: { HearingDescription: "Plea" }, + CaseNumber: "M20250007", + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["Real"], CitizenNameSurname: "Name" }, + MaskedName: "Reporting Restriction Applied", + IsMasked: "YES" + } + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + const caseItem = result.groupedListData[0].sittings[0].hearing[0].case[0]; + expect(caseItem.defendants).toBe("Reporting Restriction Applied"); + }); + + it("should return empty displayHearingType when both HearingDescription and HearingType are absent", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + CourtLists: [ + { + SittingDate: "2025-04-22", + CourtHouse: testCourtHouse, + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { Judge: {} }, + Hearings: [ + { + HearingDetails: {}, + CaseNumber: "M20250097", + Defendants: [] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + expect(result.groupedListData[0].sittings[0].hearing[0].displayHearingType).toBe(""); + }); + + it("should skip representative when solicitor has no Party Organisation or Person", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + CourtLists: [ + { + SittingDate: "2025-04-22", + CourtHouse: testCourtHouse, + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { Judge: {} }, + Hearings: [ + { + HearingDetails: { HearingDescription: "Plea" }, + CaseNumber: "M20250096", + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["A"], CitizenNameSurname: "B" }, + IsMasked: "NO" + }, + Counsel: [ + { + Solicitor: [ + { + Party: {} + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + const caseItem = result.groupedListData[0].sittings[0].hearing[0].case[0]; + expect(caseItem.representative).toBe(""); + }); + + it("should skip representative when solicitor Person has no name parts", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + CourtLists: [ + { + SittingDate: "2025-04-22", + CourtHouse: testCourtHouse, + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { Judge: {} }, + Hearings: [ + { + HearingDetails: { HearingDescription: "Plea" }, + CaseNumber: "M20250095", + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["A"], CitizenNameSurname: "B" }, + IsMasked: "NO" + }, + Counsel: [ + { + Solicitor: [ + { + Party: { + Person: { + PersonalDetails: { + Name: {}, + IsMasked: "NO" + } + } + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + const caseItem = result.groupedListData[0].sittings[0].hearing[0].case[0]; + expect(caseItem.representative).toBe(""); + }); + + it("should use CitizenNameRequestedName for judge when present", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + CourtLists: [ + { + SittingDate: "2025-04-22", + CourtHouse: testCourtHouse, + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { + Judge: { + CitizenNameTitle: "Mr", + CitizenNameForename: ["TestForename"], + CitizenNameSurname: "TestSurname", + CitizenNameRequestedName: "TestRequestedName" + } + }, + Hearings: [ + { + HearingDetails: { HearingDescription: "Trial" }, + CaseNumber: "M20250008", + Defendants: [] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + expect(result.groupedListData[0].sittings[0].formattedJudiciaries).toBe("TestRequestedName"); + }); + + it("should use CitizenNameRequestedName for defendant when present", async () => { + const input: CrownFirmListData = { + ...baseInput, + FirmList: { + ...baseInput.FirmList, + CourtLists: [ + { + SittingDate: "2025-04-22", + CourtHouse: testCourtHouse, + Sittings: [ + { + CourtRoomNumber: 1, + Judiciary: { Judge: {} }, + Hearings: [ + { + HearingDetails: { HearingDescription: "Trial" }, + CaseNumber: "M20250009", + Defendants: [ + { + PersonalDetails: { + Name: { + CitizenNameTitle: "Ms", + CitizenNameForename: ["RealForename"], + CitizenNameSurname: "RealSurname", + CitizenNameRequestedName: "RequestedName" + }, + IsMasked: "NO" + } + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownFirmListData(input, { + locationId: "101", + contentDate: new Date("2025-03-15"), + locale: "en" + }); + + const caseItem = result.groupedListData[0].sittings[0].hearing[0].case[0]; + expect(caseItem.defendants).toBe("RequestedName"); + }); +}); diff --git a/libs/list-types/crown-firm-list/src/rendering/renderer.ts b/libs/list-types/crown-firm-list/src/rendering/renderer.ts new file mode 100644 index 000000000..b63ffd086 --- /dev/null +++ b/libs/list-types/crown-firm-list/src/rendering/renderer.ts @@ -0,0 +1,161 @@ +import { formatContentDate, formatCrownLastUpdated, formatPddaCitizenName, formatPddaDefendantName, formatPddaSittingTime } from "@hmcts/list-types-common"; +import { getLocationById } from "@hmcts/location"; +import { DateTime } from "luxon"; +import type { + CrownFirmCaseRendered, + CrownFirmDaySitting, + CrownFirmGroupedDay, + CrownFirmHearingRendered, + CrownFirmListData, + PddaCourtHouse, + PddaDefendant, + PddaJudiciary, + RenderOptions +} from "../models/types.js"; + +export async function renderCrownFirmListData(jsonData: CrownFirmListData, options: RenderOptions) { + const { FirmList } = jsonData; + const location = await getLocationById(Number.parseInt(options.locationId, 10)); + const locationName = options.locale === "cy" && location?.welshName ? location.welshName : location?.name || FirmList.CrownCourt.CourtHouseName; + + const publishedTime = FirmList.ListHeader.PublishedTime; + const lastUpdated = publishedTime ? formatCrownLastUpdated(publishedTime, options.locale) : ""; + + const startDate = FirmList.ListHeader.StartDate; + const endDate = FirmList.ListHeader.EndDate; + + const formattedStart = startDate ? formatContentDate(new Date(startDate), options.locale) : formatContentDate(options.contentDate, options.locale); + const formattedEnd = endDate ? formatContentDate(new Date(endDate), options.locale) : ""; + const dateSeparator = options.locale === "cy" ? "i" : "to"; + const contentDate = formattedEnd ? `${formattedStart} ${dateSeparator} ${formattedEnd}` : formattedStart; + + const header = { + locationName, + addressLines: formatAddress(FirmList.CrownCourt), + contentDate, + lastUpdated, + version: FirmList.ListHeader.Version || "" + }; + + const openJustice = { + venueName: FirmList.CrownCourt.CourtHouseName, + email: "", + phone: FirmList.CrownCourt.CourtHouseTelephone || "" + }; + + const groupedListData = buildGroupedListData(jsonData, options.locale); + + return { header, openJustice, listData: null, groupedListData }; +} + +function formatAddress(court: CrownFirmListData["FirmList"]["CrownCourt"]): string[] { + const parts: string[] = []; + const addr = court.CourtHouseAddress; + if (!addr) return parts; + for (const line of addr.Line ?? []) { + if (line) parts.push(line); + } + if (addr.PostCode) parts.push(addr.PostCode); + return parts; +} + +function formatSittingDate(dateStr: string, locale: string): string { + const localeCode = locale === "cy" ? "cy-GB" : "en-GB"; + const dt = DateTime.fromISO(dateStr); + if (!dt.isValid) return dateStr; + const weekday = dt.toJSDate().toLocaleDateString(localeCode, { weekday: "long" }); + const date = dt.toJSDate().toLocaleDateString(localeCode, { day: "numeric", month: "long", year: "numeric" }); + return `${weekday} ${date}`; +} + +function formatCourtHouseInfo(courtHouse: PddaCourtHouse) { + const addressLines: string[] = []; + for (const line of courtHouse.CourtHouseAddress?.Line ?? []) { + if (line) addressLines.push(line); + } + if (courtHouse.CourtHouseAddress?.PostCode) { + addressLines.push(courtHouse.CourtHouseAddress.PostCode); + } + return { + name: courtHouse.CourtHouseName, + addressLines, + phone: courtHouse.CourtHouseTelephone || "" + }; +} + +function formatJudiciary(judiciary: PddaJudiciary): string { + const names: string[] = []; + const judge = formatPddaCitizenName(judiciary.Judge).trim(); + if (judge) names.push(judge); + for (const justice of judiciary.Justice ?? []) { + const name = formatPddaCitizenName(justice).trim(); + if (name) names.push(name); + } + return names.join(", "); +} + +function formatDefendantName(defendant: PddaDefendant): string { + return formatPddaDefendantName(defendant.PersonalDetails); +} + +function extractRepresentative(defendants: PddaDefendant[]): string { + const reps: string[] = []; + for (const defendant of defendants) { + for (const counsel of defendant.Counsel ?? []) { + for (const solicitor of counsel.Solicitor ?? []) { + if (solicitor.Party?.Organisation?.OrganisationName) { + reps.push(solicitor.Party.Organisation.OrganisationName); + } else if (solicitor.Party?.Person?.PersonalDetails?.Name) { + const name = formatPddaCitizenName(solicitor.Party.Person.PersonalDetails.Name); + if (name) reps.push(name); + } + } + } + } + return reps.join(", "); +} + +function renderHearing(hearing: NonNullable[0]): CrownFirmHearingRendered { + const defendants = hearing.Defendants ?? []; + const caseRendered: CrownFirmCaseRendered = { + caseNumber: hearing.CaseNumberCaTH || hearing.CaseNumber, + timeMarkingNote: hearing.TimeMarkingNote || "", + prosecutingAuthority: hearing.Prosecution?.ProsecutingAuthority || "", + listingNotes: hearing.ListNote || "", + defendants: defendants.map(formatDefendantName).filter(Boolean).join(", "), + representative: extractRepresentative(defendants), + formattedReportingRestriction: "" + }; + + return { + displayHearingType: hearing.HearingDetails.HearingDescription || hearing.HearingDetails.HearingType || "", + case: [caseRendered] + }; +} + +function buildGroupedListData(jsonData: CrownFirmListData, locale: string): CrownFirmGroupedDay[] { + const dayMap = new Map; sittings: CrownFirmDaySitting[] }>(); + + for (const courtList of jsonData.FirmList.CourtLists) { + const day = formatSittingDate(courtList.SittingDate, locale); + const courtHouseInfo = formatCourtHouseInfo(courtList.CourtHouse); + + for (const sitting of courtList.Sittings) { + const daySitting: CrownFirmDaySitting = { + courtRoomName: String(sitting.CourtRoomNumber), + formattedJudiciaries: formatJudiciary(sitting.Judiciary), + time: formatPddaSittingTime(sitting.SittingAt), + hearing: (sitting.Hearings ?? []).map(renderHearing) + }; + + const existing = dayMap.get(day); + if (existing) { + existing.sittings.push(daySitting); + } else { + dayMap.set(day, { courtHouseInfo, sittings: [daySitting] }); + } + } + } + + return Array.from(dayMap.entries()).map(([day, data]) => ({ day, courtHouseInfo: data.courtHouseInfo, sittings: data.sittings })); +} diff --git a/libs/list-types/crown-firm-list/src/schemas/crown-firm-list.json b/libs/list-types/crown-firm-list/src/schemas/crown-firm-list.json new file mode 100644 index 000000000..f58772257 --- /dev/null +++ b/libs/list-types/crown-firm-list/src/schemas/crown-firm-list.json @@ -0,0 +1,469 @@ +{ + "$defs": { + "CitizenName": { + "type": "object", + "properties": { + "CitizenNameTitle": { + "type": "string", + "minLength": 1, + "maxLength": 35 + }, + "CitizenNameForename": { + "type": "array", + "items": { "type": "string" } + }, + "CitizenNameSurname": { + "type": "string", + "minLength": 1, + "maxLength": 35 + }, + "CitizenNameSuffix": { + "type": "string", + "minLength": 1, + "maxLength": 35 + }, + "CitizenNameRequestedName": { + "type": "string", + "minLength": 1, + "maxLength": 70 + } + } + }, + "Address": { + "type": "object", + "properties": { + "Line": { + "type": "array", + "maxItems": 5, + "items": { "type": "string" } + }, + "PostCode": { "type": "string" } + } + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Root", + "type": "object", + "required": ["FirmList"], + "properties": { + "FirmList": { + "type": "object", + "required": ["DocumentID", "ListHeader", "CrownCourt", "CourtLists"], + "properties": { + "DocumentID": { + "type": "object", + "required": ["UniqueID", "DocumentType"], + "properties": { + "UniqueID": { "type": "string" }, + "DocumentType": { "type": "string" }, + "TimeStamp": { "type": "string", "format": "date-time" }, + "Version": { "type": "string" }, + "SecurityClassification": { "type": "string" } + } + }, + "ListHeader": { + "type": "object", + "required": ["StartDate", "Version", "PublishedTime"], + "properties": { + "StartDate": { "type": "string", "format": "date" }, + "EndDate": { "type": "string", "format": "date" }, + "Version": { "type": "string" }, + "PublishedTime": { "type": "string", "format": "date-time" } + } + }, + "CrownCourt": { + "type": "object", + "required": ["CourtHouseType", "CourtHouseCode", "CourtHouseName"], + "properties": { + "CourtHouseType": { + "type": "string", + "enum": ["Crown Court", "Magistrates Court", "Youth Court"] + }, + "CourtHouseCode": { "type": "integer" }, + "CourtHouseName": { "type": "string" }, + "CourtHouseAddress": { + "$ref": "#/$defs/Address" + }, + "CourtHouseTelephone": { "type": "string" } + } + }, + "CourtLists": { + "type": "array", + "items": { + "type": "object", + "required": ["SittingDate", "CourtHouse", "Sittings"], + "properties": { + "SittingDate": { "type": "string", "format": "date" }, + "CourtHouse": { + "type": "object", + "required": ["CourtHouseType", "CourtHouseCode", "CourtHouseName"], + "properties": { + "CourtHouseType": { + "type": "string", + "enum": ["Crown Court", "Magistrates Court", "Youth Court"] + }, + "CourtHouseCode": { "type": "integer" }, + "CourtHouseShortName": { "type": "string" }, + "CourtHouseName": { "type": "string" }, + "CourtHouseAddress": { + "$ref": "#/$defs/Address" + }, + "CourtHouseTelephone": { "type": "string" } + } + }, + "Sittings": { + "type": "array", + "items": { + "type": "object", + "required": ["CourtRoomNumber", "Judiciary"], + "properties": { + "CourtRoomNumber": { "type": "integer" }, + "SittingAt": { "type": "string", "format": "time" }, + "SittingPriority": { + "type": "string", + "enum": ["T", "F", "R"] + }, + "SittingNote": { "type": "string" }, + "Judiciary": { + "type": "object", + "required": ["Judge"], + "properties": { + "Judge": { + "$ref": "#/$defs/CitizenName", + "required": ["CitizenNameSurname"] + }, + "Justice": { + "type": "array", + "maxItems": 4, + "items": { + "$ref": "#/$defs/CitizenName", + "required": ["CitizenNameSurname"] + } + } + } + }, + "Hearings": { + "type": "array", + "items": { + "type": "object", + "required": ["HearingSequenceNumber", "HearingDetails", "CaseNumber", "CaseNumberCaTH"], + "properties": { + "HearingSequenceNumber": { "type": "integer" }, + "HearingDetails": { + "type": "object", + "required": ["HearingDescription"], + "properties": { + "HearingDescription": { "type": "string" }, + "HearingDate": { "type": "string", "format": "date" }, + "HearingEndDate": { "type": "string", "format": "date" }, + "HearingType": { + "type": "string", + "pattern": "^[A-Z]{3}$" + } + } + }, + "TimeMarkingNote": { "type": "string" }, + "CaseNumber": { "type": "string" }, + "CaseNumberCaTH": { "type": "string" }, + "Prosecution": { + "type": "object", + "properties": { + "Advocate": { + "type": "object", + "required": ["PersonalDetails"], + "properties": { + "PersonalDetails": { + "type": "object", + "required": ["Name", "IsMasked"], + "properties": { + "Name": { + "$ref": "#/$defs/CitizenName" + }, + "MaskedName": { "type": "string" }, + "IsMasked": { "type": "string", "enum": ["yes", "no"] } + } + } + } + }, + "ProsecutingReference": { "type": "string" }, + "ProsecutingOrganisation": { + "type": "object", + "required": ["OrganisationName"], + "properties": { + "OrganisationName": { "type": "string" } + } + }, + "ProsecutingAuthority": { + "type": "string", + "enum": [ + "Crown Prosecution Service", + "Customs and Excise", + "Department of Trade and Industry", + "Inland Revenue", + "Other Prosecutor" + ] + } + } + }, + "CommittingCourt": { + "type": "object", + "required": ["CourtHouseType", "CourtHouseCode", "CourtHouseName"], + "properties": { + "CourtHouseType": { + "type": "string", + "enum": ["Crown Court", "Magistrates Court", "Youth Court"] + }, + "CourtHouseCode": { "type": "integer" }, + "CourtHouseShortName": { "type": "string" }, + "CourtHouseName": { "type": "string" } + } + }, + "ListNote": { "type": "string" }, + "Defendants": { + "type": "array", + "items": { + "type": "object", + "required": ["PersonalDetails"], + "properties": { + "PersonalDetails": { + "type": "object", + "required": ["Name", "IsMasked"], + "properties": { + "Name": { + "$ref": "#/$defs/CitizenName" + }, + "MaskedName": { "type": "string" }, + "IsMasked": { "type": "string", "enum": ["yes", "no"] }, + "DateOfBirth": { + "type": "object", + "properties": { + "BirthDate": { "type": "string", "format": "date" }, + "VerifiedBy": { + "type": "string", + "enum": [ + "not verified", + "accepted on balance of probabilities", + "secondary certificate", + "certified copy of birth certificate", + "short form birth certificate or certificate of registration of birth", + "birth certificate" + ] + } + } + }, + "Age": { "type": "integer" }, + "Sex": { + "type": "string", + "enum": ["unknown", "male", "female", "indeterminate"] + }, + "Address": { + "$ref": "#/$defs/Address" + }, + "Nationality": { "type": "string" } + } + }, + "URN": { "type": "string" }, + "Counsel": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Solicitor": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Party": { + "type": "object", + "properties": { + "Person": { + "type": "object", + "required": ["PersonalDetails"], + "PersonalDetails": { + "type": "object", + "required": ["Name", "IsMasked"], + "properties": { + "Name": { + "$ref": "#/$defs/CitizenName" + }, + "MaskedName": { + "type": "string" + }, + "IsMasked": { + "type": "string", + "enum": ["yes", "no"] + }, + "DateOfBirth": { + "type": "object", + "properties": { + "BirthDate": { + "type": "string", + "format": "date" + }, + "VerifiedBy": { + "type": "string", + "enum": [ + "not verified", + "accepted on balance of probabilities", + "secondary certificate", + "certified copy of birth certificate", + "short form birth certificate or certificate of registration of birth", + "birth certificate" + ] + } + } + }, + "Sex": { + "type": "string", + "enum": ["unknown", "male", "female", "indeterminate"] + }, + "Address": { + "$ref": "#/$defs/Address" + } + } + } + }, + "Organisation": { + "type": "object", + "required": ["OrganisationName"], + "properties": { + "OrganisationName": { "type": "string" } + } + } + } + } + } + } + } + } + } + }, + "Charges": { + "type": "array", + "items": { + "type": "object", + "required": ["OffenceStatement"], + "properties": { + "OffenceStatement": { "type": "string" }, + "IndictmentCountNumber": { "type": "integer" }, + "CJSoffenceCode": { "type": "string" } + } + } + } + } + } + }, + "Respondent": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + } + } + } + } + }, + "ReserveList": { + "type": "array", + "items": { + "type": "object", + "required": ["HearingDetails", "CaseNumber", "CaseNumberCaTH"], + "properties": { + "HearingDetails": { + "type": "object", + "required": ["HearingDescription"], + "properties": { + "HearingDescription": { "type": "string" }, + "HearingDate": { "type": "string", "format": "date" }, + "HearingEndDate": { "type": "string", "format": "date" }, + "HearingType": { + "type": "string", + "pattern": "^[A-Z]{3}$" + } + } + }, + "CaseNumber": { "type": "string", "pattern": "^[A-Z][0-9]{8}$" }, + "CaseNumberCaTH": { "type": "string", "pattern": "^[A-Z][0-9]{8}$" }, + "Prosecution": { + "type": "object", + "properties": { + "Advocate": { + "type": "object", + "required": ["PersonalDetails"], + "properties": { + "PersonalDetails": { + "type": "object", + "required": ["Name", "IsMasked"], + "properties": { + "Name": { + "$ref": "#/$defs/CitizenName" + }, + "MaskedName": { "type": "string" }, + "IsMasked": { "type": "string", "enum": ["yes", "no"] } + } + } + } + }, + "ProsecutingReference": { "type": "string" }, + "ProsecutingOrganisation": { + "type": "object", + "required": ["OrganisationName"], + "properties": { + "OrganisationName": { "type": "string" } + } + }, + "ProsecutingAuthority": { + "type": "string", + "enum": ["Crown Prosecution Service", "Customs and Excise", "Department of Trade and Industry", "Inland Revenue", "Other Prosecutor"] + } + } + }, + "CommittingCourt": { + "type": "object", + "required": ["CourtHouseType", "CourtHouseCode", "CourtHouseName"], + "properties": { + "CourtHouseType": { + "type": "string", + "enum": ["Crown Court", "Magistrates Court", "Youth Court"] + }, + "CourtHouseCode": { "type": "integer" }, + "CourtHouseShortName": { "type": "string" }, + "CourtHouseName": { "type": "string" } + } + }, + "ListNote": { "type": "string" }, + "Defendants": { + "type": "array", + "items": { + "type": "object", + "required": ["PersonalDetails"], + "properties": { + "PersonalDetails": { + "type": "object", + "required": ["Name", "IsMasked"], + "properties": { + "Name": { + "$ref": "#/$defs/CitizenName" + }, + "MaskedName": { "type": "string" }, + "IsMasked": { "type": "string", "enum": ["yes", "no"] } + } + }, + "URN": { "type": "string" } + } + } + }, + "Respondent": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + } + } +} diff --git a/libs/list-types/crown-firm-list/src/validation/json-validator.test.ts b/libs/list-types/crown-firm-list/src/validation/json-validator.test.ts new file mode 100644 index 000000000..9c782f5c4 --- /dev/null +++ b/libs/list-types/crown-firm-list/src/validation/json-validator.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { validateCrownFirmList } from "./json-validator.js"; + +describe("validateCrownFirmList", () => { + it("should validate a correct crown firm list", () => { + const validData = { + FirmList: { + DocumentID: { UniqueID: "CFPL-2025-001", DocumentType: "crown_firm_pdda_list" }, + ListHeader: { + StartDate: "2025-11-12", + PublishedTime: "2025-11-12T09:00:00", + Version: "1.0" + }, + CrownCourt: { + CourtHouseType: "Crown Court", + CourtHouseCode: 1001, + CourtHouseName: "Crown Court at Manchester", + CourtHouseAddress: { + Line: ["Crown Square"], + PostCode: "M3 3FL" + } + }, + CourtLists: [ + { + SittingDate: "2025-11-12", + CourtHouse: { + CourtHouseType: "Crown Court", + CourtHouseCode: 1001, + CourtHouseName: "Crown Court at Manchester" + }, + Sittings: [ + { + CourtRoomNumber: 3, + Judiciary: { + Judge: { CitizenNameSurname: "Brown" } + } + } + ] + } + ] + } + }; + + const result = validateCrownFirmList(validData); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should return errors for missing required fields", () => { + const result = validateCrownFirmList({}); + + expect(result.isValid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it("should return errors for missing CrownCourt", () => { + const result = validateCrownFirmList({ + FirmList: { + DocumentID: "CFPL-2025-001", + ListHeader: {}, + CourtLists: [] + } + }); + + expect(result.isValid).toBe(false); + }); + + it("should return errors for missing CourtLists", () => { + const result = validateCrownFirmList({ + FirmList: { + DocumentID: "CFPL-2025-001", + ListHeader: {}, + CrownCourt: { CourtHouseName: "Crown Court at Manchester" } + } + }); + + expect(result.isValid).toBe(false); + }); + + it("should return errors for missing CourtRoomNumber in sitting", () => { + const result = validateCrownFirmList({ + FirmList: { + DocumentID: "CFPL-2025-001", + ListHeader: {}, + CrownCourt: { CourtHouseName: "Crown Court at Manchester" }, + CourtLists: [ + { + SittingDate: "2025-11-12", + Sittings: [ + { + Judiciary: { Judge: {} } + } + ] + } + ] + } + }); + + expect(result.isValid).toBe(false); + }); + + it("should return errors for missing SittingDate in court list", () => { + const result = validateCrownFirmList({ + FirmList: { + DocumentID: "CFPL-2025-001", + ListHeader: {}, + CrownCourt: { CourtHouseName: "Crown Court at Manchester" }, + CourtLists: [ + { + Sittings: [] + } + ] + } + }); + + expect(result.isValid).toBe(false); + }); +}); diff --git a/libs/list-types/crown-firm-list/src/validation/json-validator.ts b/libs/list-types/crown-firm-list/src/validation/json-validator.ts new file mode 100644 index 000000000..15c6913c7 --- /dev/null +++ b/libs/list-types/crown-firm-list/src/validation/json-validator.ts @@ -0,0 +1,6 @@ +import { type ValidationResult, validateJson } from "@hmcts/publication"; +import schema from "../schemas/crown-firm-list.json" with { type: "json" }; + +export function validateCrownFirmList(jsonData: unknown): ValidationResult { + return validateJson(jsonData, schema, "1.0"); +} diff --git a/libs/list-types/crown-firm-list/tsconfig.json b/libs/list-types/crown-firm-list/tsconfig.json new file mode 100644 index 000000000..a029ee937 --- /dev/null +++ b/libs/list-types/crown-firm-list/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules", "src/assets/"] +} diff --git a/libs/list-types/crown-warned-list/package.json b/libs/list-types/crown-warned-list/package.json new file mode 100644 index 000000000..03702b4c6 --- /dev/null +++ b/libs/list-types/crown-warned-list/package.json @@ -0,0 +1,43 @@ +{ + "name": "@hmcts/crown-warned-list", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.ts" + } + }, + "scripts": { + "build": "tsc && yarn build:pdf-templates && yarn build:schemas", + "build:pdf-templates": "mkdir -p dist/pdf && cp src/pdf/*.njk dist/pdf/", + "build:schemas": "mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ." + }, + "dependencies": { + "@hmcts/list-types-common": "workspace:*", + "@hmcts/pdf-generation": "workspace:*", + "@hmcts/postgres-prisma": "workspace:*", + "luxon": "3.7.2", + "nunjucks": "3.2.4" + }, + "devDependencies": { + "@types/luxon": "3.7.1", + "@types/node": "24.10.4", + "@types/nunjucks": "3.2.6", + "typescript": "6.0.3", + "vitest": "4.1.8" + }, + "peerDependencies": { + "express": "^5.2.0" + } +} diff --git a/libs/list-types/crown-warned-list/src/config.ts b/libs/list-types/crown-warned-list/src/config.ts new file mode 100644 index 000000000..b4c527259 --- /dev/null +++ b/libs/list-types/crown-warned-list/src/config.ts @@ -0,0 +1,8 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const moduleRoot = __dirname; +export const assets = path.join(__dirname, "../assets/"); diff --git a/libs/list-types/crown-warned-list/src/date-formatting.ts b/libs/list-types/crown-warned-list/src/date-formatting.ts new file mode 100644 index 000000000..9484b8206 --- /dev/null +++ b/libs/list-types/crown-warned-list/src/date-formatting.ts @@ -0,0 +1,8 @@ +import { DateTime } from "luxon"; + +export function formatShortDate(dateStr: string | undefined): string { + if (!dateStr) return ""; + const dt = DateTime.fromISO(dateStr); + if (!dt.isValid) return dateStr; + return dt.toFormat("dd/MM/yyyy"); +} diff --git a/libs/list-types/crown-warned-list/src/email-summary/summary-builder.test.ts b/libs/list-types/crown-warned-list/src/email-summary/summary-builder.test.ts new file mode 100644 index 000000000..b5e03b449 --- /dev/null +++ b/libs/list-types/crown-warned-list/src/email-summary/summary-builder.test.ts @@ -0,0 +1,382 @@ +import { describe, expect, it } from "vitest"; +import type { CrownWarnedListData } from "../models/types.js"; +import { extractCaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "./summary-builder.js"; + +const buildTestData = (overrides?: Partial): CrownWarnedListData => ({ + WarnedList: { + DocumentID: { UniqueID: "CWL-2025-001", DocumentType: "crown_warned_pdda_list" }, + ListHeader: { StartDate: "2025-01-27" }, + CrownCourt: { CourtHouseName: "Crown Court at Birmingham" }, + CourtLists: [], + ...overrides + } +}); + +describe("extractCaseSummary", () => { + it("should extract case summaries from WithFixedDate cases", () => { + const testData = buildTestData({ + CourtLists: [ + { + WithFixedDate: [ + { + Fixture: [ + { + FixedDate: "2025-02-10", + Cases: [ + { + CaseNumber: "B20250001", + Prosecution: { ProsecutingAuthority: "CPS" }, + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["Alice"], CitizenNameSurname: "Williams" }, + IsMasked: "NO" + } + } + ] + } + ] + } + ] + } + ] + } + ] + }); + + const result = extractCaseSummary(testData); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual([ + { label: "Fixed for", value: "10/02/2025" }, + { label: "Case Reference", value: "B20250001" }, + { label: "Defendant Name(s)", value: "Alice Williams" }, + { label: "Prosecuting Authority", value: "CPS" } + ]); + }); + + it("should extract case summaries from WithoutFixedDate cases", () => { + const testData = buildTestData({ + CourtLists: [ + { + WithoutFixedDate: [ + { + Fixture: [ + { + Cases: [ + { + CaseNumber: "B20250002", + Prosecution: { ProsecutingAuthority: "CPS" }, + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["Tom"], CitizenNameSurname: "Hardy" }, + IsMasked: "NO", + CustodyStatus: "On remand" + } + } + ] + } + ] + } + ] + } + ] + } + ] + }); + + const result = extractCaseSummary(testData); + + expect(result[0].find((f) => f.label === "Defendant Name(s)")?.value).toBe("Tom Hardy"); + expect(result[0].find((f) => f.label === "Fixed for")?.value).toBe(""); + }); + + it("should not include defendant field when no defendants", () => { + const testData = buildTestData({ + CourtLists: [ + { + WithFixedDate: [ + { + Fixture: [ + { + Cases: [ + { + CaseNumber: "B20250003", + Defendants: [] + } + ] + } + ] + } + ] + } + ] + }); + + const result = extractCaseSummary(testData); + + expect(result[0].find((f) => f.label === "Defendant Name(s)")).toBeUndefined(); + }); + + it("should return empty array when no court lists", () => { + expect(extractCaseSummary(buildTestData())).toHaveLength(0); + }); + + it("should return MaskedName when IsMasked is yes and MaskedName is present", () => { + const testData = buildTestData({ + CourtLists: [ + { + WithFixedDate: [ + { + Fixture: [ + { + Cases: [ + { + CaseNumber: "B20250009", + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["Real"], CitizenNameSurname: "Name" }, + IsMasked: "YES", + MaskedName: "Restricted" + } + } + ] + } + ] + } + ] + } + ] + } + ] + }); + + const result = extractCaseSummary(testData); + expect(result[0].find((f) => f.label === "Defendant Name(s)")?.value).toBe("Restricted"); + }); + + it("should use unmasked name when IsMasked is yes but MaskedName is absent", () => { + const testData = buildTestData({ + CourtLists: [ + { + WithFixedDate: [ + { + Fixture: [ + { + Cases: [ + { + CaseNumber: "B20250010", + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["Bob"], CitizenNameSurname: "Smith" }, + IsMasked: "YES" + } + } + ] + } + ] + } + ] + } + ] + } + ] + }); + + const result = extractCaseSummary(testData); + expect(result[0].find((f) => f.label === "Defendant Name(s)")?.value).toBe("Bob Smith"); + }); + + it("should handle undefined CitizenNameForename when building defendant name", () => { + const testData = buildTestData({ + CourtLists: [ + { + WithoutFixedDate: [ + { + Fixture: [ + { + Cases: [ + { + CaseNumber: "B20250011", + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameSurname: "OnlyLastName" }, + IsMasked: "NO" + } + } + ] + } + ] + } + ] + } + ] + } + ] + }); + + const result = extractCaseSummary(testData); + expect(result[0].find((f) => f.label === "Defendant Name(s)")?.value).toBe("OnlyLastName"); + }); + + it("should handle fixedDate that is invalid ISO string", () => { + const testData = buildTestData({ + CourtLists: [ + { + WithFixedDate: [ + { + Fixture: [ + { + FixedDate: "not-a-date", + Cases: [ + { + CaseNumber: "B20250012", + Defendants: [] + } + ] + } + ] + } + ] + } + ] + }); + + const result = extractCaseSummary(testData); + expect(result[0].find((f) => f.label === "Fixed for")?.value).toBe("not-a-date"); + }); + + it("should use empty string for Fixed for when fixedDate is undefined", () => { + const testData = buildTestData({ + CourtLists: [ + { + WithFixedDate: [ + { + Fixture: [ + { + Cases: [ + { + CaseNumber: "B20250013", + Defendants: [] + } + ] + } + ] + } + ] + } + ] + }); + + const result = extractCaseSummary(testData); + expect(result[0].find((f) => f.label === "Fixed for")?.value).toBe(""); + }); + + it("should handle entry with no Fixture in WithFixedDate", () => { + const testData = buildTestData({ + CourtLists: [{ WithFixedDate: [{}] } as any] + }); + expect(extractCaseSummary(testData)).toHaveLength(0); + }); + + it("should handle fixture with no Cases in WithoutFixedDate", () => { + const testData = buildTestData({ + CourtLists: [{ WithoutFixedDate: [{ Fixture: [{}] }] } as any] + }); + expect(extractCaseSummary(testData)).toHaveLength(0); + }); + + it("should use empty string for Case Reference when CaseNumber is absent", () => { + const testData = buildTestData({ + CourtLists: [ + { + WithFixedDate: [ + { + Fixture: [ + { + Cases: [{ Defendants: [] } as any] + } + ] + } + ] + } + ] + }); + const result = extractCaseSummary(testData); + expect(result[0].find((f) => f.label === "Case Reference")?.value).toBe(""); + }); + + it("should join multiple defendants with comma", () => { + const testData = buildTestData({ + CourtLists: [ + { + WithFixedDate: [ + { + Fixture: [ + { + FixedDate: "2025-02-10", + Cases: [ + { + CaseNumber: "B20250014", + Prosecution: { ProsecutingAuthority: "CPS" }, + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["Alice"], CitizenNameSurname: "One" }, + IsMasked: "NO" + } + }, + { + PersonalDetails: { + Name: { CitizenNameForename: ["Bob"], CitizenNameSurname: "Two" }, + IsMasked: "NO" + } + } + ] + } + ] + } + ] + } + ] + } + ] + }); + + const result = extractCaseSummary(testData); + expect(result[0].find((f) => f.label === "Defendant Name(s)")?.value).toBe("Alice One, Bob Two"); + }); +}); + +describe("formatCaseSummaryForEmail", () => { + it("should format summaries for email", () => { + const result = formatCaseSummaryForEmail([ + [ + { label: "Fixed for", value: "10/02/2025" }, + { label: "Case Reference", value: "B20250001" }, + { label: "Defendant Name(s)", value: "Alice Williams" }, + { label: "Prosecuting Authority", value: "CPS" } + ] + ]); + + expect(result).toContain("Fixed for - 10/02/2025"); + expect(result).toContain("Case Reference - B20250001"); + expect(result).toContain("Defendant Name(s) - Alice Williams"); + expect(result).toContain("Prosecuting Authority - CPS"); + }); + + it("should handle empty list", () => { + expect(formatCaseSummaryForEmail([])).toBe("No cases scheduled."); + }); +}); + +describe("SPECIAL_CATEGORY_DATA_WARNING", () => { + it("should contain required warning text", () => { + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Special Category Data"); + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Data Protection Act 2018"); + }); +}); diff --git a/libs/list-types/crown-warned-list/src/email-summary/summary-builder.ts b/libs/list-types/crown-warned-list/src/email-summary/summary-builder.ts new file mode 100644 index 000000000..ca9a443d2 --- /dev/null +++ b/libs/list-types/crown-warned-list/src/email-summary/summary-builder.ts @@ -0,0 +1,45 @@ +import { type CaseSummary, formatCaseSummaryForEmail, formatPddaDefendantName, SPECIAL_CATEGORY_DATA_WARNING } from "@hmcts/list-types-common"; +import { formatShortDate } from "../date-formatting.js"; +import type { CrownWarnedListData, PddaCase } from "../models/types.js"; + +export { formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING }; + +function buildCaseSummary(caseItem: PddaCase, fixedDate: string | undefined): CaseSummary { + const defendants = (caseItem.Defendants ?? []).map((d) => formatPddaDefendantName(d.PersonalDetails)).filter((n) => n.length > 0); + const fields: CaseSummary = []; + + fields.push({ label: "Fixed for", value: formatShortDate(fixedDate) }); + fields.push({ label: "Case Reference", value: caseItem.CaseNumberCaTH ?? caseItem.CaseNumber ?? "" }); + + if (defendants.length > 0) { + fields.push({ label: "Defendant Name(s)", value: defendants.join(", ") }); + } + + fields.push({ label: "Prosecuting Authority", value: caseItem.Prosecution?.ProsecutingAuthority || "" }); + + return fields; +} + +export function extractCaseSummary(jsonData: CrownWarnedListData): CaseSummary[] { + const summaries: CaseSummary[] = []; + + for (const courtList of jsonData.WarnedList.CourtLists) { + for (const entry of courtList.WithFixedDate ?? []) { + for (const fixture of entry.Fixture ?? []) { + for (const caseItem of fixture.Cases ?? []) { + summaries.push(buildCaseSummary(caseItem, fixture.FixedDate)); + } + } + } + + for (const entry of courtList.WithoutFixedDate ?? []) { + for (const fixture of entry.Fixture ?? []) { + for (const caseItem of fixture.Cases ?? []) { + summaries.push(buildCaseSummary(caseItem, fixture.FixedDate)); + } + } + } + } + + return summaries; +} diff --git a/libs/list-types/crown-warned-list/src/index.ts b/libs/list-types/crown-warned-list/src/index.ts new file mode 100644 index 000000000..a064c60f7 --- /dev/null +++ b/libs/list-types/crown-warned-list/src/index.ts @@ -0,0 +1,10 @@ +// Business logic exports + +export type { ValidationResult } from "@hmcts/publication"; +export * from "./email-summary/summary-builder.js"; +export { cy as crownWarnedListCy } from "./locales/cy.js"; +export { en as crownWarnedListEn } from "./locales/en.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export * from "./rendering/renderer.js"; +export { validateCrownWarnedList } from "./validation/json-validator.js"; diff --git a/libs/list-types/crown-warned-list/src/locales/cy.ts b/libs/list-types/crown-warned-list/src/locales/cy.ts new file mode 100644 index 000000000..d2b90eabc --- /dev/null +++ b/libs/list-types/crown-warned-list/src/locales/cy.ts @@ -0,0 +1,41 @@ +export const cy = { + title: "Rhestr Rybuddiol y Goron", + pageTitle: "Rhestr Rybuddiol y Goron ar gyfer", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factLinkText: "Dewch o hyd i fanylion cyswllt a gwybodaeth arall am lysoedd a thribiwnlysoedd", + factAdditionalText: "yng Nghymru a Lloegr, a rhai tribiwnlysoedd nad ydynt wedi'u datganoli yn yr Alban.", + lastUpdated: "Diweddarwyd ddiwethaf", + version: "Fersiwn", + preStatementPrefix: "Mae'r achosion a grybwyllir isod wedi'u rhybuddio ar gyfer cyfnod gwrandawiad yr wythnos sy'n cychwyn", + preStatementSuffix2: "Dylid gwneud unrhyw gynrychiolaeth am restru achos i'r Swyddog Rhestru ar unwaith", + preStatementSuffix3: "Yr awdurdod erlyn yw Gwasanaeth Erlyn y Goron oni nodir fel arall", + preStatementSuffix4: "*dynoda ddiffynnydd yn y ddalfa", + fixedFor: "Wedi'i bennu ar gyfer", + caseRef: "Cyfeirnod Achos", + defendant: "Enw(au) Diffynyddion", + prosecutingAuthority: "Awdurdod Erlyn", + linkedCases: "Achosion Cysylltiedig", + listingNotes: "Nodiadau Rhestru", + toBeAllocated: "I'w ddyrannu", + searchCases: "Chwilio Achosion", + reportingRestrictions: "Cyfyngiad Adrodd", + reportingRestrictionsTitle: "Cyfyngiadau ar gyhoeddi neu ysgrifennu am yr achosion hyn", + reportingRestrictionsBodyIntro: + "Rhaid i chi wirio a oes unrhyw gyfyngiadau adrodd yn berthnasol cyn cyhoeddi manylion am unrhyw un o'r achosion a restrir yma naill ai ar bapur, mewn darllediad neu dros y rhyngrwyd, gan gynnwys cyfryngau cymdeithasol.", + reportingRestrictionsWarning: + "Rhybudd Byddwch yn euog o ddirmyg llys os byddwch yn cyhoeddi unrhyw wybodaeth sydd wedi'i diogelu gan gyfyngiad adrodd. Gallech gael dirwy, dedfryd o garchar neu'r ddau.", + reportingRestrictionsBodySpecific: "Bydd cyfyngiadau penodol a orchmynnwyd gan y llys yn cael eu crybwyll ar yr achosion a restrir yma.", + reportingRestrictionsBodyHowever: + "Fodd bynnag, nid yw cyfyngiadau bob amser yn cael eu rhestru. Mae rhai yn berthnasol yn awtomatig. Er enghraifft, anhysbysrwydd a roddir i ddioddefwyr troseddau rhywiol penodol.", + reportingRestrictionsBodyContact: "I ddarganfod pa gyfyngiadau adrodd sy'n berthnasol i achos penodol, cysylltwch â:", + reportingRestrictionsContactCourt: "y llys yn uniongyrchol", + reportingRestrictionsContactHmcts: "Gwasanaeth Llysoedd a Thribiwnlysoedd Ei Fawrhydi ar 0330 808 4407", + courtHouseDetails: "Manylion y Llys", + backToTop: "Yn ôl i frig y dudalen", + dataSource: "Ffynhonnell Data", + errorTitle: "Cyhoeddiad ddim ar gael", + errorMessage: + "Ni ellir gweld y cyhoeddiad hwn ar hyn o bryd. Gwiriwch eto yn nes ymlaen. Os yw'r broblem yn parhau, cysylltwch â'r llys yn uniongyrchol am gymorth.", + error403Title: "Mynediad wedi'i Wrthod", + error403Message: "Nid oes gennych ganiatâd i weld y cyhoeddiad hwn." +}; diff --git a/libs/list-types/crown-warned-list/src/locales/en.ts b/libs/list-types/crown-warned-list/src/locales/en.ts new file mode 100644 index 000000000..fc94540ca --- /dev/null +++ b/libs/list-types/crown-warned-list/src/locales/en.ts @@ -0,0 +1,41 @@ +export const en = { + title: "Crown Warned List", + pageTitle: "Crown Warned List for", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factLinkText: "Find contact details and other information about courts and tribunals", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + lastUpdated: "Last updated", + version: "Version", + preStatementPrefix: "The undermentioned cases are warned for the hearing period of week commencing", + preStatementSuffix2: "Any representation about the listing of a case should be made to the Listing Officer immediately", + preStatementSuffix3: "The prosecuting authority is the Crown Prosecution Service unless otherwise stated", + preStatementSuffix4: "*denotes a defendant in custody", + fixedFor: "Fixed For", + caseRef: "Case Reference", + defendant: "Defendant Name(s)", + prosecutingAuthority: "Prosecuting Authority", + linkedCases: "Linked Cases", + listingNotes: "Listing Notes", + toBeAllocated: "To be allocated", + searchCases: "Search Cases", + reportingRestrictions: "Reporting Restriction", + reportingRestrictionsTitle: "Restrictions on publishing or writing about these cases", + reportingRestrictionsBodyIntro: + "You must check if any reporting restrictions apply before publishing details on any of the cases listed here either in writing, in a broadcast or by internet, including social media.", + reportingRestrictionsWarning: + "Warning You'll be in contempt of court if you publish any information which is protected by a reporting restriction. You could get a fine, prison sentence or both.", + reportingRestrictionsBodySpecific: "Specific restrictions ordered by the court will be mentioned on the cases listed here.", + reportingRestrictionsBodyHowever: + "However, restrictions are not always listed. Some apply automatically. For example, anonymity given to the victims of certain sexual offences.", + reportingRestrictionsBodyContact: "To find out which reporting restrictions apply on a specific case, contact:", + reportingRestrictionsContactCourt: "the court directly", + reportingRestrictionsContactHmcts: "HM Courts and Tribunals Service on 0330 808 4407", + courtHouseDetails: "Court House Details", + backToTop: "Back to top", + dataSource: "Data Source", + errorTitle: "Publication not available", + errorMessage: + "This publication cannot be viewed at the moment. Please check again later. If the problem persists, contact the court directly for assistance.", + error403Title: "Access Denied", + error403Message: "You do not have permission to view this publication." +}; diff --git a/libs/list-types/crown-warned-list/src/models/types.ts b/libs/list-types/crown-warned-list/src/models/types.ts new file mode 100644 index 000000000..10e40cdc6 --- /dev/null +++ b/libs/list-types/crown-warned-list/src/models/types.ts @@ -0,0 +1,98 @@ +export interface CitizenName { + CitizenNameTitle?: string; + CitizenNameForename?: string[]; + CitizenNameSurname?: string; + CitizenNameRequestedName?: string; +} + +export interface PddaPersonalDetails { + Name: CitizenName; + MaskedName?: string; + IsMasked: "YES" | "NO"; + CustodyStatus?: string; + DateOfBirth?: string; + Age?: number; + Sex?: string; +} + +export interface PddaDefendant { + PersonalDetails: PddaPersonalDetails; + URN?: string; +} + +export interface PddaCase { + CaseNumber?: string; + CaseNumberCaTH?: string; + Hearing?: Array<{ + HearingDescription?: string; + ListNote?: string; + }>; + Prosecution?: { + ProsecutingAuthority?: string; + }; + Defendants?: PddaDefendant[]; + LinkedCases?: Array<{ + CaseNumber?: string; + }>; +} + +export interface PddaFixture { + FixedDate?: string; + Cases?: PddaCase[]; +} + +export interface PddaCourtListEntry { + Fixture?: PddaFixture[]; +} + +export interface PddaAddress { + Line?: string[]; + PostCode?: string; +} + +export interface PddaCourtHouse { + CourtHouseName: string; + CourtHouseType?: string; + CourtHouseCode?: number; + CourtHouseAddress?: PddaAddress; + CourtHouseTelephone?: string; +} + +export interface CrownWarnedListData { + WarnedList: { + DocumentID: { UniqueID: string; DocumentType: string }; + ListHeader: { + StartDate?: string; + EndDate?: string; + Version?: string; + PublishedTime?: string; + }; + CrownCourt: PddaCourtHouse; + CourtLists: Array<{ + CourtHouse: PddaCourtHouse; + WithFixedDate?: PddaCourtListEntry[]; + WithoutFixedDate?: PddaCourtListEntry[]; + }>; + }; +} + +export interface GroupedHearingCategory { + category: string; + cases: CrownWarnedCaseRow[]; +} + +export interface CrownWarnedCaseRow { + fixedFor: string; + caseNumber: string; + defendants: string; + prosecutingAuthority: string; + linkedCases: string; + listingNotes: string; + isInCustody: boolean; +} + +export interface RenderOptions { + locationId: string; + contentDate: Date; + locale: string; +} diff --git a/libs/list-types/crown-warned-list/src/pdf/pdf-generator.test.ts b/libs/list-types/crown-warned-list/src/pdf/pdf-generator.test.ts new file mode 100644 index 000000000..a8d1f55b8 --- /dev/null +++ b/libs/list-types/crown-warned-list/src/pdf/pdf-generator.test.ts @@ -0,0 +1,333 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockSavePdfToStorage, mockCreatePdfErrorResult, mockConfigureNunjucks, mockLoadTranslations } = vi.hoisted(() => ({ + mockSavePdfToStorage: vi.fn(), + mockCreatePdfErrorResult: vi.fn(), + mockConfigureNunjucks: vi.fn(), + mockLoadTranslations: vi.fn() +})); + +vi.mock("@hmcts/list-types-common", () => ({ + savePdfToStorage: mockSavePdfToStorage, + createPdfErrorResult: mockCreatePdfErrorResult, + configureNunjucks: mockConfigureNunjucks, + loadTranslations: mockLoadTranslations, + PDF_BASE_STYLES: "/* base styles */", + PDF_CIVIL_FAMILY_STYLES: "/* civil family styles */" +})); + +vi.mock("@hmcts/pdf-generation", () => ({ + generatePdfFromHtml: vi.fn() +})); + +vi.mock("../rendering/renderer.js", () => ({ + renderCrownWarnedListData: vi.fn() +})); + +vi.mock("@hmcts/publication", () => ({ + PROVENANCE_LABELS: { + MANUAL_UPLOAD: "Manual Upload", + SNL: "SNL" + } +})); + +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { renderCrownWarnedListData } from "../rendering/renderer.js"; +import { generateCrownWarnedListPdf } from "./pdf-generator.js"; + +const mockRenderedData = { + header: { + locationName: "Crown Court at Birmingham", + addressLines: ["Newton Street", "B4 7NA"], + dateRange: "10 November 2025 to 11 November 2025", + lastUpdated: "12 November 2025", + weekCommencing: "10 November 2025", + version: "1.0" + }, + openJustice: { + venueName: "Crown Court at Birmingham", + email: "", + phone: "0121 681 3400" + }, + groupedCategories: [] +}; + +const mockJsonData = { + WarnedList: { + DocumentID: { UniqueID: "CWL-2025-001", DocumentType: "crown_warned_pdda_list" }, + ListHeader: { StartDate: "2025-11-10", EndDate: "2025-11-11", PublishedTime: "2025-11-12T09:00:00", Version: "1.0" }, + CrownCourt: { CourtHouseName: "Crown Court at Birmingham" }, + CourtLists: [] + } +}; + +describe("generateCrownWarnedListPdf", () => { + const mockNunjucksEnv = { + render: vi.fn().mockReturnValue("PDF HTML") + }; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(renderCrownWarnedListData).mockResolvedValue(mockRenderedData); + mockConfigureNunjucks.mockReturnValue(mockNunjucksEnv); + mockLoadTranslations.mockResolvedValue({ pageTitle: "Crown Warned List" }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "/storage/temp/uploads/test.pdf", + sizeBytes: 1024, + exceedsMaxSize: false + }); + mockCreatePdfErrorResult.mockImplementation((error: unknown) => ({ + success: false, + error: `Failed to generate PDF: ${error instanceof Error ? error.message : "Unknown error"}` + })); + }); + + it("should generate PDF successfully", async () => { + const pdfBuffer = Buffer.from("PDF content"); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer, + sizeBytes: 1024 + }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "/storage/temp/uploads/warned-artefact-123.pdf", + sizeBytes: 1024, + exceedsMaxSize: false + }); + + const result = await generateCrownWarnedListPdf({ + artefactId: "warned-artefact-123", + contentDate: new Date("2025-11-10"), + locale: "en", + locationId: "102", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(true); + expect(result.pdfPath).toContain("warned-artefact-123.pdf"); + expect(mockSavePdfToStorage).toHaveBeenCalledWith("warned-artefact-123", pdfBuffer, 1024); + }); + + it("should pass groupedCategories to template", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + await generateCrownWarnedListPdf({ + artefactId: "test", + contentDate: new Date("2025-11-10"), + locale: "en", + locationId: "102", + jsonData: mockJsonData as any + }); + + expect(mockNunjucksEnv.render).toHaveBeenCalledWith("pdf-template.njk", expect.objectContaining({ groupedCategories: [] })); + }); + + it("should return error when PDF generation fails", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: false, + error: "Puppeteer crashed" + }); + + const result = await generateCrownWarnedListPdf({ + artefactId: "failed-pdf", + contentDate: new Date("2025-11-10"), + locale: "en", + locationId: "102", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Puppeteer crashed"); + }); + + it("should return default error when PDF generation fails without error message", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ success: false }); + + const result = await generateCrownWarnedListPdf({ + artefactId: "failed-pdf", + contentDate: new Date("2025-11-10"), + locale: "en", + locationId: "102", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("PDF generation failed"); + }); + + it("should return error when PDF buffer is missing", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: undefined, + sizeBytes: 0 + }); + + const result = await generateCrownWarnedListPdf({ + artefactId: "no-buffer", + contentDate: new Date("2025-11-10"), + locale: "en", + locationId: "102", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("PDF generation failed"); + }); + + it("should handle renderer errors gracefully", async () => { + vi.mocked(renderCrownWarnedListData).mockRejectedValue(new Error("Renderer failed")); + + const result = await generateCrownWarnedListPdf({ + artefactId: "renderer-error", + contentDate: new Date("2025-11-10"), + locale: "en", + locationId: "102", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Failed to generate PDF: Renderer failed"); + }); + + it("should handle non-Error exceptions", async () => { + vi.mocked(renderCrownWarnedListData).mockRejectedValue("String error"); + + const result = await generateCrownWarnedListPdf({ + artefactId: "string-error", + contentDate: new Date("2025-11-10"), + locale: "en", + locationId: "102", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Failed to generate PDF: Unknown error"); + }); + + it("should handle file system errors", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + mockSavePdfToStorage.mockRejectedValue(new Error("Disk full")); + + const result = await generateCrownWarnedListPdf({ + artefactId: "fs-error", + contentDate: new Date("2025-11-10"), + locale: "en", + locationId: "102", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Failed to generate PDF: Disk full"); + }); + + it("should pass correct options to renderer", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + const contentDate = new Date("2025-11-10"); + + await generateCrownWarnedListPdf({ + artefactId: "test-options", + contentDate, + locale: "cy", + locationId: "999", + jsonData: mockJsonData as any + }); + + expect(renderCrownWarnedListData).toHaveBeenCalledWith(mockJsonData, { + contentDate, + locale: "cy", + locationId: "999" + }); + }); + + it("should use MANUAL_UPLOAD provenance label", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + await generateCrownWarnedListPdf({ + artefactId: "provenance-test", + contentDate: new Date("2025-11-10"), + locale: "en", + locationId: "102", + jsonData: mockJsonData as any, + provenance: "MANUAL_UPLOAD" + }); + + expect(mockNunjucksEnv.render).toHaveBeenCalledWith("pdf-template.njk", expect.objectContaining({ dataSource: "Manual Upload" })); + }); + + it("should use raw provenance value when label not found", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + await generateCrownWarnedListPdf({ + artefactId: "unknown-provenance", + contentDate: new Date("2025-11-10"), + locale: "en", + locationId: "102", + jsonData: mockJsonData as any, + provenance: "UNKNOWN_SOURCE" + }); + + expect(mockNunjucksEnv.render).toHaveBeenCalledWith("pdf-template.njk", expect.objectContaining({ dataSource: "UNKNOWN_SOURCE" })); + }); + + it("should handle missing provenance", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + const result = await generateCrownWarnedListPdf({ + artefactId: "no-provenance", + contentDate: new Date("2025-11-10"), + locale: "en", + locationId: "102", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(true); + expect(mockNunjucksEnv.render).toHaveBeenCalledWith("pdf-template.njk", expect.objectContaining({ dataSource: "" })); + }); + + it("should generate PDF for Welsh locale", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + const result = await generateCrownWarnedListPdf({ + artefactId: "welsh-pdf", + contentDate: new Date("2025-11-10"), + locale: "cy", + locationId: "102", + jsonData: mockJsonData as any + }); + + expect(result.success).toBe(true); + expect(renderCrownWarnedListData).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ locale: "cy" })); + }); +}); diff --git a/libs/list-types/crown-warned-list/src/pdf/pdf-generator.ts b/libs/list-types/crown-warned-list/src/pdf/pdf-generator.ts new file mode 100644 index 000000000..c9b23d625 --- /dev/null +++ b/libs/list-types/crown-warned-list/src/pdf/pdf-generator.ts @@ -0,0 +1,66 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + type BasePdfGenerationOptions, + configureNunjucks, + createPdfErrorResult, + loadTranslations, + PDF_BASE_STYLES, + PDF_CIVIL_FAMILY_STYLES, + type PdfGenerationResult, + savePdfToStorage +} from "@hmcts/list-types-common"; +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { PROVENANCE_LABELS } from "@hmcts/publication"; +import type { CrownWarnedListData, RenderOptions } from "../models/types.js"; +import { renderCrownWarnedListData } from "../rendering/renderer.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface PdfGenerationOptions extends BasePdfGenerationOptions { + contentDate: Date; +} + +export async function generateCrownWarnedListPdf(options: PdfGenerationOptions): Promise { + try { + const renderOptions: RenderOptions = { + contentDate: options.contentDate, + locale: options.locale, + locationId: options.locationId + }; + + const renderedData = await renderCrownWarnedListData(options.jsonData, renderOptions); + + const translations = await loadTranslations( + options.locale, + () => import("../locales/en.js"), + () => import("../locales/cy.js") + ); + + const provenanceLabel = options.provenance ? PROVENANCE_LABELS[options.provenance as keyof typeof PROVENANCE_LABELS] || options.provenance : ""; + + const env = configureNunjucks(__dirname); + const html = env.render("pdf-template.njk", { + header: renderedData.header, + openJustice: renderedData.openJustice, + groupedCategories: renderedData.groupedCategories, + dataSource: provenanceLabel, + t: translations, + pdfStyles: PDF_BASE_STYLES + PDF_CIVIL_FAMILY_STYLES + }); + + const pdfResult = await generatePdfFromHtml(html); + + if (!pdfResult.success || !pdfResult.pdfBuffer) { + return { + success: false, + error: pdfResult.error || "PDF generation failed" + }; + } + + return await savePdfToStorage(options.artefactId, pdfResult.pdfBuffer, pdfResult.sizeBytes!); + } catch (error) { + return createPdfErrorResult(error); + } +} diff --git a/libs/list-types/crown-warned-list/src/pdf/pdf-template.njk b/libs/list-types/crown-warned-list/src/pdf/pdf-template.njk new file mode 100644 index 000000000..d25e2dacc --- /dev/null +++ b/libs/list-types/crown-warned-list/src/pdf/pdf-template.njk @@ -0,0 +1,114 @@ + + + + + + {{ t.pageTitle }} {{ header.locationName }} + + + + +
+

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

+ +

{{ header.dateRange }}

+

{{ t.lastUpdated }} {{ header.lastUpdated }}

+ + {% if header.version | length %} +

{{ t.version }} {{ header.version }}

+ {% endif %} + +
+ {% for line in header.addressLines %} +

{{ line }}

+ {% endfor %} +
+
+ + {% if header.weekCommencing | length %} +

{{ t.preStatementPrefix }} {{ header.weekCommencing }}

+

{{ t.preStatementSuffix2 }}

+

{{ t.preStatementSuffix3 }}

+

{{ t.preStatementSuffix4 }}

+ {% endif %} + +
+

{{ t.reportingRestrictionsTitle }}

+

{{ t.reportingRestrictionsBodyIntro }}

+
+ + {{ t.reportingRestrictionsWarning }} +
+

{{ t.reportingRestrictionsBodySpecific }}

+

{{ t.reportingRestrictionsBodyHowever }}

+

{{ t.reportingRestrictionsBodyContact }}

+
    +
  • {{ t.reportingRestrictionsContactCourt }}
  • +
  • {{ t.reportingRestrictionsContactHmcts }}
  • +
+
+ + {% for group in groupedCategories %} +
+

+ {% if group.category == "TO_BE_ALLOCATED" %} + {{ t.toBeAllocated }} + {% else %} + {{ group.category }} + {% endif %} +

+ + + + + + + + + + + + + {% for case in group.cases %} + + + + + + + + + {% endfor %} + +
{{ t.fixedFor }}{{ t.caseRef }}{{ t.defendant }}{{ t.prosecutingAuthority }}{{ t.linkedCases }}{{ t.listingNotes }}
{{ case.fixedFor }}{{ case.caseNumber }}{% if case.isInCustody %}*{% endif %}{{ case.defendants }}{{ case.prosecutingAuthority }}{{ case.linkedCases }}{{ case.listingNotes }}
+
+ {% endfor %} + + + + diff --git a/libs/list-types/crown-warned-list/src/rendering/renderer.test.ts b/libs/list-types/crown-warned-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..9f0623b91 --- /dev/null +++ b/libs/list-types/crown-warned-list/src/rendering/renderer.test.ts @@ -0,0 +1,905 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderCrownWarnedListData, TO_BE_ALLOCATED_KEY } from "./renderer.js"; + +vi.mock("@hmcts/location", () => ({ + getLocationById: vi.fn() +})); + +import { getLocationById } from "@hmcts/location"; + +const baseInput = { + WarnedList: { + DocumentID: { UniqueID: "CWPL-2025-001", DocumentType: "crown_warned_pdda_list" }, + ListHeader: { + StartDate: "2025-11-10", + EndDate: "2025-11-11", + PublishedTime: "2025-11-12T09:00:00", + Version: "1.0" + }, + CrownCourt: { + CourtHouseName: "Crown Court at Birmingham", + CourtHouseTelephone: "0121 681 3400", + CourtHouseAddress: { + Line: ["Newton Street"], + PostCode: "B4 7NA" + } + }, + CourtLists: [] + } +}; + +describe("renderCrownWarnedListData", () => { + beforeEach(() => { + vi.clearAllMocks(); + (getLocationById as ReturnType).mockResolvedValue(undefined); + }); + + it("should render header with location name from CrownCourt", async () => { + const result = await renderCrownWarnedListData(baseInput, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + expect(result.header.locationName).toBe("Crown Court at Birmingham"); + expect(result.header.dateRange).toBe("10 November 2025 to 11 November 2025"); + expect(result.header.version).toBe("1.0"); + }); + + it("should format lastUpdated with date and time from PublishedTime", async () => { + const result = await renderCrownWarnedListData(baseInput, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + expect(result.header.lastUpdated).toBe("12 November 2025 at 9am"); + }); + + it("should set weekCommencing from contentDate option when date is a Monday", async () => { + const result = await renderCrownWarnedListData(baseInput, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + expect(result.header.weekCommencing).toBe("10 November 2025"); + }); + + it("should move weekCommencing back to the previous Monday when contentDate is a Wednesday", async () => { + const result = await renderCrownWarnedListData(baseInput, { + locationId: "102", + contentDate: new Date("2025-11-12"), + locale: "en" + }); + + expect(result.header.weekCommencing).toBe("10 November 2025"); + }); + + it("should move weekCommencing back to the previous Monday when contentDate is a Sunday", async () => { + const result = await renderCrownWarnedListData(baseInput, { + locationId: "102", + contentDate: new Date("2025-11-09"), + locale: "en" + }); + + expect(result.header.weekCommencing).toBe("03 November 2025"); + }); + + it("should return empty groupedCategories when no court lists", async () => { + const result = await renderCrownWarnedListData(baseInput, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + expect(result.groupedCategories).toHaveLength(0); + }); + + it("should group WithFixedDate cases by HearingDescription from Case.Hearing[0]", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + CourtLists: [ + { + WithFixedDate: [ + { + Fixture: [ + { + FixedDate: "2025-11-22", + Cases: [ + { + CaseNumber: "T20250001", + Hearing: [{ HearingDescription: "TestHearingDescription", ListNote: "" }], + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["Alice"], CitizenNameSurname: "Williams" }, + IsMasked: "NO" as const + } + } + ], + Prosecution: { ProsecutingAuthority: "CPS" } + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + expect(result.groupedCategories).toHaveLength(1); + const group = result.groupedCategories.find((g) => g.category === "TestHearingDescription"); + expect(group?.cases).toHaveLength(1); + expect(group?.cases[0].caseNumber).toBe("T20250001"); + expect(group?.cases[0].defendants).toBe("Alice Williams"); + expect(group?.cases[0].fixedFor).toBe("22/11/2025"); + }); + + it("should use empty string as category key when HearingDescription is absent", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + CourtLists: [ + { + WithFixedDate: [ + { + Fixture: [ + { + FixedDate: "2025-11-22", + Cases: [ + { + CaseNumber: "T20250001", + Defendants: [], + Prosecution: { ProsecutingAuthority: "CPS" } + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + expect(result.groupedCategories).toHaveLength(1); + const group = result.groupedCategories.find((g) => g.category === ""); + expect(group?.cases).toHaveLength(1); + expect(group?.cases[0].fixedFor).toBe("22/11/2025"); + }); + + it("should group WithoutFixedDate cases under TO_BE_ALLOCATED_KEY", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + CourtLists: [ + { + WithoutFixedDate: [ + { + Fixture: [ + { + Cases: [ + { + CaseNumber: "T20250002", + Defendants: [], + Prosecution: { ProsecutingAuthority: "CPS" } + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + expect(result.groupedCategories).toHaveLength(1); + const group = result.groupedCategories.find((g) => g.category === TO_BE_ALLOCATED_KEY); + expect(group?.cases).toHaveLength(1); + expect(group?.cases[0].caseNumber).toBe("T20250002"); + expect(group?.cases[0].fixedFor).toBe(""); + }); + + it("should set isInCustody=true when defendant has CustodyStatus On remand", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + CourtLists: [ + { + WithFixedDate: [ + { + Fixture: [ + { + FixedDate: "2025-11-22", + Cases: [ + { + CaseNumber: "T20250010", + Hearing: [{ HearingDescription: "TestCategory", ListNote: "" }], + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["Tom"], CitizenNameSurname: "Hardy" }, + IsMasked: "NO" as const, + CustodyStatus: "On remand" + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + const group = result.groupedCategories.find((g) => g.category === "TestCategory"); + expect(group?.cases[0].isInCustody).toBe(true); + expect(group?.cases[0].defendants).toBe("Tom Hardy"); + }); + + it("should set isInCustody=true when defendant has CustodyStatus In custody", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + CourtLists: [ + { + WithoutFixedDate: [ + { + Fixture: [ + { + Cases: [ + { + CaseNumber: "T20250011", + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["Jane"], CitizenNameSurname: "Doe" }, + IsMasked: "NO" as const, + CustodyStatus: "In custody" + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + const group = result.groupedCategories.find((g) => g.category === TO_BE_ALLOCATED_KEY); + expect(group?.cases[0].isInCustody).toBe(true); + }); + + it("should set isInCustody=false for non-custody defendants", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + CourtLists: [ + { + WithoutFixedDate: [ + { + Fixture: [ + { + Cases: [ + { + CaseNumber: "T20250012", + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["John"], CitizenNameSurname: "Smith" }, + IsMasked: "NO" as const, + CustodyStatus: "On bail" + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + const group = result.groupedCategories.find((g) => g.category === TO_BE_ALLOCATED_KEY); + expect(group?.cases[0].isInCustody).toBe(false); + }); + + it("should extract linkedCases from LinkedCases[].CaseNumber", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + CourtLists: [ + { + WithoutFixedDate: [ + { + Fixture: [ + { + Cases: [ + { + CaseNumber: "T20250020", + LinkedCases: [{ CaseNumber: "T20240001" }, { CaseNumber: "T20240002" }], + Defendants: [] + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + const group = result.groupedCategories.find((g) => g.category === TO_BE_ALLOCATED_KEY); + expect(group?.cases[0].linkedCases).toBe("T20240001, T20240002"); + }); + + it("should get listingNotes from Case.Hearing[0].ListNote", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + CourtLists: [ + { + WithFixedDate: [ + { + Fixture: [ + { + FixedDate: "2025-11-22", + Cases: [ + { + CaseNumber: "T20250030", + Hearing: [{ HearingDescription: "TestCategory", ListNote: "Interpreter required" }], + Defendants: [] + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + const group = result.groupedCategories.find((g) => g.category === "TestCategory"); + expect(group?.cases[0].listingNotes).toBe("Interpreter required"); + }); + + it("should use Welsh dateSeparator 'i' when locale is cy", async () => { + const result = await renderCrownWarnedListData(baseInput, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "cy" + }); + + expect(result.header.dateRange).toContain(" i "); + }); + + it("should use location name from getLocationById when available", async () => { + (getLocationById as ReturnType).mockResolvedValue({ id: 102, name: "Birmingham Crown Court", welshName: null }); + + const result = await renderCrownWarnedListData(baseInput, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + expect(result.header.locationName).toBe("Birmingham Crown Court"); + }); + + it("should use Welsh location name when locale is cy and welshName is available", async () => { + (getLocationById as ReturnType).mockResolvedValue({ id: 102, name: "Birmingham Crown Court", welshName: "Llys y Goron Birmingham" }); + + const result = await renderCrownWarnedListData(baseInput, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "cy" + }); + + expect(result.header.locationName).toBe("Llys y Goron Birmingham"); + }); + + it("should render dateRange with only StartDate when EndDate is missing", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + ListHeader: { + StartDate: "2025-11-10", + PublishedTime: "2025-11-12T09:00:00", + Version: "1.0" + } + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + expect(result.header.dateRange).toBe("10 November 2025"); + }); + + it("should render empty dateRange when neither StartDate nor EndDate", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + ListHeader: { + PublishedTime: "2025-11-12T09:00:00", + Version: "1.0" + } + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + expect(result.header.dateRange).toBe(""); + }); + + it("should set isInCustody=true for In care custody status", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + CourtLists: [ + { + WithFixedDate: [ + { + Fixture: [ + { + FixedDate: "2025-11-22", + Cases: [ + { + CaseNumber: "T20250050", + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["Sam"], CitizenNameSurname: "Jones" }, + IsMasked: "NO" as const, + CustodyStatus: "In care" + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + const group = result.groupedCategories.find((g) => g.category === ""); + expect(group?.cases[0].isInCustody).toBe(true); + }); + + it("should handle defendant with CivizenNameTitle in name", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + CourtLists: [ + { + WithoutFixedDate: [ + { + Fixture: [ + { + Cases: [ + { + CaseNumber: "T20250060", + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameTitle: "Dr", CitizenNameForename: ["Emma"], CitizenNameSurname: "Watson" }, + IsMasked: "NO" as const + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + const group = result.groupedCategories.find((g) => g.category === TO_BE_ALLOCATED_KEY); + expect(group?.cases[0].defendants).toContain("Emma Watson"); + }); + + it("should handle court with no address", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + CrownCourt: { + CourtHouseName: "No Address Court" + } + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + expect(result.header.addressLines).toEqual([]); + }); + + it("should use MaskedName when IsMasked is yes", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + CourtLists: [ + { + WithoutFixedDate: [ + { + Fixture: [ + { + Cases: [ + { + CaseNumber: "T20250040", + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["Real"], CitizenNameSurname: "Name" }, + MaskedName: "Restricted", + IsMasked: "YES" as const + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + const group = result.groupedCategories.find((g) => g.category === TO_BE_ALLOCATED_KEY); + expect(group?.cases[0].defendants).toBe("Restricted"); + }); + + it("should use MaskedName over CitizenNameRequestedName when IsMasked is yes", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + CourtLists: [ + { + WithoutFixedDate: [ + { + Fixture: [ + { + Cases: [ + { + CaseNumber: "T20250041", + Defendants: [ + { + PersonalDetails: { + Name: { + CitizenNameForename: ["Real"], + CitizenNameSurname: "Name", + CitizenNameRequestedName: "TestDefendantRequestedName" + }, + MaskedName: "TestMaskedName2", + IsMasked: "YES" as const + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + const group = result.groupedCategories.find((g) => g.category === TO_BE_ALLOCATED_KEY); + expect(group?.cases[0].defendants).toBe("TestMaskedName2"); + }); + + it("should use CitizenNameRequestedName for defendant when present", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + CourtLists: [ + { + WithFixedDate: [ + { + Fixture: [ + { + FixedDate: "2025-11-22", + Cases: [ + { + CaseNumber: "T20250099", + Defendants: [ + { + PersonalDetails: { + Name: { + CitizenNameTitle: "Mr", + CitizenNameForename: ["RealForename"], + CitizenNameSurname: "RealSurname", + CitizenNameRequestedName: "RequestedName" + }, + IsMasked: "NO" as const + } + } + ], + Hearing: [{ HearingDescription: "For Trial" }] + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + const group = result.groupedCategories.find((g) => g.category === "For Trial"); + expect(group?.cases[0].defendants).toBe("RequestedName"); + }); + + it("should place a case in multiple categories when it has multiple HearingDescriptions", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + CourtLists: [ + { + WithFixedDate: [ + { + Fixture: [ + { + FixedDate: "2025-11-22", + Cases: [ + { + CaseNumber: "T20250100", + Defendants: [], + Hearing: [{ HearingDescription: "For Trial" }, { HearingDescription: "For Appeal" }] + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + expect(result.groupedCategories.find((g) => g.category === "For Trial")?.cases[0].caseNumber).toBe("T20250100"); + expect(result.groupedCategories.find((g) => g.category === "For Appeal")?.cases[0].caseNumber).toBe("T20250100"); + }); + + it("should return original FixedDate string when it is not a valid ISO date", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + CourtLists: [ + { + WithFixedDate: [ + { + Fixture: [ + { + FixedDate: "not-a-date", + Cases: [{ CaseNumber: "T20250200", Defendants: [] }] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + const group = result.groupedCategories.find((g) => g.category === ""); + expect(group?.cases[0].fixedFor).toBe("not-a-date"); + }); + + it("should use empty string for caseNumber when CaseNumber is absent", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + CourtLists: [ + { + WithFixedDate: [ + { + Fixture: [ + { + FixedDate: "2025-11-22", + Cases: [{ Defendants: [] } as any] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + const group = result.groupedCategories.find((g) => g.category === ""); + expect(group?.cases[0].caseNumber).toBe(""); + }); + + it("should use empty string for linkedCase entry when CaseNumber is absent", async () => { + const input = { + ...baseInput, + WarnedList: { + ...baseInput.WarnedList, + CourtLists: [ + { + WithFixedDate: [ + { + Fixture: [ + { + FixedDate: "2025-11-22", + Cases: [ + { + CaseNumber: "T20250300", + Defendants: [], + LinkedCases: [{}] as any + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = await renderCrownWarnedListData(input, { + locationId: "102", + contentDate: new Date("2025-11-10"), + locale: "en" + }); + + const group = result.groupedCategories.find((g) => g.category === ""); + expect(group?.cases[0].linkedCases).toBe(""); + }); +}); diff --git a/libs/list-types/crown-warned-list/src/rendering/renderer.ts b/libs/list-types/crown-warned-list/src/rendering/renderer.ts new file mode 100644 index 000000000..ffaa11ed1 --- /dev/null +++ b/libs/list-types/crown-warned-list/src/rendering/renderer.ts @@ -0,0 +1,131 @@ +import { formatContentDate, formatCrownLastUpdated, formatPddaDefendantName } from "@hmcts/list-types-common"; +import { getLocationById } from "@hmcts/location"; +import { DateTime } from "luxon"; +import { formatShortDate } from "../date-formatting.js"; +import type { CrownWarnedCaseRow, CrownWarnedListData, GroupedHearingCategory, PddaCase, PddaDefendant, RenderOptions } from "../models/types.js"; + +export const TO_BE_ALLOCATED_KEY = "TO_BE_ALLOCATED"; +const CUSTODY_STATUSES = ["On remand", "In custody", "In care"]; + +export async function renderCrownWarnedListData(jsonData: CrownWarnedListData, options: RenderOptions) { + const { WarnedList } = jsonData; + const location = await getLocationById(Number.parseInt(options.locationId, 10)); + const locationName = options.locale === "cy" && location?.welshName ? location.welshName : location?.name || WarnedList.CrownCourt.CourtHouseName; + + const address = WarnedList.CrownCourt.CourtHouseAddress; + const dateSeparator = options.locale === "cy" ? "i" : "to"; + const formattedStart = WarnedList.ListHeader.StartDate ? formatLongDate(WarnedList.ListHeader.StartDate, options.locale) : ""; + const formattedEnd = WarnedList.ListHeader.EndDate ? formatLongDate(WarnedList.ListHeader.EndDate, options.locale) : ""; + const dateRange = formattedStart && formattedEnd ? `${formattedStart} ${dateSeparator} ${formattedEnd}` : formattedStart || formattedEnd || ""; + + const header = { + locationName, + addressLines: formatAddress(address), + dateRange, + lastUpdated: WarnedList.ListHeader.PublishedTime ? formatCrownLastUpdated(WarnedList.ListHeader.PublishedTime, options.locale) : "", + weekCommencing: formatContentDate(toStartOfWeek(options.contentDate), options.locale), + version: WarnedList.ListHeader.Version || "" + }; + + const openJustice = { + venueName: WarnedList.CrownCourt.CourtHouseName, + email: "", + phone: WarnedList.CrownCourt.CourtHouseTelephone || "" + }; + + const categoryMap: Map = new Map(); + for (const courtList of WarnedList.CourtLists) { + for (const entry of courtList.WithFixedDate ?? []) { + for (const fixture of entry.Fixture ?? []) { + for (const caseItem of fixture.Cases ?? []) { + const hearings = caseItem.Hearing ?? []; + const iterations = hearings.length > 0 ? hearings : [undefined]; + for (const hearing of iterations) { + const category = hearing?.HearingDescription || ""; + if (!categoryMap.has(category)) categoryMap.set(category, []); + categoryMap.get(category)!.push(processCase(caseItem, fixture.FixedDate, hearing)); + } + } + } + } + + for (const entry of courtList.WithoutFixedDate ?? []) { + if (!categoryMap.has(TO_BE_ALLOCATED_KEY)) categoryMap.set(TO_BE_ALLOCATED_KEY, []); + for (const fixture of entry.Fixture ?? []) { + for (const caseItem of fixture.Cases ?? []) { + const hearings = caseItem.Hearing ?? []; + const iterations = hearings.length > 0 ? hearings : [undefined]; + for (const hearing of iterations) { + categoryMap.get(TO_BE_ALLOCATED_KEY)!.push(processCase(caseItem, fixture.FixedDate, hearing)); + } + } + } + } + } + + for (const cases of categoryMap.values()) { + cases.sort((a, b) => { + const aDate = a.fixedFor ? new Date(a.fixedFor.split("/").reverse().join("-")).getTime() : 0; + const bDate = b.fixedFor ? new Date(b.fixedFor.split("/").reverse().join("-")).getTime() : 0; + return aDate - bDate; + }); + } + + const groupedCategories: GroupedHearingCategory[] = []; + for (const [category, cases] of categoryMap.entries()) { + if (cases.length > 0) groupedCategories.push({ category, cases }); + } + + return { header, openJustice, groupedCategories }; +} + +function formatAddress(address: CrownWarnedListData["WarnedList"]["CrownCourt"]["CourtHouseAddress"]): string[] { + if (!address) return []; + const parts: string[] = []; + for (const line of address.Line ?? []) { + if (line && line.length > 0) parts.push(line); + } + if (address.PostCode && address.PostCode.length > 0) parts.push(address.PostCode); + return parts; +} + +function formatLongDate(dateStr: string | undefined, locale: string): string { + if (!dateStr) return ""; + const localeCode = locale === "cy" ? "cy-GB" : "en-GB"; + const dt = DateTime.fromISO(dateStr); + if (!dt.isValid) return dateStr; + return dt.toJSDate().toLocaleDateString(localeCode, { day: "2-digit", month: "long", year: "numeric" }); +} + +function toStartOfWeek(date: Date): Date { + const dt = DateTime.fromJSDate(date); + if (dt.weekday === 1) return date; + return dt.startOf("week").toJSDate(); +} + +function formatDefendantName(defendant: PddaDefendant): string { + return formatPddaDefendantName(defendant.PersonalDetails); +} + +function isDefendantInCustody(defendant: PddaDefendant): boolean { + return CUSTODY_STATUSES.includes(defendant.PersonalDetails.CustodyStatus ?? ""); +} + +type Hearing = NonNullable[0]; + +function processCase(caseItem: PddaCase, fixedDate: string | undefined, hearing: Hearing | undefined): CrownWarnedCaseRow { + const defendants = caseItem.Defendants ?? []; + const names = defendants.map(formatDefendantName).filter((n) => n.length > 0); + const inCustody = defendants.some(isDefendantInCustody); + const linkedCases = (caseItem.LinkedCases ?? []).map((lc) => lc.CaseNumber ?? "").filter((n) => n.length > 0); + + return { + fixedFor: formatShortDate(fixedDate), + caseNumber: caseItem.CaseNumberCaTH ?? caseItem.CaseNumber ?? "", + defendants: names.join(", "), + prosecutingAuthority: caseItem.Prosecution?.ProsecutingAuthority ?? "", + linkedCases: linkedCases.join(", "), + listingNotes: hearing?.ListNote ?? "", + isInCustody: inCustody + }; +} diff --git a/libs/list-types/crown-warned-list/src/schemas/crown-warned-list.json b/libs/list-types/crown-warned-list/src/schemas/crown-warned-list.json new file mode 100644 index 000000000..b5b3154ac --- /dev/null +++ b/libs/list-types/crown-warned-list/src/schemas/crown-warned-list.json @@ -0,0 +1,675 @@ +{ + "$defs": { + "CaseArrivedFrom": { + "type": "object", + "oneOf": [ + { + "required": ["OriginatingCourt"], + "properties": { + "OriginatingCourt": { + "type": "object", + "required": ["CourtHouseCode", "CourtHouseName"], + "properties": { + "CourtHouseCode": { "type": "integer" }, + "CourtHouseShortName": { + "type": "string" + }, + "CourtHouseName": { + "type": "string" + } + } + } + }, + "additionalProperties": false + }, + { + "required": ["Section53"], + "properties": { + "Section53": { + "type": "string", + "enum": ["yes", "no"] + } + }, + "additionalProperties": false + } + ] + }, + "Address": { + "type": "object", + "properties": { + "Line": { + "type": "array", + "maxItems": 5, + "items": { "type": "string" } + }, + "PostCode": { "type": "string" } + } + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Root", + "type": "object", + "required": ["WarnedList"], + "properties": { + "WarnedList": { + "type": "object", + "required": ["DocumentID", "ListHeader", "CrownCourt", "CourtLists"], + "properties": { + "DocumentID": { + "type": "object", + "required": ["UniqueID", "DocumentType"], + "properties": { + "UniqueID": { + "type": "string" + }, + "DocumentType": { + "type": "string" + } + } + }, + "ListHeader": { + "type": "object", + "required": ["StartDate", "Version", "PublishedTime"], + "properties": { + "StartDate": { + "type": "string", + "format": "date" + }, + "EndDate": { + "type": "string", + "format": "date" + }, + "Version": { + "type": "string" + }, + "PublishedTime": { + "type": "string", + "format": "date-time" + } + } + }, + "CrownCourt": { + "type": "object", + "required": ["CourtHouseType", "CourtHouseCode", "CourtHouseName"], + "properties": { + "CourtHouseType": { + "type": "string", + "enum": ["Crown Court", "Magistrates Court", "Youth Court"] + }, + "CourtHouseCode": { "type": "integer" }, + "CourtHouseName": { + "type": "string" + }, + "CourtHouseAddress": { + "$ref": "#/$defs/Address" + }, + "CourtHouseTelephone": { + "type": "string" + } + } + }, + "ListingInstruction": { + "type": "array", + "maxItems": 3, + "items": { + "type": "string" + } + }, + "CourtLists": { + "type": "array", + "items": { + "type": "object", + "required": ["CourtHouse"], + "properties": { + "CourtHouse": { + "type": "object", + "required": ["CourtHouseType", "CourtHouseCode", "CourtHouseName"], + "properties": { + "CourtHouseType": { + "type": "string", + "enum": ["Crown Court", "Magistrates Court", "Youth Court"] + }, + "CourtHouseCode": { "type": "integer" }, + "CourtHouseShortName": { + "type": "string" + }, + "CourtHouseName": { + "type": "string" + }, + "CourtHouseAddress": { + "$ref": "#/$defs/Address" + }, + "CourtHouseTelephone": { + "type": "string" + } + } + }, + "WithFixedDate": { + "type": "array", + "items": { + "type": "object", + "required": ["Fixture"], + "properties": { + "HearingType": { + "type": "string", + "pattern": "^[A-Z]{3}$" + }, + "Fixture": { + "type": "array", + "items": { + "type": "object", + "required": ["Cases"], + "properties": { + "FixedDate": { + "type": "string", + "format": "date" + }, + "Notes": { + "type": "string" + }, + "Cases": { + "type": "array", + "items": { + "type": "object", + "required": ["CaseNumber", "Defendants", "CaseNumberCaTH"], + "properties": { + "CaseNumber": { "type": "string" }, + "CaseNumberCaTH": { "type": "string" }, + "CaseArrivedFrom": { + "description": "Case arrived from details for WithFixedDate", + "$ref": "#/$defs/CaseArrivedFrom" + }, + "Hearing": { + "type": "array", + "items": { + "type": "object", + "required": ["HearingDescription"], + "properties": { + "HearingDescription": { + "type": "string" + }, + "HearingDate": { + "type": "string", + "format": "date" + }, + "HearingEndDate": { + "type": "string", + "format": "date" + }, + "ListNote": { + "type": "string" + }, + "HearingType": { + "type": "string", + "pattern": "^[A-Z]{3}$" + } + } + } + }, + "Defendants": { + "type": "array", + "items": { + "type": "object", + "required": ["PersonalDetails"], + "properties": { + "PersonalDetails": { + "type": "object", + "required": ["Name", "IsMasked"], + "properties": { + "Name": { + "type": "object", + "properties": { + "CitizenNameTitle": { + "type": "string", + "minLength": 1, + "maxLength": 35 + }, + "CitizenNameForename": { + "type": "array", + "items": { "type": "string" } + }, + "CitizenNameSurname": { + "type": "string", + "minLength": 1, + "maxLength": 35 + }, + "CitizenNameSuffix": { + "type": "string", + "minLength": 1, + "maxLength": 35 + }, + "CitizenNameRequestedName": { + "type": "string", + "minLength": 1, + "maxLength": 70 + } + } + }, + "MaskedName": { + "type": "string" + }, + "IsMasked": { + "type": "string", + "enum": ["yes", "no"] + }, + "DateOfBirth": { + "type": "object", + "properties": { + "BirthDate": { + "type": "string", + "format": "date" + }, + "VerifiedBy": { + "type": "string", + "enum": [ + "not verified", + "accepted on balance of probabilities", + "secondary certificate", + "certified copy of birth certificate", + "short form birth certificate or certificate of registration of birth", + "birth certificate" + ] + } + } + }, + "Age": { + "type": "integer" + }, + "Sex": { + "type": "string", + "enum": ["unknown", "male", "female", "indeterminate"] + }, + "Address": { + "$ref": "#/$defs/Address" + }, + "Nationality": { + "type": "string" + } + } + }, + "URN": { + "type": "string" + }, + "PrisonerID": { + "type": "string" + }, + "PrisonLocation": { + "type": "object", + "required": ["Location"], + "properties": { + "Location": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "CustodyStatus": { + "type": "string", + "enum": ["On bail", "On remand", "In care", "In custody", "Not applicable"] + }, + "Counsel": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Solicitor": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Party": { + "type": "object", + "properties": { + "Organisation": { + "type": "object", + "required": ["OrganisationName"], + "properties": { + "OrganisationName": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "Charges": { + "type": "array", + "items": { + "type": "object", + "required": ["OffenceStatement"], + "properties": { + "OffenceStatement": { + "type": "string" + }, + "IndictmentCountNumber": { + "type": "integer" + }, + "CJSoffenceCode": { + "type": "string" + } + } + } + } + } + } + }, + "Prosecution": { + "type": "object", + "properties": { + "ProsecutingOrganisation": { + "type": "object", + "required": ["OrganisationName"], + "properties": { + "OrganisationName": { + "type": "string" + } + } + }, + "ProsecutingAuthority": { + "type": "string", + "enum": [ + "Crown Prosecution Service", + "Customs and Excise", + "Department of Trade and Industry", + "Inland Revenue", + "Other Prosecutor" + ] + } + } + }, + "LinkedCases": { + "type": "array", + "items": { + "type": "object", + "required": ["CaseNumber"], + "properties": { + "CaseNumber": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "WithoutFixedDate": { + "type": "array", + "items": { + "type": "object", + "required": ["Fixture"], + "properties": { + "HearingType": { + "type": "string", + "pattern": "^[A-Z]{3}$" + }, + "Fixture": { + "type": "array", + "items": { + "type": "object", + "required": ["Cases"], + "properties": { + "FixedDate": { + "type": "string", + "format": "date" + }, + "Notes": { + "type": "string" + }, + "Cases": { + "type": "array", + "items": { + "type": "object", + "required": ["CaseNumber", "Defendants", "CaseNumberCaTH"], + "properties": { + "CaseNumber": { "type": "string" }, + "CaseNumberCaTH": { "type": "string" }, + "CaseArrivedFrom": { + "description": "Case arrived from details for WithoutFixedDate", + "$ref": "#/$defs/CaseArrivedFrom" + }, + "Hearing": { + "type": "array", + "items": { + "type": "object", + "required": ["HearingDescription"], + "properties": { + "HearingDescription": { + "type": "string" + }, + "HearingDate": { + "type": "string", + "format": "date" + }, + "HearingEndDate": { + "type": "string", + "format": "date" + }, + "ListNote": { + "type": "string" + }, + "HearingType": { + "type": "string", + "pattern": "^[A-Z]{3}$" + } + } + } + }, + "Defendants": { + "type": "array", + "items": { + "type": "object", + "required": ["PersonalDetails"], + "properties": { + "PersonalDetails": { + "type": "object", + "required": ["Name", "IsMasked"], + "properties": { + "Name": { + "type": "object", + "properties": { + "CitizenNameTitle": { + "type": "string", + "minLength": 1, + "maxLength": 35 + }, + "CitizenNameForename": { + "type": "array", + "items": { "type": "string" } + }, + "CitizenNameSurname": { + "type": "string", + "minLength": 1, + "maxLength": 35 + }, + "CitizenNameSuffix": { + "type": "string", + "minLength": 1, + "maxLength": 35 + }, + "CitizenNameRequestedName": { + "type": "string", + "minLength": 1, + "maxLength": 70 + } + } + }, + "MaskedName": { + "type": "string" + }, + "IsMasked": { + "type": "string", + "enum": ["yes", "no"] + }, + "DateOfBirth": { + "type": "object", + "properties": { + "BirthDate": { + "type": "string", + "format": "date" + }, + "VerifiedBy": { + "type": "string", + "enum": [ + "not verified", + "accepted on balance of probabilities", + "secondary certificate", + "certified copy of birth certificate", + "short form birth certificate or certificate of registration of birth", + "birth certificate" + ] + } + } + }, + "Age": { + "type": "integer" + }, + "Sex": { + "type": "string", + "enum": ["unknown", "male", "female", "indeterminate"] + }, + "Address": { + "$ref": "#/$defs/Address" + }, + "Nationality": { + "type": "string" + } + } + }, + "URN": { + "type": "string" + }, + "PrisonerID": { + "type": "string" + }, + "PrisonLocation": { + "type": "object", + "required": ["Location"], + "properties": { + "Location": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "CustodyStatus": { + "type": "string", + "enum": ["On bail", "On remand", "In care", "In custody", "Not applicable"] + }, + "Counsel": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Solicitor": { + "type": "array", + "items": { + "type": "object", + "properties": { + "Party": { + "type": "object", + "properties": { + "Organisation": { + "type": "object", + "required": ["OrganisationName"], + "properties": { + "OrganisationName": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "Charges": { + "type": "array", + "items": { + "type": "object", + "required": ["OffenceStatement"], + "properties": { + "OffenceStatement": { + "type": "string" + }, + "IndictmentCountNumber": { + "type": "integer" + }, + "CJSoffenceCode": { + "type": "string" + } + } + } + } + } + } + }, + "Prosecution": { + "type": "object", + "properties": { + "ProsecutingOrganisation": { + "type": "object", + "required": ["OrganisationName"], + "properties": { + "OrganisationName": { + "type": "string" + } + } + }, + "ProsecutingAuthority": { + "type": "string", + "enum": [ + "Crown Prosecution Service", + "Customs and Excise", + "Department of Trade and Industry", + "Inland Revenue", + "Other Prosecutor" + ] + } + } + }, + "LinkedCases": { + "type": "array", + "items": { + "type": "object", + "required": ["CaseNumber"], + "properties": { + "CaseNumber": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/libs/list-types/crown-warned-list/src/validation/json-validator.test.ts b/libs/list-types/crown-warned-list/src/validation/json-validator.test.ts new file mode 100644 index 000000000..8490a0b97 --- /dev/null +++ b/libs/list-types/crown-warned-list/src/validation/json-validator.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { validateCrownWarnedList } from "./json-validator.js"; + +describe("validateCrownWarnedList", () => { + it("should validate a correct crown warned list", () => { + const validData = { + WarnedList: { + DocumentID: { UniqueID: "CWPL-2025-001", DocumentType: "crown_warned_pdda_list" }, + ListHeader: { + StartDate: "2025-11-10", + PublishedTime: "2025-11-10T09:00:00", + Version: "1.0" + }, + CrownCourt: { + CourtHouseType: "Crown Court", + CourtHouseCode: 1001, + CourtHouseName: "Crown Court at Birmingham", + CourtHouseAddress: { + Line: ["Newton Street"], + PostCode: "B4 7NA" + } + }, + CourtLists: [ + { + CourtHouse: { + CourtHouseType: "Crown Court", + CourtHouseCode: 1001, + CourtHouseName: "Crown Court at Birmingham" + }, + WithFixedDate: [ + { + Fixture: [ + { + FixedDate: "2025-11-22", + Cases: [ + { + CaseNumber: "T20250001", + CaseNumberCaTH: "CaTH001", + Defendants: [ + { + PersonalDetails: { + Name: { CitizenNameForename: ["Alice"], CitizenNameSurname: "Williams" }, + IsMasked: "no" + } + } + ] + } + ] + } + ] + } + ] + } + ] + } + }; + + const result = validateCrownWarnedList(validData); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should return errors for missing required fields", () => { + const result = validateCrownWarnedList({}); + + expect(result.isValid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it("should return errors when WarnedList is missing DocumentID", () => { + const result = validateCrownWarnedList({ + WarnedList: { + ListHeader: { StartDate: "2025-11-10" }, + CrownCourt: { CourtHouseName: "Crown Court at Birmingham" }, + CourtLists: [] + } + }); + + expect(result.isValid).toBe(false); + }); + + it("should return errors when WarnedList is missing CrownCourt", () => { + const result = validateCrownWarnedList({ + WarnedList: { + DocumentID: "CWPL-2025-001", + ListHeader: { StartDate: "2025-11-10" }, + CourtLists: [] + } + }); + + expect(result.isValid).toBe(false); + }); + + it("should return errors when CrownCourt is missing CourtHouseName", () => { + const result = validateCrownWarnedList({ + WarnedList: { + DocumentID: "CWPL-2025-001", + ListHeader: { StartDate: "2025-11-10" }, + CrownCourt: {}, + CourtLists: [] + } + }); + + expect(result.isValid).toBe(false); + }); + + it("should return errors for HTML injection in DocumentID", () => { + const result = validateCrownWarnedList({ + WarnedList: { + DocumentID: "", + ListHeader: { StartDate: "2025-11-10" }, + CrownCourt: { CourtHouseName: "Crown Court at Birmingham" }, + CourtLists: [] + } + }); + + expect(result.isValid).toBe(false); + }); +}); diff --git a/libs/list-types/crown-warned-list/src/validation/json-validator.ts b/libs/list-types/crown-warned-list/src/validation/json-validator.ts new file mode 100644 index 000000000..d096bd62d --- /dev/null +++ b/libs/list-types/crown-warned-list/src/validation/json-validator.ts @@ -0,0 +1,6 @@ +import { type ValidationResult, validateJson } from "@hmcts/publication"; +import schema from "../schemas/crown-warned-list.json" with { type: "json" }; + +export function validateCrownWarnedList(jsonData: unknown): ValidationResult { + return validateJson(jsonData, schema, "1.0"); +} diff --git a/libs/list-types/crown-warned-list/tsconfig.json b/libs/list-types/crown-warned-list/tsconfig.json new file mode 100644 index 000000000..a029ee937 --- /dev/null +++ b/libs/list-types/crown-warned-list/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules", "src/assets/"] +} diff --git a/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/package.json b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/package.json new file mode 100644 index 000000000..b64a8891e --- /dev/null +++ b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/package.json @@ -0,0 +1,41 @@ +{ + "name": "@hmcts/ftt-lands-registration-tribunal-weekly-hearing-list", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.ts" + } + }, + "scripts": { + "build": "tsc && yarn build:nunjucks && yarn build:schemas", + "build:nunjucks": "mkdir -p dist/pdf && cd src/pdf && find . -name '*.njk' -exec sh -c 'mkdir -p ../../dist/pdf/$(dirname {}) && cp {} ../../dist/pdf/{}' \\;", + "build:schemas": "mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ." + }, + "dependencies": { + "@hmcts/list-types-common": "workspace:*", + "@hmcts/pdf-generation": "workspace:*", + "luxon": "3.7.2", + "nunjucks": "3.2.4" + }, + "devDependencies": { + "@types/luxon": "3.7.1", + "@types/node": "24.10.4", + "typescript": "6.0.3", + "vitest": "4.1.9" + }, + "peerDependencies": { + "express": "^5.1.0" + } +} diff --git a/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/config.test.ts b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/config.test.ts new file mode 100644 index 000000000..1ff1bd64c --- /dev/null +++ b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/config.test.ts @@ -0,0 +1,48 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { assets, moduleRoot } from "./config.js"; + +describe("ftt-lands-registration-tribunal-weekly-hearing-list config", () => { + describe("moduleRoot", () => { + it("should be defined", () => { + expect(moduleRoot).toBeDefined(); + expect(typeof moduleRoot).toBe("string"); + }); + + it("should point to an existing directory", () => { + expect(existsSync(moduleRoot)).toBe(true); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(moduleRoot)).toBe(true); + }); + }); + + describe("assets", () => { + it("should be defined", () => { + expect(assets).toBeDefined(); + expect(typeof assets).toBe("string"); + }); + + it("should point to assets directory", () => { + expect(assets).toContain("assets"); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(assets)).toBe(true); + }); + + it("should have valid path structure", () => { + expect(assets).toBeTruthy(); + }); + + it("should end with trailing slash", () => { + expect(assets).toMatch(/\/$/); + }); + + it("should be subdirectory of moduleRoot", () => { + expect(assets.startsWith(moduleRoot)).toBe(true); + }); + }); +}); diff --git a/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/config.ts b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/config.ts new file mode 100644 index 000000000..fa44ca66b --- /dev/null +++ b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/config.ts @@ -0,0 +1,9 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const moduleRoot = __dirname; +export const assets = path.join(__dirname, "assets/"); +export const schemaPath = path.join(__dirname, "schemas/ftt-lands-registration-tribunal-weekly-hearing-list.json"); diff --git a/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/conversion/ftt-lrt-config.ts b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/conversion/ftt-lrt-config.ts new file mode 100644 index 000000000..ab9085379 --- /dev/null +++ b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/conversion/ftt-lrt-config.ts @@ -0,0 +1,58 @@ +import { + createConverter, + DD_MM_YYYY_PATTERN, + type ExcelConverterConfig, + registerConverter, + registerConverterByName, + validateDateFormat, + validateNoHtmlTags +} from "@hmcts/list-types-common"; + +// First-tier Tribunal (Land Registration Tribunal) Weekly Hearing List (listTypeId: 32) +export const FTT_LRT_EXCEL_CONFIG: ExcelConverterConfig = { + fields: [ + { + header: "Date", + fieldName: "date", + required: true, + validators: [validateDateFormat(DD_MM_YYYY_PATTERN, "dd/MM/yyyy (e.g., 02/01/2025)")] + }, + { + header: "Hearing Time", + fieldName: "hearingTime", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Hearing Time", rowNumber)] + }, + { + header: "Case Name", + fieldName: "caseName", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case Name", rowNumber)] + }, + { + header: "Case Reference Number", + fieldName: "caseReferenceNumber", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case Reference Number", rowNumber)] + }, + { + header: "Judge", + fieldName: "judge", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Judge", rowNumber)] + }, + { + header: "Venue/Platform", + fieldName: "venuePlatform", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Venue/Platform", rowNumber)] + } + ], + minRows: 1 +}; + +// Register the FTT LRT converter with listTypeId 32 and by name +// Name-based registration handles environments where the DB ID differs from the canonical seeded ID +const fttLrtConverter = createConverter(FTT_LRT_EXCEL_CONFIG); +registerConverter(32, fttLrtConverter); +registerConverterByName("FTT_LANDS_REGISTRATION_TRIBUNAL_WEEKLY_HEARING_LIST", fttLrtConverter); diff --git a/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/email-summary/summary-builder.test.ts b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/email-summary/summary-builder.test.ts new file mode 100644 index 000000000..c0fdcef39 --- /dev/null +++ b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/email-summary/summary-builder.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import type { FttLrtHearingList } from "../models/types.js"; +import { extractCaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "./summary-builder.js"; + +describe("SPECIAL_CATEGORY_DATA_WARNING", () => { + it("should contain required warning text", () => { + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Special Category Data"); + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Data Protection Act 2018"); + }); +}); + +describe("extractCaseSummary", () => { + it("should extract case summaries from hearing list", () => { + // Arrange + const hearingList: FttLrtHearingList = [ + { + date: "01/01/2025", + hearingTime: "10:00am", + caseName: "A Vs B", + caseReferenceNumber: "LRT/00001/2025", + judge: "Judge Smith", + venuePlatform: "London" + }, + { + date: "02/01/2025", + hearingTime: "2:00pm", + caseName: "C Vs D", + caseReferenceNumber: "LRT/00002/2025", + judge: "Judge Brown", + venuePlatform: "Manchester" + } + ]; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(2); + expect(result[0]).toEqual([ + { label: "Date", value: "01/01/2025" }, + { label: "Hearing time", value: "10:00am" }, + { label: "Case reference number", value: "LRT/00001/2025" } + ]); + expect(result[1]).toEqual([ + { label: "Date", value: "02/01/2025" }, + { label: "Hearing time", value: "2:00pm" }, + { label: "Case reference number", value: "LRT/00002/2025" } + ]); + }); + + it("should handle empty hearing list", () => { + // Arrange + const hearingList: FttLrtHearingList = []; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(0); + }); + + it("should handle missing case details with empty string", () => { + // Arrange + const hearingList: FttLrtHearingList = [ + { + date: "", + hearingTime: "", + caseName: "", + caseReferenceNumber: "", + judge: "", + venuePlatform: "" + } + ]; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(1); + expect(result[0]).toEqual([ + { label: "Date", value: "" }, + { label: "Hearing time", value: "" }, + { label: "Case reference number", value: "" } + ]); + }); +}); + +describe("formatCaseSummaryForEmail", () => { + it("should format single case summary correctly", () => { + // Arrange + const items = [ + [ + { label: "Date", value: "01/01/2025" }, + { label: "Hearing time", value: "10:00am" }, + { label: "Case reference number", value: "LRT/00001/2025" } + ] + ]; + + // Act + const result = formatCaseSummaryForEmail(items); + + // Assert + expect(result).toContain("---"); + expect(result).toContain("Date - 01/01/2025"); + expect(result).toContain("Hearing time - 10:00am"); + expect(result).toContain("Case reference number - LRT/00001/2025"); + }); + + it("should handle empty case list", () => { + // Act + const result = formatCaseSummaryForEmail([]); + + // Assert + expect(result).toBe("No cases scheduled."); + }); +}); diff --git a/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/email-summary/summary-builder.ts b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/email-summary/summary-builder.ts new file mode 100644 index 000000000..a0631e624 --- /dev/null +++ b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/email-summary/summary-builder.ts @@ -0,0 +1,12 @@ +import { type CaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "@hmcts/list-types-common"; +import type { FttLrtHearingList } from "../models/types.js"; + +export { formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING }; + +export function extractCaseSummary(jsonData: FttLrtHearingList): CaseSummary[] { + return jsonData.map((hearing) => [ + { label: "Date", value: hearing.date || "" }, + { label: "Hearing time", value: hearing.hearingTime || "" }, + { label: "Case reference number", value: hearing.caseReferenceNumber || "" } + ]); +} diff --git a/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/index.ts b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/index.ts new file mode 100644 index 000000000..8dc243368 --- /dev/null +++ b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/index.ts @@ -0,0 +1,11 @@ +import "./conversion/ftt-lrt-config.js"; // Register converter on module load + +// Business logic exports +export type { ValidationResult } from "@hmcts/publication"; +export * from "./email-summary/summary-builder.js"; +export { cy as fttLrtWeeklyHearingListCy } from "./locales/cy.js"; +// Locale exports +export { en as fttLrtWeeklyHearingListEn } from "./locales/en.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export * from "./rendering/renderer.js"; diff --git a/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/locales/cy.ts b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/locales/cy.ts new file mode 100644 index 000000000..5031add88 --- /dev/null +++ b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/locales/cy.ts @@ -0,0 +1,33 @@ +import { provenanceLabelsCy as provenanceLabels } from "@hmcts/list-types-common"; + +export const cy = { + pageTitle: "First-tier Tribunal (Land Registration Tribunal) Weekly Hearing List", + listForWeekCommencing: "List for week commencing", + lastUpdated: "Last updated", + at: "at", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + importantInformationTitle: "Important information", + importantInformationText: + 'Members of the public wishing to observe a hearing or representatives of the media may, on their request, join any telephone or video hearing remotely while they are taking place by sending an email in advance to the tribunal at [insert office email] with the following details in the subject line "[OBSERVER/MEDIA] REQUEST – [case reference] – [hearing date] (need to include any other information required by the tribunal)" and appropriate arrangements will be made to allow access where reasonably practicable.', + importantInformationLinkText: "Observe a court or tribunal hearing as a journalist, researcher or member of the public", + importantInformationLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + searchCasesTitle: "Search Cases", + searchCasesLabel: "Search by case name, date, judge, or other details", + tableHeaders: { + date: "Date", + hearingTime: "Hearing time", + caseName: "Case name", + caseReferenceNumber: "Case reference number", + judge: "Judge", + venuePlatform: "Venue/Platform" + }, + dataSource: "Data source", + backToTop: "Back to top", + cautionNote: + "Note this document contains Special Category Data as defined by Data Protection Act 2018, formally known as Sensitive Personal Data, and should be handled appropriately.", + cautionReporting: + "This document contains information intended to assist the accurate reporting of court proceedings. It is vital you ensure that you safeguard the Special Category Data included and abide by reporting restrictions (for example on victims and children). HMCTS will stop sending the data if there is concern about how it will be used.", + provenanceLabels +}; diff --git a/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/locales/en.ts b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/locales/en.ts new file mode 100644 index 000000000..ab1c4c8de --- /dev/null +++ b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/locales/en.ts @@ -0,0 +1,33 @@ +import { provenanceLabelsEn as provenanceLabels } from "@hmcts/list-types-common"; + +export const en = { + pageTitle: "First-tier Tribunal (Land Registration Tribunal) Weekly Hearing List", + listForWeekCommencing: "List for week commencing", + lastUpdated: "Last updated", + at: "at", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + importantInformationTitle: "Important information", + importantInformationText: + 'Members of the public wishing to observe a hearing or representatives of the media may, on their request, join any telephone or video hearing remotely while they are taking place by sending an email in advance to the tribunal at [insert office email] with the following details in the subject line "[OBSERVER/MEDIA] REQUEST – [case reference] – [hearing date] (need to include any other information required by the tribunal)" and appropriate arrangements will be made to allow access where reasonably practicable.', + importantInformationLinkText: "Observe a court or tribunal hearing as a journalist, researcher or member of the public", + importantInformationLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + searchCasesTitle: "Search Cases", + searchCasesLabel: "Search by case name, date, judge, or other details", + tableHeaders: { + date: "Date", + hearingTime: "Hearing time", + caseName: "Case name", + caseReferenceNumber: "Case reference number", + judge: "Judge", + venuePlatform: "Venue/Platform" + }, + dataSource: "Data source", + backToTop: "Back to top", + cautionNote: + "Note this document contains Special Category Data as defined by Data Protection Act 2018, formally known as Sensitive Personal Data, and should be handled appropriately.", + cautionReporting: + "This document contains information intended to assist the accurate reporting of court proceedings. It is vital you ensure that you safeguard the Special Category Data included and abide by reporting restrictions (for example on victims and children). HMCTS will stop sending the data if there is concern about how it will be used.", + provenanceLabels +}; diff --git a/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/models/types.ts b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/models/types.ts new file mode 100644 index 000000000..33822d945 --- /dev/null +++ b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/models/types.ts @@ -0,0 +1,10 @@ +export interface FttLrtHearing { + date: string; + hearingTime: string; + caseName: string; + caseReferenceNumber: string; + judge: string; + venuePlatform: string; +} + +export type FttLrtHearingList = FttLrtHearing[]; diff --git a/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/pdf/pdf-generator.test.ts b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/pdf/pdf-generator.test.ts new file mode 100644 index 000000000..5f1af6429 --- /dev/null +++ b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/pdf/pdf-generator.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockUploadBlob } = vi.hoisted(() => ({ + mockUploadBlob: vi.fn() +})); +vi.mock("@hmcts/azure-blob", () => ({ + uploadBlob: mockUploadBlob, + CONTAINER: { ARTEFACT: "artefact", PUBLICATIONS: "publications" } +})); + +vi.mock("@hmcts/pdf-generation", () => ({ + generatePdfFromHtml: vi.fn() +})); + +vi.mock("../rendering/renderer.js", () => ({ + renderFttLrtData: vi.fn() +})); + +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { renderFttLrtData } from "../rendering/renderer.js"; +import { generateFttLrtWeeklyHearingListPdf } from "./pdf-generator.js"; + +const mockRenderedData = { + header: { + listTitle: "First-tier Tribunal (Land Registration Tribunal) Weekly Hearing List", + weekCommencingDate: "01 January 2025", + lastUpdatedDate: "12 November 2025", + lastUpdatedTime: "9am" + }, + hearings: [] +}; + +const mockHearingList = [ + { + date: "01/01/2025", + hearingTime: "10:00am", + caseName: "A Vs B", + caseReferenceNumber: "LRT/00001/2025", + judge: "Judge Smith", + venuePlatform: "London" + } +]; + +describe("generateFttLrtWeeklyHearingListPdf", () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(renderFttLrtData).mockReturnValue(mockRenderedData); + mockUploadBlob.mockResolvedValue(undefined); + }); + + it("should generate PDF successfully", async () => { + // Arrange + const pdfBuffer = Buffer.from("PDF content"); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer, + sizeBytes: 1024 + }); + + // Act + const result = await generateFttLrtWeeklyHearingListPdf({ + artefactId: "test-artefact-123", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(true); + expect(result.pdfPath).toContain("test-artefact-123.pdf"); + expect(result.sizeBytes).toBe(1024); + expect(result.exceedsMaxSize).toBe(false); + }); + + it("should return exceedsMaxSize true when PDF is over 2MB", async () => { + // Arrange + const largePdfBuffer = Buffer.alloc(3 * 1024 * 1024); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: largePdfBuffer, + sizeBytes: 3 * 1024 * 1024 + }); + + // Act + const result = await generateFttLrtWeeklyHearingListPdf({ + artefactId: "large-pdf-123", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(true); + expect(result.exceedsMaxSize).toBe(true); + }); + + it("should return error when PDF generation fails", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: false, + error: "Puppeteer crashed" + }); + + // Act + const result = await generateFttLrtWeeklyHearingListPdf({ + artefactId: "failed-pdf", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("Puppeteer crashed"); + }); + + it("should pass correct render options to renderer", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + const contentDate = new Date("2025-06-15"); + + // Act + await generateFttLrtWeeklyHearingListPdf({ + artefactId: "test-render-options", + contentDate, + locale: "cy", + locationId: "999", + jsonData: mockHearingList + }); + + // Assert + expect(renderFttLrtData).toHaveBeenCalledWith(mockHearingList, { + locale: "cy", + courtName: "First-tier Tribunal (Land Registration Tribunal)", + contentDate, + lastReceivedDate: expect.any(String), + listTitle: "First-tier Tribunal (Land Registration Tribunal) Weekly Hearing List" + }); + }); +}); diff --git a/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/pdf/pdf-generator.ts b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/pdf/pdf-generator.ts new file mode 100644 index 000000000..5b4739fba --- /dev/null +++ b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/pdf/pdf-generator.ts @@ -0,0 +1,28 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { type BasePdfGenerationOptions, generateFttSiacWeeklyHearingListPdf, type PdfGenerationResult } from "@hmcts/list-types-common"; +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { PROVENANCE_LABELS } from "@hmcts/publication"; +import type { FttLrtHearingList } from "../models/types.js"; +import { renderFttLrtData } from "../rendering/renderer.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface PdfGenerationOptions extends BasePdfGenerationOptions { + contentDate: Date; +} + +export async function generateFttLrtWeeklyHearingListPdf(options: PdfGenerationOptions): Promise { + return generateFttSiacWeeklyHearingListPdf({ + ...options, + courtName: "First-tier Tribunal (Land Registration Tribunal)", + listTitle: "First-tier Tribunal (Land Registration Tribunal) Weekly Hearing List", + moduleDir: __dirname, + provenanceLabel: options.provenance ? PROVENANCE_LABELS[options.provenance as keyof typeof PROVENANCE_LABELS] || options.provenance : "", + importEn: () => import("../locales/en.js"), + importCy: () => import("../locales/cy.js"), + generatePdf: generatePdfFromHtml, + renderData: renderFttLrtData + }); +} diff --git a/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/pdf/pdf-template.njk b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/pdf/pdf-template.njk new file mode 100644 index 000000000..8b998e9e8 --- /dev/null +++ b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/pdf/pdf-template.njk @@ -0,0 +1,64 @@ + + + + + + {{ header.listTitle }} + + + +
+

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForWeekCommencing }} {{ header.weekCommencingDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+
+ +
+

{{ t.importantInformationTitle }}

+

{{ t.importantInformationText }}

+

{{ t.importantInformationLinkText }}

+
+ + {% if hearings.length > 0 %} + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.date }}{{ t.tableHeaders.hearingTime }}{{ t.tableHeaders.caseName }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.judge }}{{ t.tableHeaders.venuePlatform }}
{{ hearing.date }}{{ hearing.hearingTime }}{{ hearing.caseName }}{{ hearing.caseReferenceNumber }}{{ hearing.judge }}{{ hearing.venuePlatform }}
+ {% else %} +

No hearings scheduled.

+ {% endif %} + + + + diff --git a/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/rendering/renderer.test.ts b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..0bd0d08b0 --- /dev/null +++ b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/rendering/renderer.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import type { FttLrtHearingList } from "../models/types.js"; +import { renderFttLrtData } from "./renderer.js"; + +describe("renderFttLrtData", () => { + it("should render hearing list with formatted dates", () => { + // Arrange + const hearingList: FttLrtHearingList = [ + { + date: "02/01/2025", + hearingTime: "10:00am", + caseName: "A Vs B", + caseReferenceNumber: "LRT/00001/2025", + judge: "Judge Smith", + venuePlatform: "London" + } + ]; + + const options = { + locale: "en", + courtName: "First-tier Tribunal (Land Registration Tribunal)", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "First-tier Tribunal (Land Registration Tribunal) Weekly Hearing List" + }; + + // Act + const result = renderFttLrtData(hearingList, options); + + // Assert + expect(result.header.listTitle).toBe("First-tier Tribunal (Land Registration Tribunal) Weekly Hearing List"); + expect(result.header.weekCommencingDate).toBe("02 January 2025"); + expect(result.header.lastUpdatedDate).toBe("01 January 2025"); + expect(result.header.lastUpdatedTime).toContain("am"); + + expect(result.hearings).toHaveLength(1); + expect(result.hearings[0].date).toBe("02 January 2025"); + expect(result.hearings[0].hearingTime).toBe("10:00am"); + expect(result.hearings[0].caseName).toBe("A Vs B"); + expect(result.hearings[0].caseReferenceNumber).toBe("LRT/00001/2025"); + expect(result.hearings[0].judge).toBe("Judge Smith"); + expect(result.hearings[0].venuePlatform).toBe("London"); + }); + + it("should render multiple hearings", () => { + // Arrange + const hearingList: FttLrtHearingList = [ + { + date: "02/01/2025", + hearingTime: "10:00am", + caseName: "A Vs B", + caseReferenceNumber: "LRT/00001/2025", + judge: "Judge Smith", + venuePlatform: "London" + }, + { + date: "03/01/2025", + hearingTime: "2:00pm", + caseName: "C Vs D", + caseReferenceNumber: "LRT/00002/2025", + judge: "Judge Brown", + venuePlatform: "Manchester" + } + ]; + + const options = { + locale: "en", + courtName: "First-tier Tribunal (Land Registration Tribunal)", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "First-tier Tribunal (Land Registration Tribunal) Weekly Hearing List" + }; + + // Act + const result = renderFttLrtData(hearingList, options); + + // Assert + expect(result.hearings).toHaveLength(2); + expect(result.hearings[0].date).toBe("02 January 2025"); + expect(result.hearings[1].date).toBe("03 January 2025"); + }); + + it("should handle empty hearing list", () => { + // Arrange + const hearingList: FttLrtHearingList = []; + + const options = { + locale: "en", + courtName: "First-tier Tribunal (Land Registration Tribunal)", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "First-tier Tribunal (Land Registration Tribunal) Weekly Hearing List" + }; + + // Act + const result = renderFttLrtData(hearingList, options); + + // Assert + expect(result.hearings).toHaveLength(0); + expect(result.header.listTitle).toBe("First-tier Tribunal (Land Registration Tribunal) Weekly Hearing List"); + }); + + it("should format lastUpdated time correctly", () => { + // Arrange + const hearingList: FttLrtHearingList = [ + { + date: "02/01/2025", + hearingTime: "10:00am", + caseName: "A Vs B", + caseReferenceNumber: "LRT/00001/2025", + judge: "Judge Smith", + venuePlatform: "London" + } + ]; + + const options = { + locale: "en", + courtName: "First-tier Tribunal (Land Registration Tribunal)", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T14:30:00Z", + listTitle: "First-tier Tribunal (Land Registration Tribunal) Weekly Hearing List" + }; + + // Act + const result = renderFttLrtData(hearingList, options); + + // Assert + expect(result.header.lastUpdatedTime).toBe("2:30pm"); + }); +}); diff --git a/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/rendering/renderer.ts b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/rendering/renderer.ts new file mode 100644 index 000000000..234027575 --- /dev/null +++ b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/rendering/renderer.ts @@ -0,0 +1,44 @@ +import { formatDdMmYyyyDate, formatDisplayDate, formatLastUpdatedDateTime } from "@hmcts/list-types-common"; +import type { FttLrtHearing, FttLrtHearingList } from "../models/types.js"; + +export interface RenderOptions { + locale: string; + courtName: string; + contentDate: Date; + lastReceivedDate: string; + listTitle: string; +} + +export interface RenderedData { + header: { + listTitle: string; + weekCommencingDate: string; + lastUpdatedDate: string; + lastUpdatedTime: string; + }; + hearings: FttLrtHearing[]; +} + +export function renderFttLrtData(hearingList: FttLrtHearingList, options: RenderOptions): RenderedData { + const weekCommencingDate = formatDisplayDate(options.contentDate, options.locale); + const { date: lastUpdatedDate, time: lastUpdatedTime } = formatLastUpdatedDateTime(options.lastReceivedDate, options.locale); + + const renderedHearings = hearingList.map((hearing) => ({ + date: formatDdMmYyyyDate(hearing.date, options.locale), + hearingTime: hearing.hearingTime, + caseName: hearing.caseName, + caseReferenceNumber: hearing.caseReferenceNumber, + judge: hearing.judge, + venuePlatform: hearing.venuePlatform + })); + + return { + header: { + listTitle: options.listTitle, + weekCommencingDate, + lastUpdatedDate, + lastUpdatedTime + }, + hearings: renderedHearings + }; +} diff --git a/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/schemas/ftt-lands-registration-tribunal-weekly-hearing-list.json b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/schemas/ftt-lands-registration-tribunal-weekly-hearing-list.json new file mode 100644 index 000000000..a425dba9a --- /dev/null +++ b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/src/schemas/ftt-lands-registration-tribunal-weekly-hearing-list.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "First-tier Tribunal (Land Registration Tribunal) Weekly Hearing List", + "description": "Schema for First-tier Tribunal (Land Registration Tribunal) Weekly Hearing List from pip-data-management", + "type": "array", + "items": { + "type": "object", + "required": ["date", "hearingTime", "caseName", "caseReferenceNumber", "judge", "venuePlatform"], + "properties": { + "date": { + "title": "Date", + "type": "string", + "pattern": "^\\d{2}/\\d{2}/\\d{4}$", + "examples": ["02/01/2025"] + }, + "hearingTime": { + "title": "Hearing Time", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["10:00am"] + }, + "caseName": { + "title": "Case Name", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["A Vs B"] + }, + "caseReferenceNumber": { + "title": "Case Reference Number", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["LRT/00001/2025"] + }, + "judge": { + "title": "Judge", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["Judge Smith"] + }, + "venuePlatform": { + "title": "Venue/Platform", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["London"] + } + } + } +} diff --git a/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/tsconfig.json b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/tsconfig.json new file mode 100644 index 000000000..a029ee937 --- /dev/null +++ b/libs/list-types/ftt-lands-registration-tribunal-weekly-hearing-list/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules", "src/assets/"] +} diff --git a/libs/list-types/ftt-rpt-weekly-hearing-list/package.json b/libs/list-types/ftt-rpt-weekly-hearing-list/package.json new file mode 100644 index 000000000..78314c781 --- /dev/null +++ b/libs/list-types/ftt-rpt-weekly-hearing-list/package.json @@ -0,0 +1,42 @@ +{ + "name": "@hmcts/ftt-rpt-weekly-hearing-list", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.ts" + } + }, + "scripts": { + "build": "tsc && yarn build:nunjucks && yarn build:views && yarn build:schemas", + "build:nunjucks": "mkdir -p dist/pdf && cd src/pdf && find . -name '*.njk' -exec sh -c 'mkdir -p ../../dist/pdf/$(dirname {}) && cp {} ../../dist/pdf/{}' \\;", + "build:views": "mkdir -p dist/views && find src/views -name '*.njk' -exec cp {} dist/views/ \\;", + "build:schemas": "mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ." + }, + "dependencies": { + "@hmcts/list-types-common": "workspace:*", + "@hmcts/pdf-generation": "workspace:*", + "luxon": "3.7.2", + "nunjucks": "3.2.4" + }, + "devDependencies": { + "@types/luxon": "3.7.1", + "@types/node": "24.10.4", + "typescript": "6.0.3", + "vitest": "4.1.9" + }, + "peerDependencies": { + "express": "^5.1.0" + } +} diff --git a/libs/list-types/ftt-rpt-weekly-hearing-list/src/config.test.ts b/libs/list-types/ftt-rpt-weekly-hearing-list/src/config.test.ts new file mode 100644 index 000000000..ba5b2f5b3 --- /dev/null +++ b/libs/list-types/ftt-rpt-weekly-hearing-list/src/config.test.ts @@ -0,0 +1,48 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { assets, moduleRoot } from "./config.js"; + +describe("ftt-rpt-weekly-hearing-list config", () => { + describe("moduleRoot", () => { + it("should be defined", () => { + expect(moduleRoot).toBeDefined(); + expect(typeof moduleRoot).toBe("string"); + }); + + it("should point to an existing directory", () => { + expect(existsSync(moduleRoot)).toBe(true); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(moduleRoot)).toBe(true); + }); + }); + + describe("assets", () => { + it("should be defined", () => { + expect(assets).toBeDefined(); + expect(typeof assets).toBe("string"); + }); + + it("should point to assets directory", () => { + expect(assets).toContain("assets"); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(assets)).toBe(true); + }); + + it("should have valid path structure", () => { + expect(assets).toBeTruthy(); + }); + + it("should end with trailing slash", () => { + expect(assets).toMatch(/\/$/); + }); + + it("should be subdirectory of moduleRoot", () => { + expect(assets.startsWith(moduleRoot)).toBe(true); + }); + }); +}); diff --git a/libs/list-types/ftt-rpt-weekly-hearing-list/src/config.ts b/libs/list-types/ftt-rpt-weekly-hearing-list/src/config.ts new file mode 100644 index 000000000..90a77f452 --- /dev/null +++ b/libs/list-types/ftt-rpt-weekly-hearing-list/src/config.ts @@ -0,0 +1,9 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const moduleRoot = __dirname; +export const assets = path.join(__dirname, "assets/"); +export const schemaPath = path.join(__dirname, "schemas/ftt-rpt-weekly-hearing-list.json"); diff --git a/libs/list-types/ftt-rpt-weekly-hearing-list/src/conversion/ftt-rpt-config.ts b/libs/list-types/ftt-rpt-weekly-hearing-list/src/conversion/ftt-rpt-config.ts new file mode 100644 index 000000000..401f963ba --- /dev/null +++ b/libs/list-types/ftt-rpt-weekly-hearing-list/src/conversion/ftt-rpt-config.ts @@ -0,0 +1,89 @@ +import { + createConverter, + DD_MM_YYYY_PATTERN, + type ExcelConverterConfig, + registerConverter, + registerConverterByName, + validateDateFormat, + validateNoHtmlTags +} from "@hmcts/list-types-common"; + +// FTT RPT Weekly Hearing List — shared config for all 5 regional variants (listTypeIds: 33–37) +export const FTT_RPT_EXCEL_CONFIG: ExcelConverterConfig = { + fields: [ + { + header: "Date", + fieldName: "date", + required: true, + validators: [validateDateFormat(DD_MM_YYYY_PATTERN, "dd/MM/yyyy (e.g., 02/01/2025)")] + }, + { + header: "Time", + fieldName: "time", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Time", rowNumber)] + }, + { + header: "Venue", + fieldName: "venue", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Venue", rowNumber)] + }, + { + header: "Case Type", + fieldName: "caseType", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case Type", rowNumber)] + }, + { + header: "Case Reference Number", + fieldName: "caseReferenceNumber", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case Reference Number", rowNumber)] + }, + { + header: "Judge(s)", + fieldName: "judges", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Judge(s)", rowNumber)] + }, + { + header: "Member(s)", + fieldName: "members", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Member(s)", rowNumber)] + }, + { + header: "Hearing Method", + fieldName: "hearingMethod", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Hearing Method", rowNumber)] + }, + { + header: "Additional Information", + fieldName: "additionalInformation", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Additional Information", rowNumber)] + } + ], + minRows: 1 +}; + +// Register the same converter for all 5 RPT regional variants +// Name-based registration handles environments where the DB ID differs from the canonical seeded ID +const fttRptConverter = createConverter(FTT_RPT_EXCEL_CONFIG); + +registerConverter(33, fttRptConverter); +registerConverterByName("FTT_RPT_EASTERN_WEEKLY_HEARING_LIST", fttRptConverter); + +registerConverter(34, fttRptConverter); +registerConverterByName("FTT_RPT_LONDON_WEEKLY_HEARING_LIST", fttRptConverter); + +registerConverter(35, fttRptConverter); +registerConverterByName("FTT_RPT_MIDLANDS_WEEKLY_HEARING_LIST", fttRptConverter); + +registerConverter(36, fttRptConverter); +registerConverterByName("FTT_RPT_NORTHERN_WEEKLY_HEARING_LIST", fttRptConverter); + +registerConverter(37, fttRptConverter); +registerConverterByName("FTT_RPT_SOUTHERN_WEEKLY_HEARING_LIST", fttRptConverter); diff --git a/libs/list-types/ftt-rpt-weekly-hearing-list/src/email-summary/summary-builder.test.ts b/libs/list-types/ftt-rpt-weekly-hearing-list/src/email-summary/summary-builder.test.ts new file mode 100644 index 000000000..a3b797d0d --- /dev/null +++ b/libs/list-types/ftt-rpt-weekly-hearing-list/src/email-summary/summary-builder.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import type { FttRptHearingList } from "../models/types.js"; +import { extractCaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "./summary-builder.js"; + +describe("SPECIAL_CATEGORY_DATA_WARNING", () => { + it("should contain required warning text", () => { + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Special Category Data"); + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Data Protection Act 2018"); + }); +}); + +describe("extractCaseSummary", () => { + it("should extract case summaries from hearing list", () => { + // Arrange + const hearingList: FttRptHearingList = [ + { + date: "01/01/2025", + time: "10:00am", + venue: "London", + caseType: "Leasehold", + caseReferenceNumber: "RPT/00001/2025", + judges: "Judge Smith", + members: "", + hearingMethod: "In person", + additionalInformation: "" + }, + { + date: "02/01/2025", + time: "2:00pm", + venue: "Manchester", + caseType: "Rent", + caseReferenceNumber: "RPT/00002/2025", + judges: "Judge Brown", + members: "Member Jones", + hearingMethod: "Video", + additionalInformation: "" + } + ]; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(2); + expect(result[0]).toEqual([ + { label: "Date", value: "01/01/2025" }, + { label: "Time", value: "10:00am" }, + { label: "Case reference number", value: "RPT/00001/2025" } + ]); + expect(result[1]).toEqual([ + { label: "Date", value: "02/01/2025" }, + { label: "Time", value: "2:00pm" }, + { label: "Case reference number", value: "RPT/00002/2025" } + ]); + }); + + it("should handle empty hearing list", () => { + // Arrange + const hearingList: FttRptHearingList = []; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(0); + }); + + it("should handle missing case details with empty string", () => { + // Arrange + const hearingList: FttRptHearingList = [ + { + date: "", + time: "", + venue: "", + caseType: "", + caseReferenceNumber: "", + judges: "", + members: "", + hearingMethod: "", + additionalInformation: "" + } + ]; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(1); + expect(result[0]).toEqual([ + { label: "Date", value: "" }, + { label: "Time", value: "" }, + { label: "Case reference number", value: "" } + ]); + }); +}); + +describe("formatCaseSummaryForEmail", () => { + it("should format single case summary correctly", () => { + // Arrange + const items = [ + [ + { label: "Date", value: "01/01/2025" }, + { label: "Time", value: "10:00am" }, + { label: "Case reference number", value: "RPT/00001/2025" } + ] + ]; + + // Act + const result = formatCaseSummaryForEmail(items); + + // Assert + expect(result).toContain("---"); + expect(result).toContain("Date - 01/01/2025"); + expect(result).toContain("Time - 10:00am"); + expect(result).toContain("Case reference number - RPT/00001/2025"); + }); + + it("should handle empty case list", () => { + // Act + const result = formatCaseSummaryForEmail([]); + + // Assert + expect(result).toBe("No cases scheduled."); + }); +}); diff --git a/libs/list-types/ftt-rpt-weekly-hearing-list/src/email-summary/summary-builder.ts b/libs/list-types/ftt-rpt-weekly-hearing-list/src/email-summary/summary-builder.ts new file mode 100644 index 000000000..f8dd07d50 --- /dev/null +++ b/libs/list-types/ftt-rpt-weekly-hearing-list/src/email-summary/summary-builder.ts @@ -0,0 +1,12 @@ +import { type CaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "@hmcts/list-types-common"; +import type { FttRptHearingList } from "../models/types.js"; + +export { formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING }; + +export function extractCaseSummary(jsonData: FttRptHearingList): CaseSummary[] { + return jsonData.map((hearing) => [ + { label: "Date", value: hearing.date || "" }, + { label: "Time", value: hearing.time || "" }, + { label: "Case reference number", value: hearing.caseReferenceNumber || "" } + ]); +} diff --git a/libs/list-types/ftt-rpt-weekly-hearing-list/src/index.ts b/libs/list-types/ftt-rpt-weekly-hearing-list/src/index.ts new file mode 100644 index 000000000..ff016d75f --- /dev/null +++ b/libs/list-types/ftt-rpt-weekly-hearing-list/src/index.ts @@ -0,0 +1,11 @@ +import "./conversion/ftt-rpt-config.js"; // Register converter on module load + +// Business logic exports +export type { ValidationResult } from "@hmcts/publication"; +export * from "./email-summary/summary-builder.js"; +export { cy as fttRptWeeklyHearingListCy } from "./locales/cy.js"; +// Locale exports +export { en as fttRptWeeklyHearingListEn } from "./locales/en.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export * from "./rendering/renderer.js"; diff --git a/libs/list-types/ftt-rpt-weekly-hearing-list/src/locales/cy.ts b/libs/list-types/ftt-rpt-weekly-hearing-list/src/locales/cy.ts new file mode 100644 index 000000000..e0c1fa80d --- /dev/null +++ b/libs/list-types/ftt-rpt-weekly-hearing-list/src/locales/cy.ts @@ -0,0 +1,45 @@ +import { provenanceLabelsCy as provenanceLabels } from "@hmcts/list-types-common"; + +export const cy = { + listForWeekCommencing: "List for week commencing", + lastUpdated: "Last updated", + at: "at", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + importantInformationTitle: "Important information", + importantInformationText: + 'Members of the public wishing to observe a hearing or representatives of the media may, on their request, join any telephone or video hearing remotely while they are taking place by sending an email in advance to the tribunal at [insert office email] with the following details in the subject line "[OBSERVER/MEDIA] REQUEST – [case reference] – [hearing date]" and appropriate arrangements will be made to allow access where reasonably practicable.', + importantInformationLinkText: "Observe a court or tribunal hearing as a journalist, researcher or member of the public", + importantInformationLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + searchCasesTitle: "Search Cases", + searchCasesLabel: "Search by case reference number, date, venue, or other details", + tableHeaders: { + date: "Date", + time: "Time", + venue: "Venue", + caseType: "Case type", + caseReferenceNumber: "Case reference number", + judges: "Judge(s)", + members: "Member(s)", + hearingMethod: "Hearing method", + additionalInformation: "Additional information" + }, + dataSource: "Data source", + backToTop: "Back to top", + cautionNote: + "Note this document contains Special Category Data as defined by Data Protection Act 2018, formally known as Sensitive Personal Data, and should be handled appropriately.", + cautionReporting: + "This document contains information intended to assist the accurate reporting of court proceedings. It is vital you ensure that you safeguard the Special Category Data included and abide by reporting restrictions (for example on victims and children). HMCTS will stop sending the data if there is concern about how it will be used.", + provenanceLabels, + rptEasternCourtName: "First-tier Tribunal (Residential Property Tribunal): Eastern region", + rptLondonCourtName: "First-tier Tribunal (Residential Property Tribunal): London region", + rptMidlandsCourtName: "First-tier Tribunal (Residential Property Tribunal): Midlands region", + rptNorthernCourtName: "First-tier Tribunal (Residential Property Tribunal): Northern region", + rptSouthernCourtName: "First-tier Tribunal (Residential Property Tribunal): Southern region", + rptEasternPageTitle: "First-tier Tribunal (Residential Property Tribunal): Eastern region Weekly Hearing List", + rptLondonPageTitle: "First-tier Tribunal (Residential Property Tribunal): London region Weekly Hearing List", + rptMidlandsPageTitle: "First-tier Tribunal (Residential Property Tribunal): Midlands region Weekly Hearing List", + rptNorthernPageTitle: "First-tier Tribunal (Residential Property Tribunal): Northern region Weekly Hearing List", + rptSouthernPageTitle: "First-tier Tribunal (Residential Property Tribunal): Southern region Weekly Hearing List" +}; diff --git a/libs/list-types/ftt-rpt-weekly-hearing-list/src/locales/en.ts b/libs/list-types/ftt-rpt-weekly-hearing-list/src/locales/en.ts new file mode 100644 index 000000000..e672154a2 --- /dev/null +++ b/libs/list-types/ftt-rpt-weekly-hearing-list/src/locales/en.ts @@ -0,0 +1,45 @@ +import { provenanceLabelsEn as provenanceLabels } from "@hmcts/list-types-common"; + +export const en = { + listForWeekCommencing: "List for week commencing", + lastUpdated: "Last updated", + at: "at", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + importantInformationTitle: "Important information", + importantInformationText: + 'Members of the public wishing to observe a hearing or representatives of the media may, on their request, join any telephone or video hearing remotely while they are taking place by sending an email in advance to the tribunal at [insert office email] with the following details in the subject line "[OBSERVER/MEDIA] REQUEST – [case reference] – [hearing date]" and appropriate arrangements will be made to allow access where reasonably practicable.', + importantInformationLinkText: "Observe a court or tribunal hearing as a journalist, researcher or member of the public", + importantInformationLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + searchCasesTitle: "Search Cases", + searchCasesLabel: "Search by case reference number, date, venue, or other details", + tableHeaders: { + date: "Date", + time: "Time", + venue: "Venue", + caseType: "Case type", + caseReferenceNumber: "Case reference number", + judges: "Judge(s)", + members: "Member(s)", + hearingMethod: "Hearing method", + additionalInformation: "Additional information" + }, + dataSource: "Data source", + backToTop: "Back to top", + cautionNote: + "Note this document contains Special Category Data as defined by Data Protection Act 2018, formally known as Sensitive Personal Data, and should be handled appropriately.", + cautionReporting: + "This document contains information intended to assist the accurate reporting of court proceedings. It is vital you ensure that you safeguard the Special Category Data included and abide by reporting restrictions (for example on victims and children). HMCTS will stop sending the data if there is concern about how it will be used.", + provenanceLabels, + rptEasternCourtName: "First-tier Tribunal (Residential Property Tribunal): Eastern region", + rptLondonCourtName: "First-tier Tribunal (Residential Property Tribunal): London region", + rptMidlandsCourtName: "First-tier Tribunal (Residential Property Tribunal): Midlands region", + rptNorthernCourtName: "First-tier Tribunal (Residential Property Tribunal): Northern region", + rptSouthernCourtName: "First-tier Tribunal (Residential Property Tribunal): Southern region", + rptEasternPageTitle: "First-tier Tribunal (Residential Property Tribunal): Eastern region Weekly Hearing List", + rptLondonPageTitle: "First-tier Tribunal (Residential Property Tribunal): London region Weekly Hearing List", + rptMidlandsPageTitle: "First-tier Tribunal (Residential Property Tribunal): Midlands region Weekly Hearing List", + rptNorthernPageTitle: "First-tier Tribunal (Residential Property Tribunal): Northern region Weekly Hearing List", + rptSouthernPageTitle: "First-tier Tribunal (Residential Property Tribunal): Southern region Weekly Hearing List" +}; diff --git a/libs/list-types/ftt-rpt-weekly-hearing-list/src/models/types.ts b/libs/list-types/ftt-rpt-weekly-hearing-list/src/models/types.ts new file mode 100644 index 000000000..4a5cafc7f --- /dev/null +++ b/libs/list-types/ftt-rpt-weekly-hearing-list/src/models/types.ts @@ -0,0 +1,13 @@ +export interface FttRptHearing { + date: string; + time: string; + venue: string; + caseType: string; + caseReferenceNumber: string; + judges: string; + members: string; + hearingMethod: string; + additionalInformation: string; +} + +export type FttRptHearingList = FttRptHearing[]; diff --git a/libs/list-types/ftt-rpt-weekly-hearing-list/src/pdf/pdf-generator.test.ts b/libs/list-types/ftt-rpt-weekly-hearing-list/src/pdf/pdf-generator.test.ts new file mode 100644 index 000000000..774c071b7 --- /dev/null +++ b/libs/list-types/ftt-rpt-weekly-hearing-list/src/pdf/pdf-generator.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockUploadBlob } = vi.hoisted(() => ({ + mockUploadBlob: vi.fn() +})); +vi.mock("@hmcts/azure-blob", () => ({ + uploadBlob: mockUploadBlob, + CONTAINER: { ARTEFACT: "artefact", PUBLICATIONS: "publications" } +})); + +vi.mock("@hmcts/pdf-generation", () => ({ + generatePdfFromHtml: vi.fn() +})); + +vi.mock("../rendering/renderer.js", () => ({ + renderFttRptData: vi.fn() +})); + +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { renderFttRptData } from "../rendering/renderer.js"; +import { generateFttRptWeeklyHearingListPdf } from "./pdf-generator.js"; + +const mockRenderedData = { + header: { + listTitle: "First-tier Tribunal (Residential Property Tribunal): Eastern region Weekly Hearing List", + weekCommencingDate: "01 January 2025", + lastUpdatedDate: "12 November 2025", + lastUpdatedTime: "9am" + }, + hearings: [] +}; + +const mockHearingList = [ + { + date: "01/01/2025", + time: "10:00am", + venue: "London", + caseType: "Leasehold", + caseReferenceNumber: "RPT/00001/2025", + judges: "Judge Smith", + members: "", + hearingMethod: "In person", + additionalInformation: "" + } +]; + +describe("generateFttRptWeeklyHearingListPdf", () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(renderFttRptData).mockReturnValue(mockRenderedData); + mockUploadBlob.mockResolvedValue(undefined); + }); + + it("should generate PDF successfully", async () => { + // Arrange + const pdfBuffer = Buffer.from("PDF content"); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer, + sizeBytes: 1024 + }); + + // Act + const result = await generateFttRptWeeklyHearingListPdf({ + artefactId: "test-artefact-123", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList, + courtName: "First-tier Tribunal (Residential Property Tribunal): Eastern region", + listTitle: "First-tier Tribunal (Residential Property Tribunal): Eastern region Weekly Hearing List" + }); + + // Assert + expect(result.success).toBe(true); + expect(result.pdfPath).toContain("test-artefact-123.pdf"); + expect(result.sizeBytes).toBe(1024); + expect(result.exceedsMaxSize).toBe(false); + }); + + it("should return exceedsMaxSize true when PDF is over 2MB", async () => { + // Arrange + const largePdfBuffer = Buffer.alloc(3 * 1024 * 1024); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: largePdfBuffer, + sizeBytes: 3 * 1024 * 1024 + }); + + // Act + const result = await generateFttRptWeeklyHearingListPdf({ + artefactId: "large-pdf-123", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList, + courtName: "First-tier Tribunal (Residential Property Tribunal): London region", + listTitle: "First-tier Tribunal (Residential Property Tribunal): London region Weekly Hearing List" + }); + + // Assert + expect(result.success).toBe(true); + expect(result.exceedsMaxSize).toBe(true); + }); + + it("should return error when PDF generation fails", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: false, + error: "Puppeteer crashed" + }); + + // Act + const result = await generateFttRptWeeklyHearingListPdf({ + artefactId: "failed-pdf", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList, + courtName: "First-tier Tribunal (Residential Property Tribunal): Midlands region", + listTitle: "First-tier Tribunal (Residential Property Tribunal): Midlands region Weekly Hearing List" + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("Puppeteer crashed"); + }); + + it("should pass correct render options to renderer", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + const contentDate = new Date("2025-06-15"); + + // Act + await generateFttRptWeeklyHearingListPdf({ + artefactId: "test-render-options", + contentDate, + locale: "cy", + locationId: "999", + jsonData: mockHearingList, + courtName: "First-tier Tribunal (Residential Property Tribunal): Northern region", + listTitle: "First-tier Tribunal (Residential Property Tribunal): Northern region Weekly Hearing List" + }); + + // Assert + expect(renderFttRptData).toHaveBeenCalledWith(mockHearingList, { + locale: "cy", + courtName: "First-tier Tribunal (Residential Property Tribunal): Northern region", + contentDate, + lastReceivedDate: expect.any(String), + listTitle: "First-tier Tribunal (Residential Property Tribunal): Northern region Weekly Hearing List" + }); + }); +}); diff --git a/libs/list-types/ftt-rpt-weekly-hearing-list/src/pdf/pdf-generator.ts b/libs/list-types/ftt-rpt-weekly-hearing-list/src/pdf/pdf-generator.ts new file mode 100644 index 000000000..7cd60d8cc --- /dev/null +++ b/libs/list-types/ftt-rpt-weekly-hearing-list/src/pdf/pdf-generator.ts @@ -0,0 +1,28 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { type BasePdfGenerationOptions, generateFttSiacWeeklyHearingListPdf, type PdfGenerationResult } from "@hmcts/list-types-common"; +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { PROVENANCE_LABELS } from "@hmcts/publication"; +import type { FttRptHearingList } from "../models/types.js"; +import { renderFttRptData } from "../rendering/renderer.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface PdfGenerationOptions extends BasePdfGenerationOptions { + contentDate: Date; + courtName: string; + listTitle: string; +} + +export async function generateFttRptWeeklyHearingListPdf(options: PdfGenerationOptions): Promise { + return generateFttSiacWeeklyHearingListPdf({ + ...options, + moduleDir: __dirname, + provenanceLabel: options.provenance ? PROVENANCE_LABELS[options.provenance as keyof typeof PROVENANCE_LABELS] || options.provenance : "", + importEn: () => import("../locales/en.js"), + importCy: () => import("../locales/cy.js"), + generatePdf: generatePdfFromHtml, + renderData: renderFttRptData + }); +} diff --git a/libs/list-types/ftt-rpt-weekly-hearing-list/src/pdf/pdf-template.njk b/libs/list-types/ftt-rpt-weekly-hearing-list/src/pdf/pdf-template.njk new file mode 100644 index 000000000..43e8b4e78 --- /dev/null +++ b/libs/list-types/ftt-rpt-weekly-hearing-list/src/pdf/pdf-template.njk @@ -0,0 +1,70 @@ + + + + + + {{ header.listTitle }} + + + +
+

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForWeekCommencing }} {{ header.weekCommencingDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+
+ +
+

{{ t.importantInformationTitle }}

+

{{ t.importantInformationText }}

+

{{ t.importantInformationLinkText }}

+
+ + {% if hearings.length > 0 %} + + + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.date }}{{ t.tableHeaders.time }}{{ t.tableHeaders.venue }}{{ t.tableHeaders.caseType }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.judges }}{{ t.tableHeaders.members }}{{ t.tableHeaders.hearingMethod }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.date }}{{ hearing.time }}{{ hearing.venue }}{{ hearing.caseType }}{{ hearing.caseReferenceNumber }}{{ hearing.judges }}{{ hearing.members }}{{ hearing.hearingMethod }}{{ hearing.additionalInformation }}
+ {% else %} +

No hearings scheduled.

+ {% endif %} + + + + diff --git a/libs/list-types/ftt-rpt-weekly-hearing-list/src/rendering/renderer.test.ts b/libs/list-types/ftt-rpt-weekly-hearing-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..2fc436b9f --- /dev/null +++ b/libs/list-types/ftt-rpt-weekly-hearing-list/src/rendering/renderer.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import type { FttRptHearingList } from "../models/types.js"; +import { renderFttRptData } from "./renderer.js"; + +describe("renderFttRptData", () => { + it("should render hearing list with formatted dates", () => { + // Arrange + const hearingList: FttRptHearingList = [ + { + date: "02/01/2025", + time: "10:00am", + venue: "London", + caseType: "Leasehold", + caseReferenceNumber: "RPT/00001/2025", + judges: "Judge Smith", + members: "Member Jones", + hearingMethod: "In person", + additionalInformation: "" + } + ]; + + const options = { + locale: "en", + courtName: "First-tier Tribunal (Residential Property Tribunal): Eastern region", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "First-tier Tribunal (Residential Property Tribunal): Eastern region Weekly Hearing List" + }; + + // Act + const result = renderFttRptData(hearingList, options); + + // Assert + expect(result.header.listTitle).toBe("First-tier Tribunal (Residential Property Tribunal): Eastern region Weekly Hearing List"); + expect(result.header.weekCommencingDate).toBe("02 January 2025"); + expect(result.header.lastUpdatedDate).toBe("01 January 2025"); + expect(result.header.lastUpdatedTime).toContain("am"); + + expect(result.hearings).toHaveLength(1); + expect(result.hearings[0].date).toBe("02 January 2025"); + expect(result.hearings[0].time).toBe("10:00am"); + expect(result.hearings[0].venue).toBe("London"); + expect(result.hearings[0].caseType).toBe("Leasehold"); + expect(result.hearings[0].caseReferenceNumber).toBe("RPT/00001/2025"); + expect(result.hearings[0].judges).toBe("Judge Smith"); + expect(result.hearings[0].members).toBe("Member Jones"); + expect(result.hearings[0].hearingMethod).toBe("In person"); + expect(result.hearings[0].additionalInformation).toBe(""); + }); + + it("should render multiple hearings", () => { + // Arrange + const hearingList: FttRptHearingList = [ + { + date: "02/01/2025", + time: "10:00am", + venue: "London", + caseType: "Leasehold", + caseReferenceNumber: "RPT/00001/2025", + judges: "Judge Smith", + members: "", + hearingMethod: "In person", + additionalInformation: "" + }, + { + date: "03/01/2025", + time: "2:00pm", + venue: "Manchester", + caseType: "Rent", + caseReferenceNumber: "RPT/00002/2025", + judges: "Judge Brown", + members: "Member Jones", + hearingMethod: "Video", + additionalInformation: "Remote" + } + ]; + + const options = { + locale: "en", + courtName: "First-tier Tribunal (Residential Property Tribunal): Northern region", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "First-tier Tribunal (Residential Property Tribunal): Northern region Weekly Hearing List" + }; + + // Act + const result = renderFttRptData(hearingList, options); + + // Assert + expect(result.hearings).toHaveLength(2); + expect(result.hearings[0].date).toBe("02 January 2025"); + expect(result.hearings[1].date).toBe("03 January 2025"); + }); + + it("should handle empty hearing list", () => { + // Arrange + const hearingList: FttRptHearingList = []; + + const options = { + locale: "en", + courtName: "First-tier Tribunal (Residential Property Tribunal): London region", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "First-tier Tribunal (Residential Property Tribunal): London region Weekly Hearing List" + }; + + // Act + const result = renderFttRptData(hearingList, options); + + // Assert + expect(result.hearings).toHaveLength(0); + expect(result.header.listTitle).toBe("First-tier Tribunal (Residential Property Tribunal): London region Weekly Hearing List"); + }); + + it("should format lastUpdated time correctly", () => { + // Arrange + const hearingList: FttRptHearingList = [ + { + date: "02/01/2025", + time: "10:00am", + venue: "London", + caseType: "Leasehold", + caseReferenceNumber: "RPT/00001/2025", + judges: "Judge Smith", + members: "", + hearingMethod: "In person", + additionalInformation: "" + } + ]; + + const options = { + locale: "en", + courtName: "First-tier Tribunal (Residential Property Tribunal): Southern region", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T14:30:00Z", + listTitle: "First-tier Tribunal (Residential Property Tribunal): Southern region Weekly Hearing List" + }; + + // Act + const result = renderFttRptData(hearingList, options); + + // Assert + expect(result.header.lastUpdatedTime).toBe("2:30pm"); + }); +}); diff --git a/libs/list-types/ftt-rpt-weekly-hearing-list/src/rendering/renderer.ts b/libs/list-types/ftt-rpt-weekly-hearing-list/src/rendering/renderer.ts new file mode 100644 index 000000000..13bb7dd58 --- /dev/null +++ b/libs/list-types/ftt-rpt-weekly-hearing-list/src/rendering/renderer.ts @@ -0,0 +1,47 @@ +import { formatDdMmYyyyDate, formatDisplayDate, formatLastUpdatedDateTime } from "@hmcts/list-types-common"; +import type { FttRptHearing, FttRptHearingList } from "../models/types.js"; + +export interface RenderOptions { + locale: string; + courtName: string; + contentDate: Date; + lastReceivedDate: string; + listTitle: string; +} + +export interface RenderedData { + header: { + listTitle: string; + weekCommencingDate: string; + lastUpdatedDate: string; + lastUpdatedTime: string; + }; + hearings: FttRptHearing[]; +} + +export function renderFttRptData(hearingList: FttRptHearingList, options: RenderOptions): RenderedData { + const weekCommencingDate = formatDisplayDate(options.contentDate, options.locale); + const { date: lastUpdatedDate, time: lastUpdatedTime } = formatLastUpdatedDateTime(options.lastReceivedDate, options.locale); + + const renderedHearings = hearingList.map((hearing) => ({ + date: formatDdMmYyyyDate(hearing.date, options.locale), + time: hearing.time, + venue: hearing.venue, + caseType: hearing.caseType, + caseReferenceNumber: hearing.caseReferenceNumber, + judges: hearing.judges, + members: hearing.members, + hearingMethod: hearing.hearingMethod, + additionalInformation: hearing.additionalInformation + })); + + return { + header: { + listTitle: options.listTitle, + weekCommencingDate, + lastUpdatedDate, + lastUpdatedTime + }, + hearings: renderedHearings + }; +} diff --git a/libs/list-types/ftt-rpt-weekly-hearing-list/src/schemas/ftt-rpt-weekly-hearing-list.json b/libs/list-types/ftt-rpt-weekly-hearing-list/src/schemas/ftt-rpt-weekly-hearing-list.json new file mode 100644 index 000000000..d4ab398c1 --- /dev/null +++ b/libs/list-types/ftt-rpt-weekly-hearing-list/src/schemas/ftt-rpt-weekly-hearing-list.json @@ -0,0 +1,66 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "First-tier Tribunal (Residential Property Tribunal) Weekly Hearing List", + "description": "Schema for First-tier Tribunal (Residential Property Tribunal) Weekly Hearing Lists from pip-data-management", + "type": "array", + "items": { + "type": "object", + "required": ["date", "time", "venue", "caseType", "caseReferenceNumber", "judges", "members", "hearingMethod", "additionalInformation"], + "properties": { + "date": { + "title": "Date", + "type": "string", + "pattern": "^\\d{2}/\\d{2}/\\d{4}$", + "examples": ["02/01/2025"] + }, + "time": { + "title": "Time", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["10:00am"] + }, + "venue": { + "title": "Venue", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["London"] + }, + "caseType": { + "title": "Case Type", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["Leasehold"] + }, + "caseReferenceNumber": { + "title": "Case Reference Number", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["RPT/00001/2025"] + }, + "judges": { + "title": "Judge(s)", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["Judge Smith"] + }, + "members": { + "title": "Member(s)", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["Member Jones"] + }, + "hearingMethod": { + "title": "Hearing Method", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["In person"] + }, + "additionalInformation": { + "title": "Additional Information", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["Remote hearing"] + } + } + } +} diff --git a/libs/list-types/ftt-rpt-weekly-hearing-list/src/views/ftt-rpt-weekly-hearing-list.njk b/libs/list-types/ftt-rpt-weekly-hearing-list/src/views/ftt-rpt-weekly-hearing-list.njk new file mode 100644 index 000000000..5da1848af --- /dev/null +++ b/libs/list-types/ftt-rpt-weekly-hearing-list/src/views/ftt-rpt-weekly-hearing-list.njk @@ -0,0 +1,82 @@ +{% extends "layouts/base-template.njk" %} + + +{% block page_content %} +
+
+ +

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForWeekCommencing }} {{ header.weekCommencingDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+ +
+ + + {{ t.importantInformationTitle }} + + +
+

{{ t.importantInformationText }}

+

+ + {{ t.importantInformationLinkText }} + +

+
+
+ +
+

{{ t.searchCasesTitle }}

+ + +
+ +
+ + + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.date }}{{ t.tableHeaders.time }}{{ t.tableHeaders.venue }}{{ t.tableHeaders.caseType }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.judges }}{{ t.tableHeaders.members }}{{ t.tableHeaders.hearingMethod }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.date }}{{ hearing.time }}{{ hearing.venue }}{{ hearing.caseType }}{{ hearing.caseReferenceNumber }}{{ hearing.judges }}{{ hearing.members }}{{ hearing.hearingMethod }}{{ hearing.additionalInformation }}
+
+ +

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

+ + + +
+
+{% endblock %} diff --git a/libs/list-types/ftt-rpt-weekly-hearing-list/tsconfig.json b/libs/list-types/ftt-rpt-weekly-hearing-list/tsconfig.json new file mode 100644 index 000000000..a029ee937 --- /dev/null +++ b/libs/list-types/ftt-rpt-weekly-hearing-list/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules", "src/assets/"] +} diff --git a/libs/list-types/ftt-tax-chamber-weekly-hearing-list/package.json b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/package.json new file mode 100644 index 000000000..068745056 --- /dev/null +++ b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/package.json @@ -0,0 +1,41 @@ +{ + "name": "@hmcts/ftt-tax-chamber-weekly-hearing-list", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.ts" + } + }, + "scripts": { + "build": "tsc && yarn build:nunjucks && yarn build:schemas", + "build:nunjucks": "mkdir -p dist/pdf && cd src/pdf && find . -name '*.njk' -exec sh -c 'mkdir -p ../../dist/pdf/$(dirname {}) && cp {} ../../dist/pdf/{}' \\;", + "build:schemas": "mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ." + }, + "dependencies": { + "@hmcts/list-types-common": "workspace:*", + "@hmcts/pdf-generation": "workspace:*", + "luxon": "3.7.2", + "nunjucks": "3.2.4" + }, + "devDependencies": { + "@types/luxon": "3.7.1", + "@types/node": "24.10.4", + "typescript": "6.0.3", + "vitest": "4.1.9" + }, + "peerDependencies": { + "express": "^5.1.0" + } +} diff --git a/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/config.test.ts b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/config.test.ts new file mode 100644 index 000000000..dad18f7dd --- /dev/null +++ b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/config.test.ts @@ -0,0 +1,48 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { assets, moduleRoot } from "./config.js"; + +describe("ftt-tax-chamber-weekly-hearing-list config", () => { + describe("moduleRoot", () => { + it("should be defined", () => { + expect(moduleRoot).toBeDefined(); + expect(typeof moduleRoot).toBe("string"); + }); + + it("should point to an existing directory", () => { + expect(existsSync(moduleRoot)).toBe(true); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(moduleRoot)).toBe(true); + }); + }); + + describe("assets", () => { + it("should be defined", () => { + expect(assets).toBeDefined(); + expect(typeof assets).toBe("string"); + }); + + it("should point to assets directory", () => { + expect(assets).toContain("assets"); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(assets)).toBe(true); + }); + + it("should have valid path structure", () => { + expect(assets).toBeTruthy(); + }); + + it("should end with trailing slash", () => { + expect(assets).toMatch(/\/$/); + }); + + it("should be subdirectory of moduleRoot", () => { + expect(assets.startsWith(moduleRoot)).toBe(true); + }); + }); +}); diff --git a/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/config.ts b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/config.ts new file mode 100644 index 000000000..79bbf2632 --- /dev/null +++ b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/config.ts @@ -0,0 +1,9 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const moduleRoot = __dirname; +export const assets = path.join(__dirname, "assets/"); +export const schemaPath = path.join(__dirname, "schemas/ftt-tax-chamber-weekly-hearing-list.json"); diff --git a/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/conversion/ftt-tax-config.ts b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/conversion/ftt-tax-config.ts new file mode 100644 index 000000000..eb988d900 --- /dev/null +++ b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/conversion/ftt-tax-config.ts @@ -0,0 +1,64 @@ +import { + createConverter, + DD_MM_YYYY_PATTERN, + type ExcelConverterConfig, + registerConverter, + registerConverterByName, + validateDateFormat, + validateNoHtmlTags +} from "@hmcts/list-types-common"; + +// First-tier Tribunal (Tax Chamber) Weekly Hearing List (listTypeId: 31) +export const FTT_TAX_EXCEL_CONFIG: ExcelConverterConfig = { + fields: [ + { + header: "Date", + fieldName: "date", + required: true, + validators: [validateDateFormat(DD_MM_YYYY_PATTERN, "dd/MM/yyyy (e.g., 02/01/2025)")] + }, + { + header: "Hearing Time", + fieldName: "hearingTime", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Hearing Time", rowNumber)] + }, + { + header: "Case Name", + fieldName: "caseName", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case Name", rowNumber)] + }, + { + header: "Case Reference Number", + fieldName: "caseReferenceNumber", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case Reference Number", rowNumber)] + }, + { + header: "Judge(s)", + fieldName: "judges", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Judge(s)", rowNumber)] + }, + { + header: "Member(s)", + fieldName: "members", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Member(s)", rowNumber)] + }, + { + header: "Venue/Platform", + fieldName: "venuePlatform", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Venue/Platform", rowNumber)] + } + ], + minRows: 1 +}; + +// Register the FTT Tax converter with listTypeId 31 and by name +// Name-based registration handles environments where the DB ID differs from the canonical seeded ID +const fttTaxConverter = createConverter(FTT_TAX_EXCEL_CONFIG); +registerConverter(31, fttTaxConverter); +registerConverterByName("FTT_TAX_CHAMBER_WEEKLY_HEARING_LIST", fttTaxConverter); diff --git a/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/email-summary/summary-builder.test.ts b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/email-summary/summary-builder.test.ts new file mode 100644 index 000000000..7e6faa0fc --- /dev/null +++ b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/email-summary/summary-builder.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import type { FttTaxChamberHearingList } from "../models/types.js"; +import { extractCaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "./summary-builder.js"; + +describe("SPECIAL_CATEGORY_DATA_WARNING", () => { + it("should contain required warning text", () => { + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Special Category Data"); + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Data Protection Act 2018"); + }); +}); + +describe("extractCaseSummary", () => { + it("should extract case summaries from hearing list", () => { + // Arrange + const hearingList: FttTaxChamberHearingList = [ + { + date: "01/01/2025", + hearingTime: "10:00am", + caseName: "A Vs HMRC", + caseReferenceNumber: "TC/00001/2025", + judges: "Judge Smith", + members: "", + venuePlatform: "London" + }, + { + date: "02/01/2025", + hearingTime: "2:00pm", + caseName: "B Vs HMRC", + caseReferenceNumber: "TC/00002/2025", + judges: "Judge Brown", + members: "Member Jones", + venuePlatform: "Manchester" + } + ]; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(2); + expect(result[0]).toEqual([ + { label: "Date", value: "01/01/2025" }, + { label: "Hearing time", value: "10:00am" }, + { label: "Case reference number", value: "TC/00001/2025" } + ]); + expect(result[1]).toEqual([ + { label: "Date", value: "02/01/2025" }, + { label: "Hearing time", value: "2:00pm" }, + { label: "Case reference number", value: "TC/00002/2025" } + ]); + }); + + it("should handle empty hearing list", () => { + // Arrange + const hearingList: FttTaxChamberHearingList = []; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(0); + }); + + it("should handle missing case details with empty string", () => { + // Arrange + const hearingList: FttTaxChamberHearingList = [ + { + date: "", + hearingTime: "", + caseName: "", + caseReferenceNumber: "", + judges: "", + members: "", + venuePlatform: "" + } + ]; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(1); + expect(result[0]).toEqual([ + { label: "Date", value: "" }, + { label: "Hearing time", value: "" }, + { label: "Case reference number", value: "" } + ]); + }); +}); + +describe("formatCaseSummaryForEmail", () => { + it("should format single case summary correctly", () => { + // Arrange + const items = [ + [ + { label: "Date", value: "01/01/2025" }, + { label: "Hearing time", value: "10:00am" }, + { label: "Case reference number", value: "TC/00001/2025" } + ] + ]; + + // Act + const result = formatCaseSummaryForEmail(items); + + // Assert + expect(result).toContain("---"); + expect(result).toContain("Date - 01/01/2025"); + expect(result).toContain("Hearing time - 10:00am"); + expect(result).toContain("Case reference number - TC/00001/2025"); + }); + + it("should handle empty case list", () => { + // Act + const result = formatCaseSummaryForEmail([]); + + // Assert + expect(result).toBe("No cases scheduled."); + }); +}); diff --git a/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/email-summary/summary-builder.ts b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/email-summary/summary-builder.ts new file mode 100644 index 000000000..baf114a20 --- /dev/null +++ b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/email-summary/summary-builder.ts @@ -0,0 +1,12 @@ +import { type CaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "@hmcts/list-types-common"; +import type { FttTaxChamberHearingList } from "../models/types.js"; + +export { formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING }; + +export function extractCaseSummary(jsonData: FttTaxChamberHearingList): CaseSummary[] { + return jsonData.map((hearing) => [ + { label: "Date", value: hearing.date || "" }, + { label: "Hearing time", value: hearing.hearingTime || "" }, + { label: "Case reference number", value: hearing.caseReferenceNumber || "" } + ]); +} diff --git a/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/index.ts b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/index.ts new file mode 100644 index 000000000..031ef210c --- /dev/null +++ b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/index.ts @@ -0,0 +1,11 @@ +import "./conversion/ftt-tax-config.js"; // Register converter on module load + +// Business logic exports +export type { ValidationResult } from "@hmcts/publication"; +export * from "./email-summary/summary-builder.js"; +export { cy as fttTaxChamberWeeklyHearingListCy } from "./locales/cy.js"; +// Locale exports +export { en as fttTaxChamberWeeklyHearingListEn } from "./locales/en.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export * from "./rendering/renderer.js"; diff --git a/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/locales/cy.ts b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/locales/cy.ts new file mode 100644 index 000000000..8cd266568 --- /dev/null +++ b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/locales/cy.ts @@ -0,0 +1,38 @@ +import { provenanceLabelsCy as provenanceLabels } from "@hmcts/list-types-common"; + +export const cy = { + pageTitle: "First-tier Tribunal (Tax Chamber) Weekly Hearing List", + listForWeekCommencing: "List for week commencing", + lastUpdated: "Last updated", + at: "at", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + importantInformationTitle: "Important information", + importantInformationParagraphs: [ + "Open justice is a fundamental principle of our justice system. You can attend a public hearing in person, or you can apply for permission to observe remotely.", + "Members of the public and the media can ask to join any telephone or video hearing remotely. Contact the Tribunal before the hearing to ask for permission to attend by emailing taxappeals@justice.gov.uk.", + "The subject line for the email should contain the following wording: \"HEARING ACCESS REQUEST – [Appellant's name] v [Respondent's name, for example HMRC] – [case reference] – [hearing date]\". You will be sent instructions on how to join the hearing.", + "The judge may refuse a request and can also decide a hearing must be held in private, in such cases you will not be able to attend." + ], + importantInformationLinkText: "Observe a court or tribunal hearing as a journalist, researcher or member of the public", + importantInformationLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + searchCasesTitle: "Search Cases", + searchCasesLabel: "Search by case name, date, judge, or other details", + tableHeaders: { + date: "Date", + hearingTime: "Hearing time", + caseName: "Case name", + caseReferenceNumber: "Case reference number", + judges: "Judge(s)", + members: "Member(s)", + venuePlatform: "Venue/Platform" + }, + dataSource: "Data source", + backToTop: "Back to top", + cautionNote: + "Note this document contains Special Category Data as defined by Data Protection Act 2018, formally known as Sensitive Personal Data, and should be handled appropriately.", + cautionReporting: + "This document contains information intended to assist the accurate reporting of court proceedings. It is vital you ensure that you safeguard the Special Category Data included and abide by reporting restrictions (for example on victims and children). HMCTS will stop sending the data if there is concern about how it will be used.", + provenanceLabels +}; diff --git a/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/locales/en.ts b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/locales/en.ts new file mode 100644 index 000000000..806465103 --- /dev/null +++ b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/locales/en.ts @@ -0,0 +1,38 @@ +import { provenanceLabelsEn as provenanceLabels } from "@hmcts/list-types-common"; + +export const en = { + pageTitle: "First-tier Tribunal (Tax Chamber) Weekly Hearing List", + listForWeekCommencing: "List for week commencing", + lastUpdated: "Last updated", + at: "at", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + importantInformationTitle: "Important information", + importantInformationParagraphs: [ + "Open justice is a fundamental principle of our justice system. You can attend a public hearing in person, or you can apply for permission to observe remotely.", + "Members of the public and the media can ask to join any telephone or video hearing remotely. Contact the Tribunal before the hearing to ask for permission to attend by emailing taxappeals@justice.gov.uk.", + "The subject line for the email should contain the following wording: \"HEARING ACCESS REQUEST – [Appellant's name] v [Respondent's name, for example HMRC] – [case reference] – [hearing date]\". You will be sent instructions on how to join the hearing.", + "The judge may refuse a request and can also decide a hearing must be held in private, in such cases you will not be able to attend." + ], + importantInformationLinkText: "Observe a court or tribunal hearing as a journalist, researcher or member of the public", + importantInformationLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + searchCasesTitle: "Search Cases", + searchCasesLabel: "Search by case name, date, judge, or other details", + tableHeaders: { + date: "Date", + hearingTime: "Hearing time", + caseName: "Case name", + caseReferenceNumber: "Case reference number", + judges: "Judge(s)", + members: "Member(s)", + venuePlatform: "Venue/Platform" + }, + dataSource: "Data source", + backToTop: "Back to top", + cautionNote: + "Note this document contains Special Category Data as defined by Data Protection Act 2018, formally known as Sensitive Personal Data, and should be handled appropriately.", + cautionReporting: + "This document contains information intended to assist the accurate reporting of court proceedings. It is vital you ensure that you safeguard the Special Category Data included and abide by reporting restrictions (for example on victims and children). HMCTS will stop sending the data if there is concern about how it will be used.", + provenanceLabels +}; diff --git a/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/models/types.ts b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/models/types.ts new file mode 100644 index 000000000..5e17059ab --- /dev/null +++ b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/models/types.ts @@ -0,0 +1,11 @@ +export interface FttTaxChamberHearing { + date: string; + hearingTime: string; + caseName: string; + caseReferenceNumber: string; + judges: string; + members: string; + venuePlatform: string; +} + +export type FttTaxChamberHearingList = FttTaxChamberHearing[]; diff --git a/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/pdf/pdf-generator.test.ts b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/pdf/pdf-generator.test.ts new file mode 100644 index 000000000..a63673ab9 --- /dev/null +++ b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/pdf/pdf-generator.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockUploadBlob } = vi.hoisted(() => ({ + mockUploadBlob: vi.fn() +})); +vi.mock("@hmcts/azure-blob", () => ({ + uploadBlob: mockUploadBlob, + CONTAINER: { ARTEFACT: "artefact", PUBLICATIONS: "publications" } +})); + +vi.mock("@hmcts/pdf-generation", () => ({ + generatePdfFromHtml: vi.fn() +})); + +vi.mock("../rendering/renderer.js", () => ({ + renderFttTaxChamberData: vi.fn() +})); + +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { renderFttTaxChamberData } from "../rendering/renderer.js"; +import { generateFttTaxChamberWeeklyHearingListPdf } from "./pdf-generator.js"; + +const mockRenderedData = { + header: { + listTitle: "First-tier Tribunal (Tax Chamber) Weekly Hearing List", + weekCommencingDate: "01 January 2025", + lastUpdatedDate: "12 November 2025", + lastUpdatedTime: "9am" + }, + hearings: [] +}; + +const mockHearingList = [ + { + date: "01/01/2025", + hearingTime: "10:00am", + caseName: "A Vs HMRC", + caseReferenceNumber: "TC/00001/2025", + judges: "Judge Smith", + members: "", + venuePlatform: "London" + } +]; + +describe("generateFttTaxChamberWeeklyHearingListPdf", () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(renderFttTaxChamberData).mockReturnValue(mockRenderedData); + mockUploadBlob.mockResolvedValue(undefined); + }); + + it("should generate PDF successfully", async () => { + // Arrange + const pdfBuffer = Buffer.from("PDF content"); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer, + sizeBytes: 1024 + }); + + // Act + const result = await generateFttTaxChamberWeeklyHearingListPdf({ + artefactId: "test-artefact-123", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(true); + expect(result.pdfPath).toContain("test-artefact-123.pdf"); + expect(result.sizeBytes).toBe(1024); + expect(result.exceedsMaxSize).toBe(false); + }); + + it("should return exceedsMaxSize true when PDF is over 2MB", async () => { + // Arrange + const largePdfBuffer = Buffer.alloc(3 * 1024 * 1024); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: largePdfBuffer, + sizeBytes: 3 * 1024 * 1024 + }); + + // Act + const result = await generateFttTaxChamberWeeklyHearingListPdf({ + artefactId: "large-pdf-123", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(true); + expect(result.exceedsMaxSize).toBe(true); + }); + + it("should return error when PDF generation fails", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: false, + error: "Puppeteer crashed" + }); + + // Act + const result = await generateFttTaxChamberWeeklyHearingListPdf({ + artefactId: "failed-pdf", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("Puppeteer crashed"); + }); + + it("should pass correct render options to renderer", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + const contentDate = new Date("2025-06-15"); + + // Act + await generateFttTaxChamberWeeklyHearingListPdf({ + artefactId: "test-render-options", + contentDate, + locale: "cy", + locationId: "999", + jsonData: mockHearingList + }); + + // Assert + expect(renderFttTaxChamberData).toHaveBeenCalledWith(mockHearingList, { + locale: "cy", + courtName: "First-tier Tribunal (Tax Chamber)", + contentDate, + lastReceivedDate: expect.any(String), + listTitle: "First-tier Tribunal (Tax Chamber) Weekly Hearing List" + }); + }); +}); diff --git a/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/pdf/pdf-generator.ts b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/pdf/pdf-generator.ts new file mode 100644 index 000000000..3c9889027 --- /dev/null +++ b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/pdf/pdf-generator.ts @@ -0,0 +1,28 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { type BasePdfGenerationOptions, generateFttSiacWeeklyHearingListPdf, type PdfGenerationResult } from "@hmcts/list-types-common"; +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { PROVENANCE_LABELS } from "@hmcts/publication"; +import type { FttTaxChamberHearingList } from "../models/types.js"; +import { renderFttTaxChamberData } from "../rendering/renderer.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface PdfGenerationOptions extends BasePdfGenerationOptions { + contentDate: Date; +} + +export async function generateFttTaxChamberWeeklyHearingListPdf(options: PdfGenerationOptions): Promise { + return generateFttSiacWeeklyHearingListPdf({ + ...options, + courtName: "First-tier Tribunal (Tax Chamber)", + listTitle: "First-tier Tribunal (Tax Chamber) Weekly Hearing List", + moduleDir: __dirname, + provenanceLabel: options.provenance ? PROVENANCE_LABELS[options.provenance as keyof typeof PROVENANCE_LABELS] || options.provenance : "", + importEn: () => import("../locales/en.js"), + importCy: () => import("../locales/cy.js"), + generatePdf: generatePdfFromHtml, + renderData: renderFttTaxChamberData + }); +} diff --git a/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/pdf/pdf-template.njk b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/pdf/pdf-template.njk new file mode 100644 index 000000000..617bf8967 --- /dev/null +++ b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/pdf/pdf-template.njk @@ -0,0 +1,68 @@ + + + + + + {{ header.listTitle }} + + + +
+

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForWeekCommencing }} {{ header.weekCommencingDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+
+ +
+

{{ t.importantInformationTitle }}

+ {% for paragraph in t.importantInformationParagraphs %} +

{{ paragraph }}

+ {% endfor %} +

{{ t.importantInformationLinkText }}

+
+ + {% if hearings.length > 0 %} + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.date }}{{ t.tableHeaders.hearingTime }}{{ t.tableHeaders.caseName }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.judges }}{{ t.tableHeaders.members }}{{ t.tableHeaders.venuePlatform }}
{{ hearing.date }}{{ hearing.hearingTime }}{{ hearing.caseName }}{{ hearing.caseReferenceNumber }}{{ hearing.judges }}{{ hearing.members }}{{ hearing.venuePlatform }}
+ {% else %} +

No hearings scheduled.

+ {% endif %} + + + + diff --git a/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/rendering/renderer.test.ts b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..d4410adc9 --- /dev/null +++ b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/rendering/renderer.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import type { FttTaxChamberHearingList } from "../models/types.js"; +import { renderFttTaxChamberData } from "./renderer.js"; + +describe("renderFttTaxChamberData", () => { + it("should render hearing list with formatted dates", () => { + // Arrange + const hearingList: FttTaxChamberHearingList = [ + { + date: "02/01/2025", + hearingTime: "10:00am", + caseName: "A Vs HMRC", + caseReferenceNumber: "TC/00001/2025", + judges: "Judge Smith", + members: "Member Jones", + venuePlatform: "London" + } + ]; + + const options = { + locale: "en", + courtName: "First-tier Tribunal (Tax Chamber)", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "First-tier Tribunal (Tax Chamber) Weekly Hearing List" + }; + + // Act + const result = renderFttTaxChamberData(hearingList, options); + + // Assert + expect(result.header.listTitle).toBe("First-tier Tribunal (Tax Chamber) Weekly Hearing List"); + expect(result.header.weekCommencingDate).toBe("02 January 2025"); + expect(result.header.lastUpdatedDate).toBe("01 January 2025"); + expect(result.header.lastUpdatedTime).toContain("am"); + + expect(result.hearings).toHaveLength(1); + expect(result.hearings[0].date).toBe("02 January 2025"); + expect(result.hearings[0].hearingTime).toBe("10:00am"); + expect(result.hearings[0].caseName).toBe("A Vs HMRC"); + expect(result.hearings[0].caseReferenceNumber).toBe("TC/00001/2025"); + expect(result.hearings[0].judges).toBe("Judge Smith"); + expect(result.hearings[0].members).toBe("Member Jones"); + expect(result.hearings[0].venuePlatform).toBe("London"); + }); + + it("should render multiple hearings", () => { + // Arrange + const hearingList: FttTaxChamberHearingList = [ + { + date: "02/01/2025", + hearingTime: "10:00am", + caseName: "A Vs HMRC", + caseReferenceNumber: "TC/00001/2025", + judges: "Judge Smith", + members: "", + venuePlatform: "London" + }, + { + date: "03/01/2025", + hearingTime: "2:00pm", + caseName: "B Vs HMRC", + caseReferenceNumber: "TC/00002/2025", + judges: "Judge Brown", + members: "Member Jones", + venuePlatform: "Manchester" + } + ]; + + const options = { + locale: "en", + courtName: "First-tier Tribunal (Tax Chamber)", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "First-tier Tribunal (Tax Chamber) Weekly Hearing List" + }; + + // Act + const result = renderFttTaxChamberData(hearingList, options); + + // Assert + expect(result.hearings).toHaveLength(2); + expect(result.hearings[0].date).toBe("02 January 2025"); + expect(result.hearings[1].date).toBe("03 January 2025"); + }); + + it("should handle empty hearing list", () => { + // Arrange + const hearingList: FttTaxChamberHearingList = []; + + const options = { + locale: "en", + courtName: "First-tier Tribunal (Tax Chamber)", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "First-tier Tribunal (Tax Chamber) Weekly Hearing List" + }; + + // Act + const result = renderFttTaxChamberData(hearingList, options); + + // Assert + expect(result.hearings).toHaveLength(0); + expect(result.header.listTitle).toBe("First-tier Tribunal (Tax Chamber) Weekly Hearing List"); + }); + + it("should format lastUpdated time correctly", () => { + // Arrange + const hearingList: FttTaxChamberHearingList = [ + { + date: "02/01/2025", + hearingTime: "10:00am", + caseName: "A Vs HMRC", + caseReferenceNumber: "TC/00001/2025", + judges: "Judge Smith", + members: "", + venuePlatform: "London" + } + ]; + + const options = { + locale: "en", + courtName: "First-tier Tribunal (Tax Chamber)", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T14:30:00Z", + listTitle: "First-tier Tribunal (Tax Chamber) Weekly Hearing List" + }; + + // Act + const result = renderFttTaxChamberData(hearingList, options); + + // Assert + expect(result.header.lastUpdatedTime).toBe("2:30pm"); + }); +}); diff --git a/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/rendering/renderer.ts b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/rendering/renderer.ts new file mode 100644 index 000000000..0af31f956 --- /dev/null +++ b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/rendering/renderer.ts @@ -0,0 +1,45 @@ +import { formatDdMmYyyyDate, formatDisplayDate, formatLastUpdatedDateTime } from "@hmcts/list-types-common"; +import type { FttTaxChamberHearing, FttTaxChamberHearingList } from "../models/types.js"; + +export interface RenderOptions { + locale: string; + courtName: string; + contentDate: Date; + lastReceivedDate: string; + listTitle: string; +} + +export interface RenderedData { + header: { + listTitle: string; + weekCommencingDate: string; + lastUpdatedDate: string; + lastUpdatedTime: string; + }; + hearings: FttTaxChamberHearing[]; +} + +export function renderFttTaxChamberData(hearingList: FttTaxChamberHearingList, options: RenderOptions): RenderedData { + const weekCommencingDate = formatDisplayDate(options.contentDate, options.locale); + const { date: lastUpdatedDate, time: lastUpdatedTime } = formatLastUpdatedDateTime(options.lastReceivedDate, options.locale); + + const renderedHearings = hearingList.map((hearing) => ({ + date: formatDdMmYyyyDate(hearing.date, options.locale), + hearingTime: hearing.hearingTime, + caseName: hearing.caseName, + caseReferenceNumber: hearing.caseReferenceNumber, + judges: hearing.judges, + members: hearing.members, + venuePlatform: hearing.venuePlatform + })); + + return { + header: { + listTitle: options.listTitle, + weekCommencingDate, + lastUpdatedDate, + lastUpdatedTime + }, + hearings: renderedHearings + }; +} diff --git a/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/schemas/ftt-tax-chamber-weekly-hearing-list.json b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/schemas/ftt-tax-chamber-weekly-hearing-list.json new file mode 100644 index 000000000..0c84f39a5 --- /dev/null +++ b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/src/schemas/ftt-tax-chamber-weekly-hearing-list.json @@ -0,0 +1,54 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "First-tier Tribunal (Tax Chamber) Weekly Hearing List", + "description": "Schema for First-tier Tribunal (Tax Chamber) Weekly Hearing List from pip-data-management", + "type": "array", + "items": { + "type": "object", + "required": ["date", "hearingTime", "caseName", "caseReferenceNumber", "judges", "members", "venuePlatform"], + "properties": { + "date": { + "title": "Date", + "type": "string", + "pattern": "^\\d{2}/\\d{2}/\\d{4}$", + "examples": ["02/01/2025"] + }, + "hearingTime": { + "title": "Hearing Time", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["10:00am"] + }, + "caseName": { + "title": "Case Name", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["A Vs HMRC"] + }, + "caseReferenceNumber": { + "title": "Case Reference Number", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["TC/00001/2025"] + }, + "judges": { + "title": "Judge(s)", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["Judge Smith"] + }, + "members": { + "title": "Member(s)", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["Member Jones"] + }, + "venuePlatform": { + "title": "Venue/Platform", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["London"] + } + } + } +} diff --git a/libs/list-types/ftt-tax-chamber-weekly-hearing-list/tsconfig.json b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/tsconfig.json new file mode 100644 index 000000000..a029ee937 --- /dev/null +++ b/libs/list-types/ftt-tax-chamber-weekly-hearing-list/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules", "src/assets/"] +} diff --git a/libs/list-types/grc-weekly-hearing-list/package.json b/libs/list-types/grc-weekly-hearing-list/package.json new file mode 100644 index 000000000..4dd90ddfc --- /dev/null +++ b/libs/list-types/grc-weekly-hearing-list/package.json @@ -0,0 +1,42 @@ +{ + "name": "@hmcts/grc-weekly-hearing-list", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.ts" + } + }, + "scripts": { + "build": "tsc && yarn build:nunjucks && yarn build:schemas", + "build:nunjucks": "mkdir -p dist/pdf && cd src/pdf && find . -name '*.njk' -exec sh -c 'mkdir -p ../../dist/pdf/$(dirname {}) && cp {} ../../dist/pdf/{}' \\;", + "build:schemas": "mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ." + }, + "dependencies": { + "@hmcts/list-types-common": "workspace:*", + "@hmcts/pdf-generation": "workspace:*", + "@hmcts/postgres-prisma": "workspace:*", + "luxon": "3.7.2", + "nunjucks": "3.2.4" + }, + "devDependencies": { + "@types/luxon": "3.7.1", + "@types/node": "24.10.4", + "typescript": "6.0.3", + "vitest": "4.1.9" + }, + "peerDependencies": { + "express": "^5.1.0" + } +} diff --git a/libs/list-types/grc-weekly-hearing-list/src/config.ts b/libs/list-types/grc-weekly-hearing-list/src/config.ts new file mode 100644 index 000000000..a6cbae656 --- /dev/null +++ b/libs/list-types/grc-weekly-hearing-list/src/config.ts @@ -0,0 +1,9 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const moduleRoot = __dirname; +export const assets = path.join(__dirname, "assets/"); +export const schemaPath = path.join(__dirname, "schemas/grc-weekly-hearing-list.json"); diff --git a/libs/list-types/grc-weekly-hearing-list/src/conversion/grc-config.ts b/libs/list-types/grc-weekly-hearing-list/src/conversion/grc-config.ts new file mode 100644 index 000000000..3ec86ec54 --- /dev/null +++ b/libs/list-types/grc-weekly-hearing-list/src/conversion/grc-config.ts @@ -0,0 +1,74 @@ +import { + createConverter, + DD_MM_YYYY_PATTERN, + type ExcelConverterConfig, + registerConverter, + registerConverterByName, + validateDateFormat, + validateNoHtmlTags, + validateTimeFormatSimple +} from "@hmcts/list-types-common"; + +export const GRC_EXCEL_CONFIG: ExcelConverterConfig = { + fields: [ + { + header: "Date", + fieldName: "date", + required: true, + validators: [validateDateFormat(DD_MM_YYYY_PATTERN, "dd/MM/yyyy (e.g., 02/01/2025)")] + }, + { + header: "Hearing time", + fieldName: "hearingTime", + required: true, + validators: [validateTimeFormatSimple] + }, + { + header: "Case reference number", + fieldName: "caseReferenceNumber", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case reference number", rowNumber)] + }, + { + header: "Case name", + fieldName: "caseName", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case name", rowNumber)] + }, + { + header: "Judge(s)", + fieldName: "judges", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Judge(s)", rowNumber)] + }, + { + header: "Member(s)", + fieldName: "members", + required: false, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Member(s)", rowNumber)] + }, + { + header: "Mode of hearing", + fieldName: "modeOfHearing", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Mode of hearing", rowNumber)] + }, + { + header: "Venue", + fieldName: "venue", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Venue", rowNumber)] + }, + { + header: "Additional information", + fieldName: "additionalInformation", + required: false, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Additional information", rowNumber)] + } + ], + minRows: 1 +}; + +const grcConverter = createConverter(GRC_EXCEL_CONFIG); +registerConverter(28, grcConverter); +registerConverterByName("GRC_WEEKLY_HEARING_LIST", grcConverter); diff --git a/libs/list-types/grc-weekly-hearing-list/src/email-summary/summary-builder.test.ts b/libs/list-types/grc-weekly-hearing-list/src/email-summary/summary-builder.test.ts new file mode 100644 index 000000000..b07b37ca4 --- /dev/null +++ b/libs/list-types/grc-weekly-hearing-list/src/email-summary/summary-builder.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import type { GrcWeeklyHearingList } from "../models/types.js"; +import { extractCaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "./summary-builder.js"; + +describe("SPECIAL_CATEGORY_DATA_WARNING", () => { + it("should contain required warning text", () => { + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Special Category Data"); + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Data Protection Act 2018"); + }); +}); + +describe("extractCaseSummary", () => { + it("should extract case summaries from hearing list", () => { + // Arrange + const hearingList: GrcWeeklyHearingList = [ + { + date: "01/01/2025", + hearingTime: "10:00am", + caseReferenceNumber: "GRC/2025/001", + caseName: "Smith v Care Provider Ltd", + judges: "Judge Smith", + members: "", + modeOfHearing: "Remote", + venue: "GRC Hearing Centre", + additionalInformation: "" + }, + { + date: "02/01/2025", + hearingTime: "2:00pm", + caseReferenceNumber: "GRC/2025/002", + caseName: "Brown v Regulator", + judges: "Judge Brown", + members: "Member Jones", + modeOfHearing: "In person", + venue: "GRC Office", + additionalInformation: "" + } + ]; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(2); + expect(result[0]).toEqual([ + { label: "Date", value: "01/01/2025" }, + { label: "Hearing time", value: "10:00am" }, + { label: "Case reference number", value: "GRC/2025/001" } + ]); + expect(result[1]).toEqual([ + { label: "Date", value: "02/01/2025" }, + { label: "Hearing time", value: "2:00pm" }, + { label: "Case reference number", value: "GRC/2025/002" } + ]); + }); + + it("should handle empty hearing list", () => { + // Arrange + const hearingList: GrcWeeklyHearingList = []; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(0); + }); + + it("should handle missing fields with empty string", () => { + // Arrange + const hearingList: GrcWeeklyHearingList = [ + { + date: "", + hearingTime: "", + caseReferenceNumber: "", + caseName: "", + judges: "", + members: "", + modeOfHearing: "", + venue: "", + additionalInformation: "" + } + ]; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(1); + expect(result[0]).toEqual([ + { label: "Date", value: "" }, + { label: "Hearing time", value: "" }, + { label: "Case reference number", value: "" } + ]); + }); +}); + +describe("formatCaseSummaryForEmail", () => { + it("should format single case summary correctly", () => { + // Arrange + const items = [ + [ + { label: "Date", value: "01/01/2025" }, + { label: "Hearing time", value: "10:00am" }, + { label: "Case reference number", value: "GRC/2025/001" } + ] + ]; + + // Act + const result = formatCaseSummaryForEmail(items); + + // Assert + expect(result).toContain("---"); + expect(result).toContain("Date - 01/01/2025"); + expect(result).toContain("Hearing time - 10:00am"); + expect(result).toContain("Case reference number - GRC/2025/001"); + }); + + it("should handle empty case list", () => { + // Act + const result = formatCaseSummaryForEmail([]); + + // Assert + expect(result).toBe("No cases scheduled."); + }); +}); diff --git a/libs/list-types/grc-weekly-hearing-list/src/email-summary/summary-builder.ts b/libs/list-types/grc-weekly-hearing-list/src/email-summary/summary-builder.ts new file mode 100644 index 000000000..168ae4eb3 --- /dev/null +++ b/libs/list-types/grc-weekly-hearing-list/src/email-summary/summary-builder.ts @@ -0,0 +1,12 @@ +import { type CaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "@hmcts/list-types-common"; +import type { GrcWeeklyHearingList } from "../models/types.js"; + +export { formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING }; + +export function extractCaseSummary(jsonData: GrcWeeklyHearingList): CaseSummary[] { + return jsonData.map((hearing) => [ + { label: "Date", value: hearing.date || "" }, + { label: "Hearing time", value: hearing.hearingTime || "" }, + { label: "Case reference number", value: hearing.caseReferenceNumber || "" } + ]); +} diff --git a/libs/list-types/grc-weekly-hearing-list/src/index.ts b/libs/list-types/grc-weekly-hearing-list/src/index.ts new file mode 100644 index 000000000..f81d7e5b4 --- /dev/null +++ b/libs/list-types/grc-weekly-hearing-list/src/index.ts @@ -0,0 +1,12 @@ +import "./conversion/grc-config.js"; // Register converter on module load + +// Business logic exports +export type { ValidationResult } from "@hmcts/list-types-common"; +export * from "./email-summary/summary-builder.js"; +export { cy as grcWeeklyHearingListCy } from "./locales/cy.js"; +// Locale exports +export { en as grcWeeklyHearingListEn } from "./locales/en.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export * from "./rendering/renderer.js"; +export { validateGrcWeeklyHearingList } from "./validation/json-validator.js"; diff --git a/libs/list-types/grc-weekly-hearing-list/src/locales/cy.ts b/libs/list-types/grc-weekly-hearing-list/src/locales/cy.ts new file mode 100644 index 000000000..98f6bcf78 --- /dev/null +++ b/libs/list-types/grc-weekly-hearing-list/src/locales/cy.ts @@ -0,0 +1,40 @@ +import { provenanceLabelsCy as provenanceLabels } from "@hmcts/list-types-common"; + +export const cy = { + pageTitle: "[WELSH TRANSLATION REQUIRED: 'General Regulatory Chamber Weekly Hearing List']", + listForWeekCommencing: "Rhestr ar gyfer yr wythnos yn dechrau ar", + lastUpdated: "Diweddarwyd ddiwethaf", + at: "am", + factLinkText: "Dod o hyd i fanylion cyswllt a gwybodaeth arall am lysoedd a thribiwnlysoedd yng Nghymru a Lloegr", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "a rhai tribiwnlysoedd heb eu datganoli yn yr Alban.", + importantInformationTitle: "Gwybodaeth bwysig", + importantInformationText: + "[WELSH TRANSLATION REQUIRED: 'Parties and representatives will be informed about arrangements for hearing cases remotely. Any other person interested in joining the hearing remotely should email GRC@justice.gov.uk so that arrangements can be made. If the case is to be heard in private or is subject to a reporting restriction, this will be notified.']", + importantInformationRecordingText: + "[WELSH TRANSLATION REQUIRED: 'If you join a hearing you must not make any personal or private recording or publish any part of this hearing, including court communications. It is a criminal offence to do so.']", + importantInformationLinkText: "Arsylwi gwrandawiad llys neu dribiwnlys fel newyddiadurwr, ymchwilydd neu aelod o'r cyhoedd", + importantInformationLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + importantInformationLink2Text: "[WELSH TRANSLATION REQUIRED: 'What to expect when joining a telephone or video hearing']", + importantInformationLink2Url: "https://www.gov.uk/guidance/what-to-expect-when-joining-a-telephone-or-video-hearing", + searchCasesTitle: "Chwilio Achosion", + searchCasesLabel: "[WELSH TRANSLATION REQUIRED: 'Search by case reference number, case name, date, venue, or other details']", + tableHeaders: { + date: "[WELSH TRANSLATION REQUIRED: 'Date']", + hearingTime: "[WELSH TRANSLATION REQUIRED: 'Hearing time']", + caseReferenceNumber: "[WELSH TRANSLATION REQUIRED: 'Case reference number']", + caseName: "[WELSH TRANSLATION REQUIRED: 'Case name']", + judges: "[WELSH TRANSLATION REQUIRED: 'Judge(s)']", + members: "[WELSH TRANSLATION REQUIRED: 'Member(s)']", + modeOfHearing: "[WELSH TRANSLATION REQUIRED: 'Mode of hearing']", + venue: "[WELSH TRANSLATION REQUIRED: 'Venue']", + additionalInformation: "Gwybodaeth ychwanegol" + }, + dataSource: "Ffynhonnell data", + backToTop: "Yn ôl i frig y dudalen", + cautionNote: + "Noder bod y ddogfen hon yn cynnwys Data Categori Arbennig fel y'i diffinnir yn Neddf Gwarchod Data 2018, a elwid gynt yn Ddata Personol Sensitif, a dylid ei drin yn y ffordd briodol.", + cautionReporting: + "Mae'r ddogfen hon yn cynnwys gwybodaeth a fwriedir i gynorthwyo i roi adroddiad manwl-gywir am achosion llys. Mae'n hanfodol eich bod yn sicrhau eich bod yn gwarchod y Data Categori Arbennig sydd ynddi ac yn cadw at gyfyngiadau adrodd (er enghraifft yn achos dioddefwyr a phlant). Bydd GLlTEF yn rhoi'r gorau i anfon y data os cyfyd pryder ynghylch sut y'i defnyddir.", + provenanceLabels +}; diff --git a/libs/list-types/grc-weekly-hearing-list/src/locales/en.ts b/libs/list-types/grc-weekly-hearing-list/src/locales/en.ts new file mode 100644 index 000000000..63b6eb477 --- /dev/null +++ b/libs/list-types/grc-weekly-hearing-list/src/locales/en.ts @@ -0,0 +1,40 @@ +import { provenanceLabelsEn as provenanceLabels } from "@hmcts/list-types-common"; + +export const en = { + pageTitle: "General Regulatory Chamber Weekly Hearing List", + listForWeekCommencing: "List for week commencing", + lastUpdated: "Last updated", + at: "at", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + importantInformationTitle: "Important information", + importantInformationText: + "Parties and representatives will be informed about arrangements for hearing cases remotely. Any other person interested in joining the hearing remotely should email GRC@justice.gov.uk so that arrangements can be made. If the case is to be heard in private or is subject to a reporting restriction, this will be notified.", + importantInformationRecordingText: + "If you join a hearing you must not make any personal or private recording or publish any part of this hearing, including court communications. It is a criminal offence to do so.", + importantInformationLinkText: "Observe a court or tribunal hearing as a journalist, researcher or member of the public", + importantInformationLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + importantInformationLink2Text: "What to expect when joining a telephone or video hearing", + importantInformationLink2Url: "https://www.gov.uk/guidance/what-to-expect-when-joining-a-telephone-or-video-hearing", + searchCasesTitle: "Search Cases", + searchCasesLabel: "Search by case reference number, case name, date, venue, or other details", + tableHeaders: { + date: "Date", + hearingTime: "Hearing time", + caseReferenceNumber: "Case reference number", + caseName: "Case name", + judges: "Judge(s)", + members: "Member(s)", + modeOfHearing: "Mode of hearing", + venue: "Venue", + additionalInformation: "Additional information" + }, + dataSource: "Data source", + backToTop: "Back to top", + cautionNote: + "Note this document contains Special Category Data as defined by Data Protection Act 2018, formally known as Sensitive Personal Data, and should be handled appropriately.", + cautionReporting: + "This document contains information intended to assist the accurate reporting of court proceedings. It is vital you ensure that you safeguard the Special Category Data included and abide by reporting restrictions (for example on victims and children). HMCTS will stop sending the data if there is concern about how it will be used.", + provenanceLabels +}; diff --git a/libs/list-types/grc-weekly-hearing-list/src/models/types.ts b/libs/list-types/grc-weekly-hearing-list/src/models/types.ts new file mode 100644 index 000000000..fdbdb6542 --- /dev/null +++ b/libs/list-types/grc-weekly-hearing-list/src/models/types.ts @@ -0,0 +1,13 @@ +export interface GrcWeeklyHearing { + date: string; + hearingTime: string; + caseReferenceNumber: string; + caseName: string; + judges: string; + members: string; + modeOfHearing: string; + venue: string; + additionalInformation: string; +} + +export type GrcWeeklyHearingList = GrcWeeklyHearing[]; diff --git a/libs/list-types/grc-weekly-hearing-list/src/pdf/pdf-generator.test.ts b/libs/list-types/grc-weekly-hearing-list/src/pdf/pdf-generator.test.ts new file mode 100644 index 000000000..6fffa0e8d --- /dev/null +++ b/libs/list-types/grc-weekly-hearing-list/src/pdf/pdf-generator.test.ts @@ -0,0 +1,257 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockSavePdfToStorage, mockCreatePdfErrorResult, mockConfigureNunjucks, mockLoadTranslations } = vi.hoisted(() => ({ + mockSavePdfToStorage: vi.fn(), + mockCreatePdfErrorResult: vi.fn(), + mockConfigureNunjucks: vi.fn(), + mockLoadTranslations: vi.fn() +})); + +vi.mock("@hmcts/list-types-common", () => ({ + savePdfToStorage: mockSavePdfToStorage, + createPdfErrorResult: mockCreatePdfErrorResult, + configureNunjucks: mockConfigureNunjucks, + loadTranslations: mockLoadTranslations, + PDF_BASE_STYLES: "/* base styles */", + provenanceLabelsEn: { + MANUAL_UPLOAD: "Manual Upload", + SNL: "SNL" + } +})); + +vi.mock("@hmcts/pdf-generation", () => ({ + generatePdfFromHtml: vi.fn() +})); + +vi.mock("../rendering/renderer.js", () => ({ + renderGrcWeeklyHearingListData: vi.fn() +})); + +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { renderGrcWeeklyHearingListData } from "../rendering/renderer.js"; +import { generateGrcWeeklyHearingListPdf } from "./pdf-generator.js"; + +const mockRenderedData = { + header: { + listTitle: "General Regulatory Chamber Weekly Hearing List", + weekCommencingDate: "01 January 2025", + lastUpdatedDate: "12 November 2025", + lastUpdatedTime: "9am" + }, + hearings: [] +}; + +const mockHearingList = [ + { + date: "01/01/2025", + hearingTime: "10:00am", + caseReferenceNumber: "GRC/2025/001", + caseName: "Smith v Regulator", + judges: "Judge Smith", + members: "", + modeOfHearing: "Remote", + venue: "GRC Hearing Centre", + additionalInformation: "" + } +]; + +describe("generateGrcWeeklyHearingListPdf", () => { + const mockNunjucksEnv = { + render: vi.fn().mockReturnValue("PDF HTML") + }; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(renderGrcWeeklyHearingListData).mockReturnValue(mockRenderedData); + mockConfigureNunjucks.mockReturnValue(mockNunjucksEnv); + mockLoadTranslations.mockResolvedValue({ pageTitle: "Test Title" }); + mockCreatePdfErrorResult.mockImplementation((error: unknown) => ({ + success: false, + error: `Failed to generate PDF: ${error instanceof Error ? error.message : "Unknown error"}` + })); + }); + + it("should generate PDF successfully", async () => { + // Arrange + const pdfBuffer = Buffer.from("PDF content"); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer, + sizeBytes: 1024 + }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "test-artefact-123.pdf", + sizeBytes: 1024, + exceedsMaxSize: false + }); + + // Act + const result = await generateGrcWeeklyHearingListPdf({ + artefactId: "test-artefact-123", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(true); + expect(result.pdfPath).toContain("test-artefact-123.pdf"); + expect(result.sizeBytes).toBe(1024); + expect(result.exceedsMaxSize).toBe(false); + }); + + it("should return exceedsMaxSize true when PDF is over 2MB", async () => { + // Arrange + const largePdfBuffer = Buffer.alloc(3 * 1024 * 1024); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: largePdfBuffer, + sizeBytes: 3 * 1024 * 1024 + }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "large-pdf-123.pdf", + sizeBytes: 3 * 1024 * 1024, + exceedsMaxSize: true + }); + + // Act + const result = await generateGrcWeeklyHearingListPdf({ + artefactId: "large-pdf-123", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(true); + expect(result.exceedsMaxSize).toBe(true); + }); + + it("should return error when PDF generation fails", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: false, + error: "Puppeteer crashed" + }); + + // Act + const result = await generateGrcWeeklyHearingListPdf({ + artefactId: "failed-pdf", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("Puppeteer crashed"); + }); + + it("should pass correct render options to renderer", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "test-render-options.pdf", + sizeBytes: 100, + exceedsMaxSize: false + }); + + const contentDate = new Date("2025-06-15"); + + // Act + await generateGrcWeeklyHearingListPdf({ + artefactId: "test-render-options", + contentDate, + locale: "cy", + locationId: "999", + jsonData: mockHearingList + }); + + // Assert + expect(renderGrcWeeklyHearingListData).toHaveBeenCalledWith(mockHearingList, { + locale: "cy", + courtName: "General Regulatory Chamber", + contentDate, + lastReceivedDate: expect.any(String), + listTitle: "General Regulatory Chamber Weekly Hearing List" + }); + }); + + it("should return error when PDF buffer is missing", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ success: true, pdfBuffer: undefined, sizeBytes: 0 }); + + // Act + const result = await generateGrcWeeklyHearingListPdf({ + artefactId: "no-buffer", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("PDF generation failed"); + }); + + it("should handle renderer errors gracefully", async () => { + // Arrange + vi.mocked(renderGrcWeeklyHearingListData).mockImplementation(() => { + throw new Error("Renderer failed"); + }); + + // Act + const result = await generateGrcWeeklyHearingListPdf({ + artefactId: "renderer-error", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("Failed to generate PDF: Renderer failed"); + }); + + it("should use provenance label when provenance is provided", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "provenance-test.pdf", + sizeBytes: 100, + exceedsMaxSize: false + }); + + // Act + const result = await generateGrcWeeklyHearingListPdf({ + artefactId: "provenance-test", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList, + provenance: "MANUAL_UPLOAD" + }); + + // Assert + expect(result.success).toBe(true); + expect(mockNunjucksEnv.render).toHaveBeenCalledWith("pdf-template.njk", expect.objectContaining({ dataSource: "Manual Upload" })); + }); +}); diff --git a/libs/list-types/grc-weekly-hearing-list/src/pdf/pdf-generator.ts b/libs/list-types/grc-weekly-hearing-list/src/pdf/pdf-generator.ts new file mode 100644 index 000000000..aa3b5a087 --- /dev/null +++ b/libs/list-types/grc-weekly-hearing-list/src/pdf/pdf-generator.ts @@ -0,0 +1,64 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + type BasePdfGenerationOptions, + configureNunjucks, + createPdfErrorResult, + loadTranslations, + PDF_BASE_STYLES, + type PdfGenerationResult, + provenanceLabelsEn as PROVENANCE_LABELS, + savePdfToStorage +} from "@hmcts/list-types-common"; +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import type { GrcWeeklyHearingList } from "../models/types.js"; +import { renderGrcWeeklyHearingListData } from "../rendering/renderer.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface PdfGenerationOptions extends BasePdfGenerationOptions { + contentDate: Date; +} + +export async function generateGrcWeeklyHearingListPdf(options: PdfGenerationOptions): Promise { + try { + const renderedData = renderGrcWeeklyHearingListData(options.jsonData, { + locale: options.locale, + courtName: "General Regulatory Chamber", + contentDate: options.contentDate, + lastReceivedDate: new Date().toISOString(), + listTitle: "General Regulatory Chamber Weekly Hearing List" + }); + + const translations = await loadTranslations( + options.locale, + () => import("../locales/en.js"), + () => import("../locales/cy.js") + ); + + const provenanceLabel = options.provenance ? PROVENANCE_LABELS[options.provenance as keyof typeof PROVENANCE_LABELS] || options.provenance : ""; + + const env = configureNunjucks(__dirname); + const html = env.render("pdf-template.njk", { + header: renderedData.header, + hearings: renderedData.hearings, + dataSource: provenanceLabel, + t: translations, + pdfStyles: PDF_BASE_STYLES + }); + + const pdfResult = await generatePdfFromHtml(html); + + if (!pdfResult.success || !pdfResult.pdfBuffer) { + return { + success: false, + error: pdfResult.error || "PDF generation failed" + }; + } + + return await savePdfToStorage(options.artefactId, pdfResult.pdfBuffer, pdfResult.sizeBytes!); + } catch (error) { + return createPdfErrorResult(error); + } +} diff --git a/libs/list-types/grc-weekly-hearing-list/src/pdf/pdf-template.njk b/libs/list-types/grc-weekly-hearing-list/src/pdf/pdf-template.njk new file mode 100644 index 000000000..1d3906cd4 --- /dev/null +++ b/libs/list-types/grc-weekly-hearing-list/src/pdf/pdf-template.njk @@ -0,0 +1,72 @@ + + + + + + {{ header.listTitle }} + + + +
+

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForWeekCommencing }} {{ header.weekCommencingDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+
+ +
+

{{ t.importantInformationTitle }}

+

{{ t.importantInformationText }}

+

{{ t.importantInformationRecordingText }}

+

{{ t.importantInformationLink2Text }}

+

{{ t.importantInformationLinkText }}

+
+ + {% if hearings.length > 0 %} + + + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.date }}{{ t.tableHeaders.hearingTime }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.caseName }}{{ t.tableHeaders.judges }}{{ t.tableHeaders.members }}{{ t.tableHeaders.modeOfHearing }}{{ t.tableHeaders.venue }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.date }}{{ hearing.hearingTime }}{{ hearing.caseReferenceNumber }}{{ hearing.caseName }}{{ hearing.judges }}{{ hearing.members }}{{ hearing.modeOfHearing }}{{ hearing.venue }}{{ hearing.additionalInformation }}
+ {% else %} +

No hearings scheduled.

+ {% endif %} + + + + diff --git a/libs/list-types/grc-weekly-hearing-list/src/rendering/renderer.test.ts b/libs/list-types/grc-weekly-hearing-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..56133227c --- /dev/null +++ b/libs/list-types/grc-weekly-hearing-list/src/rendering/renderer.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import type { GrcWeeklyHearingList } from "../models/types.js"; +import { renderGrcWeeklyHearingListData } from "./renderer.js"; + +describe("renderGrcWeeklyHearingListData", () => { + it("should render hearing list with formatted dates", () => { + // Arrange + const hearingList: GrcWeeklyHearingList = [ + { + date: "02/01/2025", + hearingTime: "10:00am", + caseReferenceNumber: "GRC/2025/001", + caseName: "A Vs B", + judges: "Judge Smith", + members: "Member Jones", + modeOfHearing: "Remote", + venue: "GRC Hearing Centre", + additionalInformation: "Remote hearing" + } + ]; + + const options = { + locale: "en", + courtName: "General Regulatory Chamber", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "General Regulatory Chamber Weekly Hearing List" + }; + + // Act + const result = renderGrcWeeklyHearingListData(hearingList, options); + + // Assert + expect(result.header.listTitle).toBe("General Regulatory Chamber Weekly Hearing List"); + expect(result.header.weekCommencingDate).toBe("02 January 2025"); + expect(result.header.lastUpdatedDate).toBe("01 January 2025"); + expect(result.header.lastUpdatedTime).toContain("am"); + + expect(result.hearings).toHaveLength(1); + expect(result.hearings[0].date).toBe("02 January 2025"); + expect(result.hearings[0].hearingTime).toBe("10:00am"); + expect(result.hearings[0].caseReferenceNumber).toBe("GRC/2025/001"); + expect(result.hearings[0].caseName).toBe("A Vs B"); + expect(result.hearings[0].judges).toBe("Judge Smith"); + expect(result.hearings[0].members).toBe("Member Jones"); + expect(result.hearings[0].modeOfHearing).toBe("Remote"); + expect(result.hearings[0].venue).toBe("GRC Hearing Centre"); + expect(result.hearings[0].additionalInformation).toBe("Remote hearing"); + }); + + it("should render multiple hearings", () => { + // Arrange + const hearingList: GrcWeeklyHearingList = [ + { + date: "02/01/2025", + hearingTime: "10:00am", + caseReferenceNumber: "GRC/2025/001", + caseName: "A Vs B", + judges: "Judge Smith", + members: "", + modeOfHearing: "Remote", + venue: "GRC Hearing Centre", + additionalInformation: "" + }, + { + date: "03/01/2025", + hearingTime: "2:00pm", + caseReferenceNumber: "GRC/2025/002", + caseName: "C Vs D", + judges: "Judge Brown", + members: "Member Green", + modeOfHearing: "In person", + venue: "GRC Office", + additionalInformation: "In person" + } + ]; + + const options = { + locale: "en", + courtName: "General Regulatory Chamber", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "General Regulatory Chamber Weekly Hearing List" + }; + + // Act + const result = renderGrcWeeklyHearingListData(hearingList, options); + + // Assert + expect(result.hearings).toHaveLength(2); + expect(result.hearings[0].date).toBe("02 January 2025"); + expect(result.hearings[1].date).toBe("03 January 2025"); + }); + + it("should handle empty hearing list", () => { + // Arrange + const hearingList: GrcWeeklyHearingList = []; + + const options = { + locale: "en", + courtName: "General Regulatory Chamber", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "General Regulatory Chamber Weekly Hearing List" + }; + + // Act + const result = renderGrcWeeklyHearingListData(hearingList, options); + + // Assert + expect(result.hearings).toHaveLength(0); + expect(result.header.listTitle).toBe("General Regulatory Chamber Weekly Hearing List"); + }); + + it("should format lastUpdated time correctly", () => { + // Arrange + const hearingList: GrcWeeklyHearingList = [ + { + date: "02/01/2025", + hearingTime: "10:00am", + caseReferenceNumber: "GRC/2025/001", + caseName: "A Vs B", + judges: "Judge Smith", + members: "", + modeOfHearing: "Remote", + venue: "GRC Hearing Centre", + additionalInformation: "" + } + ]; + + const options = { + locale: "en", + courtName: "General Regulatory Chamber", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T14:30:00Z", + listTitle: "General Regulatory Chamber Weekly Hearing List" + }; + + // Act + const result = renderGrcWeeklyHearingListData(hearingList, options); + + // Assert + expect(result.header.lastUpdatedTime).toBe("2:30pm"); + }); +}); diff --git a/libs/list-types/grc-weekly-hearing-list/src/rendering/renderer.ts b/libs/list-types/grc-weekly-hearing-list/src/rendering/renderer.ts new file mode 100644 index 000000000..9759521bb --- /dev/null +++ b/libs/list-types/grc-weekly-hearing-list/src/rendering/renderer.ts @@ -0,0 +1,47 @@ +import { formatDdMmYyyyDate, formatDisplayDate, formatLastUpdatedDateTime } from "@hmcts/list-types-common"; +import type { GrcWeeklyHearing, GrcWeeklyHearingList } from "../models/types.js"; + +export interface RenderOptions { + locale: string; + courtName: string; + contentDate: Date; + lastReceivedDate: string; + listTitle: string; +} + +export interface RenderedData { + header: { + listTitle: string; + weekCommencingDate: string; + lastUpdatedDate: string; + lastUpdatedTime: string; + }; + hearings: GrcWeeklyHearing[]; +} + +export function renderGrcWeeklyHearingListData(hearingList: GrcWeeklyHearingList, options: RenderOptions): RenderedData { + const weekCommencingDate = formatDisplayDate(options.contentDate, options.locale); + const { date: lastUpdatedDate, time: lastUpdatedTime } = formatLastUpdatedDateTime(options.lastReceivedDate, options.locale); + + const renderedHearings = hearingList.map((hearing) => ({ + date: formatDdMmYyyyDate(hearing.date, options.locale), + hearingTime: hearing.hearingTime, + caseReferenceNumber: hearing.caseReferenceNumber, + caseName: hearing.caseName, + judges: hearing.judges, + members: hearing.members, + modeOfHearing: hearing.modeOfHearing, + venue: hearing.venue, + additionalInformation: hearing.additionalInformation + })); + + return { + header: { + listTitle: options.listTitle, + weekCommencingDate, + lastUpdatedDate, + lastUpdatedTime + }, + hearings: renderedHearings + }; +} diff --git a/libs/list-types/grc-weekly-hearing-list/src/schemas/grc-weekly-hearing-list.json b/libs/list-types/grc-weekly-hearing-list/src/schemas/grc-weekly-hearing-list.json new file mode 100644 index 000000000..b6e175aec --- /dev/null +++ b/libs/list-types/grc-weekly-hearing-list/src/schemas/grc-weekly-hearing-list.json @@ -0,0 +1,75 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Root", + "type": "array", + "items": { + "title": "Hearing list", + "type": "object", + "required": ["date", "hearingTime", "caseReferenceNumber", "caseName", "judges", "members", "modeOfHearing", "venue", "additionalInformation"], + "properties": { + "date": { + "title": "Date of hearing", + "type": "string", + "default": "", + "examples": ["02/01/2025"], + "pattern": "^\\d{2}/\\d{2}/\\d{4}$" + }, + "hearingTime": { + "title": "Time of hearing", + "type": "string", + "default": "", + "examples": ["10:30am"], + "pattern": "^\\d{1,2}([:.]\\d{2})?[ap]m\\s*$" + }, + "caseReferenceNumber": { + "title": "Case reference number", + "type": "string", + "default": "", + "examples": ["12345"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "caseName": { + "title": "Case name", + "type": "string", + "default": "", + "examples": ["A Vs B"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "judges": { + "title": "Judges", + "type": "string", + "default": "", + "examples": ["Judge A"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "members": { + "title": "Members of the panel", + "type": "string", + "default": "", + "examples": ["Forename Surname"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "modeOfHearing": { + "title": "Mode of hearing being presented", + "type": "string", + "default": "", + "examples": ["Oral Hearing"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "venue": { + "title": "Venue name of the hearing", + "type": "string", + "default": "", + "examples": ["This is the venue of the hearing"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "additionalInformation": { + "title": "Additional information", + "type": "string", + "default": "", + "examples": ["This is additional information"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + } + } +} diff --git a/libs/list-types/grc-weekly-hearing-list/src/validation/json-validator.test.ts b/libs/list-types/grc-weekly-hearing-list/src/validation/json-validator.test.ts new file mode 100644 index 000000000..93165dfca --- /dev/null +++ b/libs/list-types/grc-weekly-hearing-list/src/validation/json-validator.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { validateGrcWeeklyHearingList } from "./json-validator.js"; + +describe("validateGrcWeeklyHearingList", () => { + it("should return isValid true for valid data", () => { + // Arrange + const validData = [ + { + date: "01/01/2025", + hearingTime: "10:00am", + caseReferenceNumber: "GRC/2025/001", + caseName: "A Vs B", + judges: "Judge Smith", + members: "", + modeOfHearing: "Remote", + venue: "GRC Hearing Centre", + additionalInformation: "" + } + ]; + + // Act + const result = validateGrcWeeklyHearingList(validData); + + // Assert + expect(result.isValid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("should return isValid false when required fields are missing", () => { + // Arrange + const invalidData = [ + { + date: "01/01/2025" + } + ]; + + // Act + const result = validateGrcWeeklyHearingList(invalidData); + + // Assert + expect(result.isValid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); +}); diff --git a/libs/list-types/grc-weekly-hearing-list/src/validation/json-validator.ts b/libs/list-types/grc-weekly-hearing-list/src/validation/json-validator.ts new file mode 100644 index 000000000..f6a78614c --- /dev/null +++ b/libs/list-types/grc-weekly-hearing-list/src/validation/json-validator.ts @@ -0,0 +1,6 @@ +import { createJsonValidator, type ValidationResult } from "@hmcts/list-types-common"; +import { schemaPath } from "../config.js"; + +export function validateGrcWeeklyHearingList(jsonData: unknown): ValidationResult { + return createJsonValidator(schemaPath)(jsonData); +} diff --git a/libs/list-types/grc-weekly-hearing-list/tsconfig.json b/libs/list-types/grc-weekly-hearing-list/tsconfig.json new file mode 100644 index 000000000..a029ee937 --- /dev/null +++ b/libs/list-types/grc-weekly-hearing-list/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules", "src/assets/"] +} diff --git a/libs/list-types/magistrates-standard-list/package.json b/libs/list-types/magistrates-standard-list/package.json new file mode 100644 index 000000000..575a3431e --- /dev/null +++ b/libs/list-types/magistrates-standard-list/package.json @@ -0,0 +1,28 @@ +{ + "name": "@hmcts/magistrates-standard-list", + "version": "1.0.0", + "type": "module", + "dependencies": { + "@hmcts/location": "workspace:*" + }, + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.ts" + } + }, + "scripts": { + "build": "tsc && yarn build:pdf-templates", + "build:pdf-templates": "mkdir -p dist/pdf && cp src/pdf/*.njk dist/pdf/", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write ." + } +} diff --git a/libs/list-types/magistrates-standard-list/src/assets/css/magistrates-standard-list.scss b/libs/list-types/magistrates-standard-list/src/assets/css/magistrates-standard-list.scss new file mode 100644 index 000000000..c40c5cabe --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/assets/css/magistrates-standard-list.scss @@ -0,0 +1,25 @@ +.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; +} + +.govuk-accordion__controls { + text-align: right; +} + +.govuk-details .govuk-table__cell, +.govuk-details .govuk-table__header { + border-bottom: none; +} diff --git a/libs/list-types/magistrates-standard-list/src/config.test.ts b/libs/list-types/magistrates-standard-list/src/config.test.ts new file mode 100644 index 000000000..e7bf88f36 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/config.test.ts @@ -0,0 +1,24 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { assets, moduleRoot, schemaPath } from "./config.js"; + +describe("config", () => { + it("should export moduleRoot pointing to the src directory", () => { + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + expect(moduleRoot).toBe(__dirname); + }); + + it("should export assets as a sibling assets/ path", () => { + expect(assets).toBeDefined(); + expect(typeof assets).toBe("string"); + expect(assets).toContain("assets"); + }); + + it("should export schemaPath pointing to the JSON schema", () => { + expect(schemaPath).toBeDefined(); + expect(typeof schemaPath).toBe("string"); + expect(schemaPath).toContain("magistrates-standard-list.json"); + }); +}); diff --git a/libs/list-types/magistrates-standard-list/src/config.ts b/libs/list-types/magistrates-standard-list/src/config.ts new file mode 100644 index 000000000..0e0ce1f50 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/config.ts @@ -0,0 +1,9 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const moduleRoot = __dirname; +export const assets = path.join(__dirname, "assets/"); +export const schemaPath = path.join(__dirname, "schemas/magistrates-standard-list.json"); diff --git a/libs/list-types/magistrates-standard-list/src/email-summary/summary-builder.test.ts b/libs/list-types/magistrates-standard-list/src/email-summary/summary-builder.test.ts new file mode 100644 index 000000000..97f1f05e4 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/email-summary/summary-builder.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vitest"; +import type { MagistratesStandardList } from "../models/types.js"; +import { extractCaseSummary } from "./summary-builder.js"; + +const BASE: MagistratesStandardList = { + document: { publicationDate: "2025-01-13T09:30:00.000Z" }, + venue: {}, + courtLists: [] +}; + +function makeJson( + hearings: MagistratesStandardList["courtLists"][0]["courtHouse"]["courtRoom"][0]["session"][0]["sittings"][0]["hearing"] +): MagistratesStandardList { + return { + ...BASE, + courtLists: [ + { + courtHouse: { + courtHouseName: "Test Court", + courtRoom: [ + { + courtRoomName: "Court 1", + session: [{ sittings: [{ sittingStart: "2025-01-13T10:00:00.000Z", hearing: hearings }] }] + } + ] + } + } + ] + }; +} + +describe("extractCaseSummary", () => { + it("should include middle name in defendant name", () => { + const result = extractCaseSummary( + makeJson([ + { + case: [ + { + caseUrn: "URN001", + party: [ + { + partyRole: "DEFENDANT", + individualDetails: { + individualForenames: "John", + individualMiddleName: "Edward", + individualSurname: "Smith" + } + } + ] + } + ] + } + ]) + ); + + expect(result[0].find((f) => f.label === "Name")?.value).toBe("Smith, John Edward"); + }); + + it("should include middle name in applicant name", () => { + const result = extractCaseSummary( + makeJson([ + { + application: [ + { + applicationReference: "APP001", + party: [ + { + subject: true, + individualDetails: { + individualForenames: "Jane", + individualMiddleName: "Marie", + individualSurname: "Doe" + } + } + ] + } + ] + } + ]) + ); + + expect(result[0].find((f) => f.label === "Name")?.value).toBe("Doe, Jane Marie"); + }); + + it("should join all offence titles with comma for defendant", () => { + const result = extractCaseSummary( + makeJson([ + { + case: [ + { + caseUrn: "URN002", + party: [ + { + partyRole: "DEFENDANT", + individualDetails: { individualForenames: "Bob", individualSurname: "Jones" }, + offence: [{ offenceTitle: "Drink driving" }, { offenceTitle: "Assault by beating" }, { offenceTitle: "Criminal damage" }] + } + ] + } + ] + } + ]) + ); + + expect(result[0].find((f) => f.label === "Offence")?.value).toBe("Drink driving, Assault by beating, Criminal damage"); + }); + + it("should display offences for application subject party", () => { + const result = extractCaseSummary( + makeJson([ + { + application: [ + { + applicationReference: "APP002", + party: [ + { + subject: true, + organisationDetails: { organisationName: "Respondent Ltd" }, + offence: [{ offenceTitle: "Breach of order" }, { offenceTitle: "Contempt of court" }] + } + ] + } + ] + } + ]) + ); + + expect(result[0].find((f) => f.label === "Offence")?.value).toBe("Breach of order, Contempt of court"); + }); + + it("should not include Offence field when no offences present", () => { + const result = extractCaseSummary( + makeJson([ + { + case: [ + { + caseUrn: "URN003", + party: [{ partyRole: "DEFENDANT", individualDetails: { individualForenames: "Ann", individualSurname: "Brown" } }] + } + ] + } + ]) + ); + + expect(result[0].find((f) => f.label === "Offence")).toBeUndefined(); + }); + + it("should include prosecuting authority for cases", () => { + const result = extractCaseSummary( + makeJson([ + { + case: [ + { + caseUrn: "URN004", + party: [ + { partyRole: "DEFENDANT", individualDetails: { individualForenames: "Tom", individualSurname: "Hill" } }, + { partyRole: "PROSECUTING_AUTHORITY", organisationDetails: { organisationName: "Crown Prosecution Service" } } + ] + } + ] + } + ]) + ); + + expect(result[0].find((f) => f.label === "Prosecuting authority")?.value).toBe("Crown Prosecution Service"); + }); + + it("should not include Offence field for applications with no offences", () => { + const result = extractCaseSummary( + makeJson([ + { + application: [ + { + applicationReference: "APP003", + party: [{ subject: true, organisationDetails: { organisationName: "Test Org" } }] + } + ] + } + ]) + ); + + expect(result[0].find((f) => f.label === "Offence")).toBeUndefined(); + }); + + it("should return empty name when party has neither individual nor organisation details", () => { + const result = extractCaseSummary( + makeJson([ + { + case: [ + { + caseUrn: "URN005", + party: [{ partyRole: "DEFENDANT" }] + } + ] + } + ]) + ); + + expect(result[0].find((f) => f.label === "Name")).toBeUndefined(); + }); + + it("should include hearing type for case hearings when present", () => { + const result = extractCaseSummary( + makeJson([ + { + hearingType: "First hearing", + case: [ + { + caseUrn: "URN006", + party: [{ partyRole: "DEFENDANT", individualDetails: { individualForenames: "Sam", individualSurname: "Green" } }] + } + ] + } + ]) + ); + + expect(result[0].find((f) => f.label === "Hearing type")?.value).toBe("First hearing"); + }); + + it("should include hearing type for application hearings when present", () => { + const result = extractCaseSummary( + makeJson([ + { + hearingType: "Restraining order", + application: [ + { + applicationReference: "APP004", + party: [{ subject: true, organisationDetails: { organisationName: "Applicant Org" } }] + } + ] + } + ]) + ); + + expect(result[0].find((f) => f.label === "Hearing type")?.value).toBe("Restraining order"); + }); +}); diff --git a/libs/list-types/magistrates-standard-list/src/email-summary/summary-builder.ts b/libs/list-types/magistrates-standard-list/src/email-summary/summary-builder.ts new file mode 100644 index 000000000..f8ddd8da4 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/email-summary/summary-builder.ts @@ -0,0 +1,95 @@ +import { type CaseSummary, formatCaseSummaryForEmail } from "@hmcts/list-types-common"; +import type { MagistratesStandardList } from "../models/types.js"; + +export { formatCaseSummaryForEmail }; + +function extractPartyName(party: { + partyRole?: string; + subject?: boolean; + individualDetails?: { individualForenames?: string; individualMiddleName?: string; individualSurname?: string }; + organisationDetails?: { organisationName: string }; +}): string { + if (party.individualDetails) { + const { individualForenames, individualMiddleName, individualSurname } = party.individualDetails; + const forenames = [individualForenames, individualMiddleName].filter(Boolean).join(" "); + const parts = [individualSurname, forenames].filter(Boolean); + return parts.join(", "); + } + if (party.organisationDetails) { + return party.organisationDetails.organisationName; + } + return ""; +} + +function extractOffenceTitles(party: { offence?: { offenceTitle?: string }[] }): string { + const titles = (party.offence ?? []).map((o) => o.offenceTitle).filter(Boolean) as string[]; + return titles.join(", "); +} + +export function extractCaseSummary(jsonData: MagistratesStandardList): CaseSummary[] { + const summaries: CaseSummary[] = []; + + for (const courtList of jsonData.courtLists) { + for (const courtRoom of courtList.courtHouse.courtRoom) { + for (const session of courtRoom.session) { + for (const sitting of session.sittings) { + for (const hearing of sitting.hearing) { + for (const caseItem of hearing.case ?? []) { + const defendant = caseItem.party?.find((p) => p.partyRole === "DEFENDANT"); + const prosecutor = caseItem.party?.find((p) => p.partyRole === "PROSECUTING_AUTHORITY"); + const fields: CaseSummary = []; + + if (defendant) { + const name = extractPartyName(defendant); + if (name) fields.push({ label: "Name", value: name }); + } + + if (prosecutor) { + const authority = extractPartyName(prosecutor); + if (authority) fields.push({ label: "Prosecuting authority", value: authority }); + } + + fields.push({ label: "Reference", value: caseItem.caseUrn }); + + if (hearing.hearingType) { + fields.push({ label: "Hearing type", value: hearing.hearingType }); + } + + if (defendant) { + const offences = extractOffenceTitles(defendant); + if (offences) fields.push({ label: "Offence", value: offences }); + } + + summaries.push(fields); + } + + for (const application of hearing.application ?? []) { + const subject = application.party?.find((p) => p.subject === true); + const fields: CaseSummary = []; + + if (subject) { + const name = extractPartyName(subject); + if (name) fields.push({ label: "Name", value: name }); + } + + fields.push({ label: "Reference", value: application.applicationReference }); + + if (hearing.hearingType) { + fields.push({ label: "Hearing type", value: hearing.hearingType }); + } + + if (subject) { + const offences = extractOffenceTitles(subject); + if (offences) fields.push({ label: "Offence", value: offences }); + } + + summaries.push(fields); + } + } + } + } + } + } + + return summaries; +} diff --git a/libs/list-types/magistrates-standard-list/src/index.ts b/libs/list-types/magistrates-standard-list/src/index.ts new file mode 100644 index 000000000..2e83edf66 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/index.ts @@ -0,0 +1,7 @@ +export * from "./email-summary/summary-builder.js"; +export { cy as magistratesStandardListCy } from "./locales/cy.js"; +export { en as magistratesStandardListEn } from "./locales/en.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export * from "./rendering/renderer.js"; +export { validateMagistratesStandardList } from "./validation/json-validator.js"; diff --git a/libs/list-types/magistrates-standard-list/src/locales/cy.ts b/libs/list-types/magistrates-standard-list/src/locales/cy.ts new file mode 100644 index 000000000..9b626c31e --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/locales/cy.ts @@ -0,0 +1,52 @@ +export const cy = { + title: "Rhestr Safonol y Llys Ynadon", + pageTitle: "Rhestr Safonol y Llys Ynadon ar gyfer", + factLinkText: "Dod o hyd i fanylion cyswllt a gwybodaeth arall am lysoedd a thribiwnlysoedd", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "yng Nghymru a Lloegr, a rhai tribiwnlysoedd sydd heb eu datganoli yn yr Alban.", + listDate: "Rhestr ar gyfer", + lastUpdated: "Diweddarwyd diwethaf:", + publishedAt: "am", + restrictionInformationHeading: "Cyfyngiadau ar gyhoeddi neu ysgrifennu am yr achosion hyn.", + restrictionInformationP1: + "Rhaid i chi wirio a oes unrhyw gyfyngiadau riportio yn berthnasol cyn cyhoeddi manylion am unrhyw un o'r achosion a restrir yma, naill ai'n ysgrifenedig, mewn darllediad neu ar y rhyngrwyd, gan gynnwys y cyfryngau cymdeithasol.", + restrictionInformationBoldText: + "Byddwch yn euog o ddirmyg llys os byddwch yn cyhoeddi unrhyw wybodaeth sydd wedi'i diogelu gan gyfyngiad riportio. Gallwch gael dirwy, eich dedfrydu i garchar, neu'r ddau.", + restrictionInformationP2: "Bydd cyfyngiadau penodol a orchmynnir gan y llys yn cael eu crybwyll ar yr achosion a restrir yma.", + restrictionInformationP3: + "Fodd bynnag, nid yw'r cyfyngiadau bob amser yn cael eu rhestru. Mae rhai yn berthnasol yn awtomatig. Er enghraifft, anhysbysrwydd a roddir i ddioddefwyr rhai troseddau rhywiol.", + restrictionInformationP4: "I ganfod pa gyfyngiadau riportio sy'n berthnasol ar achos penodol, cysylltwch â'r:", + restrictionBulletPoint1: "llys yn uniongyrchol", + restrictionBulletPoint2: "Gwasanaeth Llysoedd a Thribiwnlysoedd EM ar 0330 808 4407", + linkToTop: "Yn ôl i'r brig", + name: "Enw'r: ", + sittingAt: "Yn eistedd yn ", + reference: "Cyfeirnod: ", + applicationType: "Math o Gais: ", + dobAndAge: "Dyddiad Geni ac Oedran: ", + age: "Oedran:", + asn: "ASN (Rhif Gwŷs Arestio): ", + pncId: "PNC ID: ", + address: "Cyfeiriad: ", + hearingType: "Math o Wrandawiad: ", + prosecutingAuthority: "Enw'r Awdurdod Erlyn: ", + panel: "Panel: ", + attendanceMethod: "Dull Presenoldeb: ", + reportingRestrictions: "Cyfyngiadau Riportio: ", + applicationParticulars: "Manylion y Cais: ", + plea: "Ple", + dateOfPlea: "Dyddiad Pledio", + convictedOn: "Cafwyd yn euog ar", + adjournedFrom: "Wedi'i ohirio o", + adjournedText: "Ar gyfer y treial", + legislation: "Deddfwriaeth", + maxPenalty: "Cosb Uchaf", + lja: "LJA", + dataSource: "Ffynhonnell Data", + searchCases: "Chwilio achosion", + noHearings: "Dim gwrandawiadau heddiw", + errorTitle: "Nid yw'r cyhoeddiad ar gael", + errorMessage: "Ni ellir gweld y cyhoeddiad hwn ar hyn o bryd. Gwiriwch eto yn nes ymlaen. Os bydd y broblem yn parhau, cysylltwch â'r llys yn uniongyrchol.", + error403Title: "Mynediad wedi'i wrthod", + error403Message: "Nid oes gennych ganiatâd i weld y cyhoeddiad hwn." +}; diff --git a/libs/list-types/magistrates-standard-list/src/locales/en.ts b/libs/list-types/magistrates-standard-list/src/locales/en.ts new file mode 100644 index 000000000..7445a6e7b --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/locales/en.ts @@ -0,0 +1,53 @@ +export const en = { + title: "Magistrates Standard List", + pageTitle: "Magistrates Standard List for", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + listDate: "List for", + lastUpdated: "Last updated:", + publishedAt: "at", + restrictionInformationHeading: "Restrictions on publishing or writing about these cases", + restrictionInformationP1: + "You must check if any reporting restrictions apply before publishing details on any of the cases listed here either in writing, in a broadcast or by internet, including social media.", + restrictionInformationBoldText: + "You'll be in contempt of court if you publish any information which is protected by a reporting restriction. You could get a fine, prison sentence or both.", + restrictionInformationP2: "Specific restrictions ordered by the court will be mentioned on the cases listed here.", + restrictionInformationP3: + "However, restrictions are not always listed. Some apply automatically. For example, anonymity given to the victims of certain sexual offences.", + restrictionInformationP4: "To find out which reporting restrictions apply on a specific case, contact:", + restrictionBulletPoint1: "the court directly", + restrictionBulletPoint2: "HM Courts and Tribunals Service on 0330 808 4407", + linkToTop: "Back to top", + name: "Name: ", + sittingAt: "Sitting at ", + reference: "Reference: ", + applicationType: "Application Type: ", + dobAndAge: "DOB and Age: ", + age: "Age:", + asn: "ASN: ", + pncId: "PNC ID: ", + address: "Address: ", + hearingType: "Hearing Type: ", + prosecutingAuthority: "Prosecuting Authority Name: ", + panel: "Panel: ", + attendanceMethod: "Attendance Method: ", + reportingRestrictions: "Reporting Restrictions: ", + applicationParticulars: "Application Particulars: ", + plea: "Plea", + dateOfPlea: "Date of Plea", + convictedOn: "Convicted on", + adjournedFrom: "Adjourned from", + adjournedText: "For the trial", + legislation: "Legislation", + maxPenalty: "Max Penalty", + lja: "LJA", + dataSource: "Data Source", + searchCases: "Search cases", + noHearings: "No hearings today", + errorTitle: "Publication not available", + errorMessage: + "This publication cannot be viewed at the moment. Please check again later. If the problem persists, contact the court directly for assistance.", + error403Title: "Access Denied", + error403Message: "You do not have permission to view this publication." +}; diff --git a/libs/list-types/magistrates-standard-list/src/models/types.ts b/libs/list-types/magistrates-standard-list/src/models/types.ts new file mode 100644 index 000000000..babc07d58 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/models/types.ts @@ -0,0 +1,168 @@ +export interface MagistratesStandardList { + document: { + publicationDate: string; + }; + venue: { + venueAddress?: Address; + }; + courtLists: CourtList[]; +} + +export interface CourtList { + courtHouse: { + courtHouseName?: string; + lja?: string; + courtRoom: CourtRoom[]; + }; +} + +export interface CourtRoom { + courtRoomName: string; + session: Session[]; +} + +export interface Session { + judiciary?: Judiciary[]; + sittings: Sitting[]; +} + +export interface Judiciary { + johKnownAs?: string; + isPresiding?: boolean; +} + +export interface Sitting { + sittingStart: string; + hearing: Hearing[]; +} + +export interface Hearing { + hearingType?: string; + panel?: string; + channel?: string[]; + case?: Case[]; + application?: Application[]; +} + +export interface Case { + caseUrn: string; + reportingRestriction?: boolean; + reportingRestrictionDetails?: string[]; + caseSequenceIndicator?: string; + party?: Party[]; +} + +export interface Application { + applicationReference: string; + applicationType?: string; + applicationParticulars?: string; + reportingRestriction?: boolean; + reportingRestrictionDetails?: string[]; + party?: Party[]; +} + +export interface Party { + partyRole?: string; + subject?: boolean; + individualDetails?: IndividualDetails; + organisationDetails?: OrganisationDetails; + offence?: Offence[]; +} + +export interface IndividualDetails { + individualForenames?: string; + individualMiddleName?: string; + individualSurname?: string; + dateOfBirth?: string; + age?: number; + address?: Address; + inCustody?: boolean; + gender?: string; + asn?: string; + pncId?: string; +} + +export interface OrganisationDetails { + organisationName: string; + organisationAddress?: Address; +} + +export interface Offence { + offenceCode?: string; + offenceTitle?: string; + offenceWording?: string; + offenceMaxPen?: string; + reportingRestriction?: boolean; + reportingRestrictionDetails?: string[]; + convictionDate?: string; + adjournedDate?: string; + plea?: string; + pleaDate?: string; + offenceLegislation?: string; +} + +export interface Address { + line?: string[]; + town?: string; + county?: string; + postCode?: string; +} + +export interface RenderedMagistratesStandardListHeader { + locationName: string; + contentDate: string; + publishedDate: string; + publishedTime: string; + venueAddress: string[]; +} + +export interface RenderedCourtRoom { + courtHouseName: string; + courtRoomName: string; + lja: string; + sittings: RenderedSitting[]; +} + +export interface RenderedSitting { + sittingHeading: string; + hearings: RenderedHearing[]; +} + +export interface RenderedHearing { + partyInfo: RenderedPartyInfo; + sittingStartTime: string; + prosecutingAuthority: string; + attendanceMethod: string; + reference: string; + applicationType: string; + caseSequenceIndicator: string; + hearingType: string; + panel: string; + applicationParticulars: string; + reportingRestriction: boolean; + reportingRestrictionDetails: string; + offences: RenderedOffence[]; +} + +export interface RenderedPartyInfo { + name: string; + dob: string; + age: string; + address: string; + asn: string; + pncId: string; +} + +export interface RenderedOffence { + offenceCode: string; + offenceTitle: string; + offenceWording: string; + plea: string; + pleaDate: string; + convictionDate: string; + adjournedDate: string; + offenceLegislation: string; + offenceMaxPenalty: string; + reportingRestriction: boolean; + reportingRestrictionDetails: string; +} diff --git a/libs/list-types/magistrates-standard-list/src/pdf/pdf-generator.test.ts b/libs/list-types/magistrates-standard-list/src/pdf/pdf-generator.test.ts new file mode 100644 index 000000000..d52e93548 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/pdf/pdf-generator.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockSavePdfToStorage, mockCreatePdfErrorResult, mockConfigureNunjucks, mockLoadTranslations } = vi.hoisted(() => ({ + mockSavePdfToStorage: vi.fn(), + mockCreatePdfErrorResult: vi.fn(), + mockConfigureNunjucks: vi.fn(), + mockLoadTranslations: vi.fn() +})); + +vi.mock("@hmcts/list-types-common", () => ({ + savePdfToStorage: mockSavePdfToStorage, + createPdfErrorResult: mockCreatePdfErrorResult, + configureNunjucks: mockConfigureNunjucks, + loadTranslations: mockLoadTranslations, + PDF_BASE_STYLES: "/* base styles */" +})); + +vi.mock("@hmcts/pdf-generation", () => ({ + generatePdfFromHtml: vi.fn() +})); + +vi.mock("../rendering/renderer.js", () => ({ + renderMagistratesStandardListData: vi.fn() +})); + +vi.mock("@hmcts/publication", () => ({ + PROVENANCE_LABELS: { + MANUAL_UPLOAD: "Manual Upload", + SNL: "SNL" + } +})); + +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { renderMagistratesStandardListData } from "../rendering/renderer.js"; +import { generateMagistratesStandardListPdf } from "./pdf-generator.js"; + +const mockRenderedData = { + header: { + locationName: "Manchester Magistrates Court", + contentDate: "13 January 2025", + publishedDate: "13 January 2025", + publishedTime: "9:30am", + venueAddress: ["THE LAW COURTS", "CROWN SQUARE", "Manchester", "M3 3FL"] + }, + listData: [] +}; + +const mockJsonData = { + document: { + publicationDate: "2025-01-13T09:30:00.000Z" + }, + venue: { + venueAddress: { + line: ["THE LAW COURTS", "CROWN SQUARE"], + town: "Manchester", + postCode: "M3 3FL" + } + }, + courtLists: [] +}; + +const baseOptions = { + artefactId: "test-artefact-id", + jsonData: mockJsonData, + locale: "en", + locationId: "123", + provenance: "MANUAL_UPLOAD", + contentDate: new Date("2025-01-13") +}; + +describe("generateMagistratesStandardListPdf", () => { + const mockNunjucksEnv = { + render: vi.fn().mockReturnValue("PDF HTML") + }; + + beforeEach(() => { + vi.clearAllMocks(); + (renderMagistratesStandardListData as ReturnType).mockResolvedValue(mockRenderedData); + mockLoadTranslations.mockResolvedValue({ title: "Magistrates Standard List" }); + mockConfigureNunjucks.mockReturnValue(mockNunjucksEnv); + (generatePdfFromHtml as ReturnType).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("pdf"), + sizeBytes: 3 + }); + mockSavePdfToStorage.mockResolvedValue({ success: true }); + }); + + it("should generate and save a PDF successfully", async () => { + const result = await generateMagistratesStandardListPdf(baseOptions); + + expect(result.success).toBe(true); + expect(renderMagistratesStandardListData).toHaveBeenCalledWith(mockJsonData, { + locale: "en", + locationId: "123", + contentDate: baseOptions.contentDate + }); + expect(mockSavePdfToStorage).toHaveBeenCalledWith("test-artefact-id", expect.any(Buffer), 3); + }); + + it("should pass the MANUAL_UPLOAD provenance label to the template", async () => { + await generateMagistratesStandardListPdf(baseOptions); + + expect(mockNunjucksEnv.render).toHaveBeenCalledWith("pdf-template.njk", expect.objectContaining({ dataSource: "Manual Upload" })); + }); + + it("should use empty string for unknown provenance", async () => { + await generateMagistratesStandardListPdf({ ...baseOptions, provenance: "UNKNOWN" }); + + expect(mockNunjucksEnv.render).toHaveBeenCalledWith("pdf-template.njk", expect.objectContaining({ dataSource: "UNKNOWN" })); + }); + + it("should use empty string when provenance is undefined", async () => { + await generateMagistratesStandardListPdf({ ...baseOptions, provenance: undefined }); + + expect(mockNunjucksEnv.render).toHaveBeenCalledWith("pdf-template.njk", expect.objectContaining({ dataSource: "" })); + }); + + it("should return an error result when PDF generation fails", async () => { + (generatePdfFromHtml as ReturnType).mockResolvedValue({ + success: false, + error: "Chromium crashed" + }); + mockCreatePdfErrorResult.mockReturnValue({ success: false, error: "Chromium crashed" }); + + const result = await generateMagistratesStandardListPdf(baseOptions); + + expect(result.success).toBe(false); + }); + + it("should call createPdfErrorResult when an exception is thrown", async () => { + (renderMagistratesStandardListData as ReturnType).mockRejectedValue(new Error("Render failed")); + mockCreatePdfErrorResult.mockReturnValue({ success: false, error: "Render failed" }); + + const result = await generateMagistratesStandardListPdf(baseOptions); + + expect(mockCreatePdfErrorResult).toHaveBeenCalled(); + expect(result.success).toBe(false); + }); + + it("should load Welsh translations for cy locale", async () => { + await generateMagistratesStandardListPdf({ ...baseOptions, locale: "cy" }); + + expect(mockLoadTranslations).toHaveBeenCalledWith("cy", expect.any(Function), expect.any(Function)); + }); +}); diff --git a/libs/list-types/magistrates-standard-list/src/pdf/pdf-generator.ts b/libs/list-types/magistrates-standard-list/src/pdf/pdf-generator.ts new file mode 100644 index 000000000..7bc0da462 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/pdf/pdf-generator.ts @@ -0,0 +1,59 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + type BasePdfGenerationOptions, + configureNunjucks, + createPdfErrorResult, + loadTranslations, + PDF_BASE_STYLES, + type PdfGenerationResult, + savePdfToStorage +} from "@hmcts/list-types-common"; +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { PROVENANCE_LABELS } from "@hmcts/publication"; +import type { MagistratesStandardList } from "../models/types.js"; +import { renderMagistratesStandardListData } from "../rendering/renderer.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface PdfGenerationOptions extends BasePdfGenerationOptions { + contentDate: Date; +} + +export async function generateMagistratesStandardListPdf(options: PdfGenerationOptions): Promise { + try { + const renderedData = await renderMagistratesStandardListData(options.jsonData, { + locale: options.locale, + locationId: options.locationId, + contentDate: options.contentDate + }); + + const translations = await loadTranslations( + options.locale, + () => import("../locales/en.js"), + () => import("../locales/cy.js") + ); + + const provenanceLabel = options.provenance ? PROVENANCE_LABELS[options.provenance as keyof typeof PROVENANCE_LABELS] || options.provenance : ""; + + const env = configureNunjucks(__dirname); + const html = env.render("pdf-template.njk", { + header: renderedData.header, + listData: renderedData.listData, + dataSource: provenanceLabel, + t: translations, + pdfStyles: PDF_BASE_STYLES + }); + + const pdfResult = await generatePdfFromHtml(html); + + if (!pdfResult.success || !pdfResult.pdfBuffer) { + return { success: false, error: pdfResult.error || "PDF generation failed" }; + } + + return await savePdfToStorage(options.artefactId, pdfResult.pdfBuffer, pdfResult.sizeBytes!); + } catch (error) { + return createPdfErrorResult(error); + } +} diff --git a/libs/list-types/magistrates-standard-list/src/pdf/pdf-template.njk b/libs/list-types/magistrates-standard-list/src/pdf/pdf-template.njk new file mode 100644 index 000000000..396f01cc9 --- /dev/null +++ b/libs/list-types/magistrates-standard-list/src/pdf/pdf-template.njk @@ -0,0 +1,173 @@ + + + + + {{ t.title }} + + + + +
+

{{ 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/list-types/send-daily-hearing-list/package.json b/libs/list-types/send-daily-hearing-list/package.json new file mode 100644 index 000000000..ae25417d9 --- /dev/null +++ b/libs/list-types/send-daily-hearing-list/package.json @@ -0,0 +1,42 @@ +{ + "name": "@hmcts/send-daily-hearing-list", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.ts" + } + }, + "scripts": { + "build": "tsc && yarn build:nunjucks && yarn build:schemas", + "build:nunjucks": "mkdir -p dist/pdf && cd src/pdf && find . -name '*.njk' -exec sh -c 'mkdir -p ../../dist/pdf/$(dirname {}) && cp {} ../../dist/pdf/{}' \\;", + "build:schemas": "mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ." + }, + "dependencies": { + "@hmcts/list-types-common": "workspace:*", + "@hmcts/pdf-generation": "workspace:*", + "@hmcts/postgres-prisma": "workspace:*", + "luxon": "3.7.2", + "nunjucks": "3.2.4" + }, + "devDependencies": { + "@types/luxon": "3.7.1", + "@types/node": "24.10.4", + "typescript": "6.0.3", + "vitest": "4.1.9" + }, + "peerDependencies": { + "express": "^5.1.0" + } +} diff --git a/libs/list-types/send-daily-hearing-list/src/config.test.ts b/libs/list-types/send-daily-hearing-list/src/config.test.ts new file mode 100644 index 000000000..73c9e1e59 --- /dev/null +++ b/libs/list-types/send-daily-hearing-list/src/config.test.ts @@ -0,0 +1,26 @@ +import { existsSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { assets, moduleRoot, schemaPath } from "./config.js"; + +describe("send-daily-hearing-list config", () => { + it("should export a valid moduleRoot", () => { + expect(moduleRoot).toBeDefined(); + expect(typeof moduleRoot).toBe("string"); + expect(moduleRoot).toMatch(/[/\\]/); + expect(existsSync(moduleRoot)).toBe(true); + }); + + it("should export a valid assets path", () => { + expect(assets).toBeDefined(); + expect(typeof assets).toBe("string"); + expect(assets).toContain("assets"); + expect(assets.endsWith("/") || assets.endsWith("\\")).toBe(true); + expect(assets.startsWith(moduleRoot)).toBe(true); + }); + + it("should export a valid schemaPath", () => { + expect(schemaPath).toBeDefined(); + expect(schemaPath).toContain("send-daily-hearing-list.json"); + expect(existsSync(schemaPath)).toBe(true); + }); +}); diff --git a/libs/list-types/send-daily-hearing-list/src/config.ts b/libs/list-types/send-daily-hearing-list/src/config.ts new file mode 100644 index 000000000..ee2a6fbc9 --- /dev/null +++ b/libs/list-types/send-daily-hearing-list/src/config.ts @@ -0,0 +1,9 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const moduleRoot = __dirname; +export const assets = path.join(__dirname, "assets/"); +export const schemaPath = path.join(__dirname, "schemas/send-daily-hearing-list.json"); diff --git a/libs/list-types/send-daily-hearing-list/src/conversion/send-config.ts b/libs/list-types/send-daily-hearing-list/src/conversion/send-config.ts new file mode 100644 index 000000000..3ac444b3f --- /dev/null +++ b/libs/list-types/send-daily-hearing-list/src/conversion/send-config.ts @@ -0,0 +1,54 @@ +import { + createConverter, + type ExcelConverterConfig, + registerConverter, + registerConverterByName, + validateNoHtmlTags, + validateTimeFormat +} from "@hmcts/list-types-common"; + +export const SEND_EXCEL_CONFIG: ExcelConverterConfig = { + fields: [ + { + header: "Time", + fieldName: "time", + required: true, + validators: [(value, rowNumber) => validateTimeFormat(value, rowNumber)] + }, + { + header: "Case reference number", + fieldName: "caseReferenceNumber", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case reference number", rowNumber)] + }, + { + header: "Respondent", + fieldName: "respondent", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Respondent", rowNumber)] + }, + { + header: "Hearing type", + fieldName: "hearingType", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Hearing type", rowNumber)] + }, + { + header: "Venue", + fieldName: "venue", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Venue", rowNumber)] + }, + { + header: "Time estimate", + fieldName: "timeEstimate", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Time estimate", rowNumber)] + } + ], + minRows: 1 +}; + +const sendConverter = createConverter(SEND_EXCEL_CONFIG); +registerConverter(28, sendConverter); +registerConverterByName("SEND_DAILY_HEARING_LIST", sendConverter); diff --git a/libs/list-types/send-daily-hearing-list/src/email-summary/summary-builder.test.ts b/libs/list-types/send-daily-hearing-list/src/email-summary/summary-builder.test.ts new file mode 100644 index 000000000..25cf8128d --- /dev/null +++ b/libs/list-types/send-daily-hearing-list/src/email-summary/summary-builder.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import type { SendDailyHearingList } from "../models/types.js"; +import { extractCaseSummary, formatCaseSummaryForEmail } from "./summary-builder.js"; + +describe("extractCaseSummary", () => { + it("should extract time, caseReferenceNumber and venue fields", () => { + const hearingList: SendDailyHearingList = [ + { + time: "10am", + caseReferenceNumber: "SEND/2025/001", + respondent: "Local Authority", + hearingType: "Final", + venue: "Remote", + timeEstimate: "2 hours" + } + ]; + + const result = extractCaseSummary(hearingList); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual([ + { label: "Time", value: "10am" }, + { label: "Case reference number", value: "SEND/2025/001" }, + { label: "Venue", value: "Remote" } + ]); + }); + + it("should handle empty list", () => { + expect(extractCaseSummary([])).toHaveLength(0); + }); + + it("should handle missing values with empty string", () => { + const hearingList: SendDailyHearingList = [{ time: "", caseReferenceNumber: "", respondent: "", hearingType: "", venue: "", timeEstimate: "" }]; + + const result = extractCaseSummary(hearingList); + expect(result[0]).toEqual([ + { label: "Time", value: "" }, + { label: "Case reference number", value: "" }, + { label: "Venue", value: "" } + ]); + }); +}); + +describe("formatCaseSummaryForEmail", () => { + it("should return no cases message for empty list", () => { + expect(formatCaseSummaryForEmail([])).toBe("No cases scheduled."); + }); + + it("should format a single case summary with field labels", () => { + const hearingList: SendDailyHearingList = [ + { time: "10am", caseReferenceNumber: "SEND/2025/001", respondent: "Local Authority", hearingType: "Final", venue: "Remote", timeEstimate: "2 hours" } + ]; + const result = formatCaseSummaryForEmail(extractCaseSummary(hearingList)); + + expect(result).toContain("Time - 10am"); + expect(result).toContain("Case reference number - SEND/2025/001"); + expect(result).toContain("Venue - Remote"); + }); + + it("should separate multiple cases with dividers", () => { + const hearingList: SendDailyHearingList = [ + { time: "10am", caseReferenceNumber: "SEND/2025/001", respondent: "LA One", hearingType: "Final", venue: "Remote", timeEstimate: "1 hour" }, + { time: "2pm", caseReferenceNumber: "SEND/2025/002", respondent: "LA Two", hearingType: "Directions", venue: "In person", timeEstimate: "30 mins" } + ]; + const result = formatCaseSummaryForEmail(extractCaseSummary(hearingList)); + + expect(result).toContain("Case reference number - SEND/2025/001"); + expect(result).toContain("Case reference number - SEND/2025/002"); + expect(result.split("---").length).toBeGreaterThan(2); + }); +}); diff --git a/libs/list-types/send-daily-hearing-list/src/email-summary/summary-builder.ts b/libs/list-types/send-daily-hearing-list/src/email-summary/summary-builder.ts new file mode 100644 index 000000000..f854416c7 --- /dev/null +++ b/libs/list-types/send-daily-hearing-list/src/email-summary/summary-builder.ts @@ -0,0 +1,12 @@ +import { type CaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "@hmcts/list-types-common"; +import type { SendDailyHearingList } from "../models/types.js"; + +export { formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING }; + +export function extractCaseSummary(jsonData: SendDailyHearingList): CaseSummary[] { + return jsonData.map((hearing) => [ + { label: "Time", value: hearing.time || "" }, + { label: "Case reference number", value: hearing.caseReferenceNumber || "" }, + { label: "Venue", value: hearing.venue || "" } + ]); +} diff --git a/libs/list-types/send-daily-hearing-list/src/index.ts b/libs/list-types/send-daily-hearing-list/src/index.ts new file mode 100644 index 000000000..47b4b93e0 --- /dev/null +++ b/libs/list-types/send-daily-hearing-list/src/index.ts @@ -0,0 +1,9 @@ +import "./conversion/send-config.js"; // Register converter on module load + +export type { ValidationResult } from "@hmcts/publication"; +export * from "./email-summary/summary-builder.js"; +export { cy as sendDailyHearingListCy } from "./locales/cy.js"; +export { en as sendDailyHearingListEn } from "./locales/en.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export * from "./rendering/renderer.js"; diff --git a/libs/list-types/send-daily-hearing-list/src/locales/cy.ts b/libs/list-types/send-daily-hearing-list/src/locales/cy.ts new file mode 100644 index 000000000..c29ec6b33 --- /dev/null +++ b/libs/list-types/send-daily-hearing-list/src/locales/cy.ts @@ -0,0 +1,32 @@ +import { provenanceLabelsCy as provenanceLabels } from "@hmcts/list-types-common"; + +export const cy = { + pageTitle: "Rhestr o Wrandawiadau Dyddiol y Tribiwnlys Haen Gyntaf (Anghenion Addysgol Arbennig ac Anabledd)", + listForDate: "Rhestr ar gyfer", + lastUpdated: "Diweddarwyd ddiwethaf", + at: "am", + factLinkText: "Dod o hyd i fanylion cyswllt a gwybodaeth arall am lysoedd a thribiwnlysoedd yng Nghymru a Lloegr", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "a rhai tribiwnlysoedd heb eu datganoli yn yr Alban.", + importantInformationTitle: "Gwybodaeth bwysig", + importantInformationParagraphs: [ + "Cynhelir gwrandawiadau Tribiwnlys Anghenion Addysgol Arbennig ac Anabledd (SEND) yn breifat ac oni bai bod cais gan y partïon i wrandawiad gael ei wrando yn gyhoeddus wedi'i gymeradwyo, ni fyddwch yn gallu arsylwi.", + "Nid yw gwrandawiadau preifat yn caniatáu i unrhyw un arsylwi o bell neu wyneb yn wyneb. Mae hyn yn cynnwys aelodau o'r wasg.", + "Mae cyfiawnder agored yn un o egwyddorion sylfaenol ein system gyfiawnder. Ar gyfer mynychu gwrandawiad cyhoeddus gan ddefnyddio cyswllt o bell rhaid i chi wneud cais am ganiatâd i arsylwi.", + "Dylid gwneud ceisiadau i arsylwi gwrandawiad cyhoeddus sy'n cael ei gynnal mewn pryd yn uniongyrchol at: send@justice.gov.uk. Efallai y gofynnir i chi ddarparu rhagor o fanylion.", + "Bydd y barnwr sy'n gwrando'r achos yn penderfynu a yw'n briodol i chi arsylwi o bell. Byddant yn ystyried buddiannau cyfiawnder, y gallu technegol i arsylwi o bell a'r hyn sy'n angenrheidiol i sicrhau gweinyddiaeth briodol cyfiawnder." + ], + searchCasesTitle: "Chwilio achosion", + searchCasesLabel: "Chwilio yn ôl cyfeirnod achos, atebydd, lleoliad, neu fanylion eraill", + tableHeaders: { + time: "Amser", + caseReferenceNumber: "Cyfeirnod yr achos", + respondent: "Atebydd", + hearingType: "Math o wrandawiad", + venue: "Lleoliad", + timeEstimate: "Amcangyfrif o'r amser" + }, + dataSource: "Ffynhonnell Data", + backToTop: "Yn ôl i frig y dudalen", + provenanceLabels +}; diff --git a/libs/list-types/send-daily-hearing-list/src/locales/en.ts b/libs/list-types/send-daily-hearing-list/src/locales/en.ts new file mode 100644 index 000000000..d2e025f3c --- /dev/null +++ b/libs/list-types/send-daily-hearing-list/src/locales/en.ts @@ -0,0 +1,32 @@ +import { provenanceLabelsEn as provenanceLabels } from "@hmcts/list-types-common"; + +export const en = { + pageTitle: "First-tier Tribunal (Special Educational Needs and Disability) Daily Hearing List", + listForDate: "List for", + lastUpdated: "Last updated", + at: "at", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + importantInformationTitle: "Important information", + importantInformationParagraphs: [ + "Special Educational Needs and Disability (SEND) Tribunal hearings are held in private and unless a request from the parties for the hearing to be heard in public has been approved, you will not be able to observe.", + "Private hearings do not allow anyone to observe remotely or in person. This includes members of the press.", + "Open justice is a fundamental principle of our justice system. To attend a public hearing using a remote link you must apply for permission to observe.", + "Requests to observe a public hearing that is taking place should be made in good time direct to: send@justice.gov.uk. You may be asked to provide further details.", + "The judge hearing the case will decide if it is appropriate for you to observe remotely. They will have regard to the interests of justice, the technical capacity for remote observation and what is necessary to secure the proper administration of justice." + ], + searchCasesTitle: "Search Cases", + searchCasesLabel: "Search by case reference, respondent, venue, or other details", + tableHeaders: { + time: "Time", + caseReferenceNumber: "Case reference number", + respondent: "Respondent", + hearingType: "Hearing type", + venue: "Venue", + timeEstimate: "Time estimate" + }, + dataSource: "Data source", + backToTop: "Back to top", + provenanceLabels +}; diff --git a/libs/list-types/send-daily-hearing-list/src/models/types.ts b/libs/list-types/send-daily-hearing-list/src/models/types.ts new file mode 100644 index 000000000..910170b6f --- /dev/null +++ b/libs/list-types/send-daily-hearing-list/src/models/types.ts @@ -0,0 +1,10 @@ +export interface SendDailyHearing { + time: string; + caseReferenceNumber: string; + respondent: string; + hearingType: string; + venue: string; + timeEstimate: string; +} + +export type SendDailyHearingList = SendDailyHearing[]; diff --git a/libs/list-types/send-daily-hearing-list/src/pdf/pdf-generator.test.ts b/libs/list-types/send-daily-hearing-list/src/pdf/pdf-generator.test.ts new file mode 100644 index 000000000..a2d25febd --- /dev/null +++ b/libs/list-types/send-daily-hearing-list/src/pdf/pdf-generator.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SendDailyHearingList } from "../models/types.js"; +import { generateSendDailyHearingListPdf } from "./pdf-generator.js"; + +vi.mock("@hmcts/list-types-common", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + generateListPdf: vi.fn() + }; +}); + +vi.mock("@hmcts/publication", () => ({ + PROVENANCE_LABELS: { + MANUAL_UPLOAD: "Manual Upload", + SNL: "ListAssist" + } +})); + +import { generateListPdf } from "@hmcts/list-types-common"; + +const mockHearingList: SendDailyHearingList = [ + { + time: "10am", + caseReferenceNumber: "SEND/2025/001", + respondent: "Local Authority", + hearingType: "Final", + venue: "Remote", + timeEstimate: "2 hours" + } +]; + +const baseOptions = { + artefactId: "test-artefact-id", + locale: "en", + locationId: "13", + contentDate: new Date("2025-06-20"), + jsonData: mockHearingList +}; + +describe("generateSendDailyHearingListPdf", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(generateListPdf).mockResolvedValue({ success: true, pdfPath: "/tmp/test.pdf", sizeBytes: 1024 }); + }); + + it("should generate PDF successfully", async () => { + const result = await generateSendDailyHearingListPdf(baseOptions); + + expect(result.success).toBe(true); + expect(generateListPdf).toHaveBeenCalledWith(expect.objectContaining({ artefactId: "test-artefact-id", provenanceLabel: "" })); + }); + + it("should resolve known provenance to label", async () => { + await generateSendDailyHearingListPdf({ ...baseOptions, provenance: "MANUAL_UPLOAD" }); + + expect(generateListPdf).toHaveBeenCalledWith(expect.objectContaining({ provenanceLabel: "Manual Upload" })); + }); + + it("should fall back to raw provenance string for unknown provenance", async () => { + await generateSendDailyHearingListPdf({ ...baseOptions, provenance: "UNKNOWN_SOURCE" }); + + expect(generateListPdf).toHaveBeenCalledWith(expect.objectContaining({ provenanceLabel: "UNKNOWN_SOURCE" })); + }); + + it("should pass Welsh locale to generateListPdf", async () => { + await generateSendDailyHearingListPdf({ ...baseOptions, locale: "cy" }); + + expect(generateListPdf).toHaveBeenCalledWith(expect.objectContaining({ locale: "cy" })); + }); + + it("should return failure when generateListPdf returns failure", async () => { + vi.mocked(generateListPdf).mockResolvedValue({ success: false, error: "PDF generation failed" }); + + const result = await generateSendDailyHearingListPdf(baseOptions); + + expect(result.success).toBe(false); + expect(result.error).toBe("PDF generation failed"); + }); + + it("should use the correct list title", async () => { + await generateSendDailyHearingListPdf(baseOptions); + + expect(generateListPdf).toHaveBeenCalledWith( + expect.objectContaining({ + listTitle: "First-tier Tribunal (Special Educational Needs and Disability) Daily Hearing List" + }) + ); + }); + + it("should provide working importEn and importCy callbacks", async () => { + await generateSendDailyHearingListPdf(baseOptions); + + const callArgs = vi.mocked(generateListPdf).mock.calls[0][0]; + const enModule = await callArgs.importEn(); + const cyModule = await callArgs.importCy(); + + expect(enModule.en).toBeDefined(); + expect(cyModule.cy).toBeDefined(); + }); +}); diff --git a/libs/list-types/send-daily-hearing-list/src/pdf/pdf-generator.ts b/libs/list-types/send-daily-hearing-list/src/pdf/pdf-generator.ts new file mode 100644 index 000000000..8bcf8322a --- /dev/null +++ b/libs/list-types/send-daily-hearing-list/src/pdf/pdf-generator.ts @@ -0,0 +1,21 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { type BasePdfGenerationOptions, generateListPdf, type PdfGenerationResult } from "@hmcts/list-types-common"; +import { PROVENANCE_LABELS } from "@hmcts/publication"; +import type { SendDailyHearingList } from "../models/types.js"; +import { renderSendDailyHearingListData } from "../rendering/renderer.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export async function generateSendDailyHearingListPdf(options: BasePdfGenerationOptions): Promise { + return generateListPdf({ + ...options, + listTitle: "First-tier Tribunal (Special Educational Needs and Disability) Daily Hearing List", + provenanceLabel: options.provenance ? PROVENANCE_LABELS[options.provenance as keyof typeof PROVENANCE_LABELS] || options.provenance : "", + templateDir: __dirname, + renderData: renderSendDailyHearingListData, + importEn: () => import("../locales/en.js"), + importCy: () => import("../locales/cy.js") + }); +} diff --git a/libs/list-types/send-daily-hearing-list/src/pdf/pdf-template.njk b/libs/list-types/send-daily-hearing-list/src/pdf/pdf-template.njk new file mode 100644 index 000000000..c6d9f104f --- /dev/null +++ b/libs/list-types/send-daily-hearing-list/src/pdf/pdf-template.njk @@ -0,0 +1,61 @@ + + + + + + {{ header.listTitle }} + + + +
+

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForDate }} {{ header.listForDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+
+ +
+

{{ t.importantInformationTitle }}

+ {% for paragraph in t.importantInformationParagraphs %} +

{{ paragraph }}

+ {% endfor %} +
+ + {% if hearings.length > 0 %} + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.time }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.respondent }}{{ t.tableHeaders.hearingType }}{{ t.tableHeaders.venue }}{{ t.tableHeaders.timeEstimate }}
{{ hearing.time }}{{ hearing.caseReferenceNumber }}{{ hearing.respondent }}{{ hearing.hearingType }}{{ hearing.venue }}{{ hearing.timeEstimate }}
+ {% else %} +

No hearings scheduled.

+ {% endif %} + + + + diff --git a/libs/list-types/send-daily-hearing-list/src/rendering/renderer.test.ts b/libs/list-types/send-daily-hearing-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..bdb31331f --- /dev/null +++ b/libs/list-types/send-daily-hearing-list/src/rendering/renderer.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import type { SendDailyHearingList } from "../models/types.js"; +import { renderSendDailyHearingListData } from "./renderer.js"; + +describe("renderSendDailyHearingListData", () => { + it("should render hearing list with header and hearings", () => { + const hearingList: SendDailyHearingList = [ + { + time: "10am", + caseReferenceNumber: "SEND/2025/001", + respondent: "Local Authority", + hearingType: "Final", + venue: "Remote", + timeEstimate: "2 hours" + } + ]; + + const options = { + locale: "en", + contentDate: new Date("2025-06-20"), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "First-tier Tribunal (Special Educational Needs and Disability) Daily Hearing List" + }; + + const result = renderSendDailyHearingListData(hearingList, options); + + expect(result.header.listTitle).toBe("First-tier Tribunal (Special Educational Needs and Disability) Daily Hearing List"); + expect(result.header.listForDate).toBe("20 June 2025"); + expect(result.header.lastUpdatedDate).toBe("01 January 2025"); + expect(result.header.lastUpdatedTime).toContain("am"); + expect(result.hearings).toHaveLength(1); + expect(result.hearings[0].time).toBe("10am"); + expect(result.hearings[0].caseReferenceNumber).toBe("SEND/2025/001"); + expect(result.hearings[0].respondent).toBe("Local Authority"); + expect(result.hearings[0].hearingType).toBe("Final"); + expect(result.hearings[0].venue).toBe("Remote"); + expect(result.hearings[0].timeEstimate).toBe("2 hours"); + }); + + it("should handle empty hearing list", () => { + const hearingList: SendDailyHearingList = []; + const options = { + locale: "en", + contentDate: new Date("2025-06-20"), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "SEND Daily Hearing List" + }; + + const result = renderSendDailyHearingListData(hearingList, options); + + expect(result.hearings).toHaveLength(0); + expect(result.header.listTitle).toBe("SEND Daily Hearing List"); + }); + + it("should use Welsh locale", () => { + const hearingList: SendDailyHearingList = []; + const options = { + locale: "cy", + contentDate: new Date("2025-06-20"), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "Rhestr Gwrandawiadau Dyddiol SEND" + }; + + const result = renderSendDailyHearingListData(hearingList, options); + + expect(result.header.listTitle).toBe("Rhestr Gwrandawiadau Dyddiol SEND"); + }); + + it("should format PM times correctly", () => { + const hearingList: SendDailyHearingList = []; + const options = { + locale: "en", + contentDate: new Date("2025-06-20"), + lastReceivedDate: "2025-01-01T14:30:00Z", + listTitle: "SEND Daily Hearing List" + }; + + const result = renderSendDailyHearingListData(hearingList, options); + + expect(result.header.lastUpdatedTime).toBe("2:30pm"); + }); +}); diff --git a/libs/list-types/send-daily-hearing-list/src/rendering/renderer.ts b/libs/list-types/send-daily-hearing-list/src/rendering/renderer.ts new file mode 100644 index 000000000..dd7fb02a2 --- /dev/null +++ b/libs/list-types/send-daily-hearing-list/src/rendering/renderer.ts @@ -0,0 +1,34 @@ +import { formatDisplayDate, formatLastUpdatedDateTime } from "@hmcts/list-types-common"; +import type { SendDailyHearing, SendDailyHearingList } from "../models/types.js"; + +export interface RenderOptions { + locale: string; + contentDate: Date; + lastReceivedDate: string; + listTitle: string; +} + +export interface RenderedData { + header: { + listTitle: string; + listForDate: string; + lastUpdatedDate: string; + lastUpdatedTime: string; + }; + hearings: SendDailyHearing[]; +} + +export function renderSendDailyHearingListData(hearingList: SendDailyHearingList, options: RenderOptions): RenderedData { + const { date: lastUpdatedDate, time: lastUpdatedTime } = formatLastUpdatedDateTime(options.lastReceivedDate, options.locale); + const listForDate = formatDisplayDate(options.contentDate, options.locale); + + return { + header: { + listTitle: options.listTitle, + listForDate, + lastUpdatedDate, + lastUpdatedTime + }, + hearings: hearingList.map((hearing) => ({ ...hearing })) + }; +} diff --git a/libs/list-types/send-daily-hearing-list/src/schemas/send-daily-hearing-list.json b/libs/list-types/send-daily-hearing-list/src/schemas/send-daily-hearing-list.json new file mode 100644 index 000000000..adb8f214e --- /dev/null +++ b/libs/list-types/send-daily-hearing-list/src/schemas/send-daily-hearing-list.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SEND Daily Hearing List", + "description": "Schema for First-tier Tribunal (Special Educational Needs and Disability) Daily Hearing List", + "type": "array", + "items": { + "type": "object", + "required": ["time", "caseReferenceNumber", "respondent", "hearingType", "venue", "timeEstimate"], + "properties": { + "time": { + "title": "Time of hearing", + "type": "string", + "pattern": "^\\d{1,2}([:.]\\d{2})?[ap]m\\s*$", + "examples": ["10am", "2:30pm"] + }, + "caseReferenceNumber": { + "title": "Case reference number", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "respondent": { + "title": "Respondent", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "hearingType": { + "title": "Type of hearing", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "venue": { + "title": "Venue", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "timeEstimate": { + "title": "Time estimate", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + } + } +} diff --git a/libs/list-types/send-daily-hearing-list/tsconfig.json b/libs/list-types/send-daily-hearing-list/tsconfig.json new file mode 100644 index 000000000..a029ee937 --- /dev/null +++ b/libs/list-types/send-daily-hearing-list/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules", "src/assets/"] +} diff --git a/libs/list-types/siac-poac-paac-weekly-hearing-list/package.json b/libs/list-types/siac-poac-paac-weekly-hearing-list/package.json new file mode 100644 index 000000000..f068f6aa2 --- /dev/null +++ b/libs/list-types/siac-poac-paac-weekly-hearing-list/package.json @@ -0,0 +1,42 @@ +{ + "name": "@hmcts/siac-poac-paac-weekly-hearing-list", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.ts" + } + }, + "scripts": { + "build": "tsc && yarn build:nunjucks && yarn build:views && yarn build:schemas", + "build:nunjucks": "mkdir -p dist/pdf && cd src/pdf && find . -name '*.njk' -exec sh -c 'mkdir -p ../../dist/pdf/$(dirname {}) && cp {} ../../dist/pdf/{}' \\;", + "build:views": "mkdir -p dist/views && find src/views -name '*.njk' -exec cp {} dist/views/ \\;", + "build:schemas": "mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ." + }, + "dependencies": { + "@hmcts/list-types-common": "workspace:*", + "@hmcts/pdf-generation": "workspace:*", + "luxon": "3.7.2", + "nunjucks": "3.2.4" + }, + "devDependencies": { + "@types/luxon": "3.7.1", + "@types/node": "24.10.4", + "typescript": "6.0.3", + "vitest": "4.1.9" + }, + "peerDependencies": { + "express": "^5.1.0" + } +} diff --git a/libs/list-types/siac-poac-paac-weekly-hearing-list/src/config.test.ts b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/config.test.ts new file mode 100644 index 000000000..10f207cca --- /dev/null +++ b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/config.test.ts @@ -0,0 +1,48 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { assets, moduleRoot } from "./config.js"; + +describe("siac-poac-paac-weekly-hearing-list config", () => { + describe("moduleRoot", () => { + it("should be defined", () => { + expect(moduleRoot).toBeDefined(); + expect(typeof moduleRoot).toBe("string"); + }); + + it("should point to an existing directory", () => { + expect(existsSync(moduleRoot)).toBe(true); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(moduleRoot)).toBe(true); + }); + }); + + describe("assets", () => { + it("should be defined", () => { + expect(assets).toBeDefined(); + expect(typeof assets).toBe("string"); + }); + + it("should point to assets directory", () => { + expect(assets).toContain("assets"); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(assets)).toBe(true); + }); + + it("should have valid path structure", () => { + expect(assets).toBeTruthy(); + }); + + it("should end with trailing slash", () => { + expect(assets).toMatch(/\/$/); + }); + + it("should be subdirectory of moduleRoot", () => { + expect(assets.startsWith(moduleRoot)).toBe(true); + }); + }); +}); diff --git a/libs/list-types/siac-poac-paac-weekly-hearing-list/src/config.ts b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/config.ts new file mode 100644 index 000000000..5a5bd9b11 --- /dev/null +++ b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/config.ts @@ -0,0 +1,9 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const moduleRoot = __dirname; +export const assets = path.join(__dirname, "assets/"); +export const schemaPath = path.join(__dirname, "schemas/siac-poac-paac-weekly-hearing-list.json"); diff --git a/libs/list-types/siac-poac-paac-weekly-hearing-list/src/conversion/siac-poac-paac-config.ts b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/conversion/siac-poac-paac-config.ts new file mode 100644 index 000000000..8a8904a77 --- /dev/null +++ b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/conversion/siac-poac-paac-config.ts @@ -0,0 +1,71 @@ +import { + createConverter, + DD_MM_YYYY_PATTERN, + type ExcelConverterConfig, + registerConverter, + registerConverterByName, + validateDateFormat, + validateNoHtmlTags +} from "@hmcts/list-types-common"; + +// SIAC / POAC / PAAC Weekly Hearing List (listTypeIds: 28, 29, 30) +export const SIAC_POAC_PAAC_EXCEL_CONFIG: ExcelConverterConfig = { + fields: [ + { + header: "Date", + fieldName: "date", + required: true, + validators: [validateDateFormat(DD_MM_YYYY_PATTERN, "dd/MM/yyyy (e.g., 02/01/2025)")] + }, + { + header: "Time", + fieldName: "time", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Time", rowNumber)] + }, + { + header: "Appellant", + fieldName: "appellant", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Appellant", rowNumber)] + }, + { + header: "Case Reference Number", + fieldName: "caseReferenceNumber", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case Reference Number", rowNumber)] + }, + { + header: "Hearing Type", + fieldName: "hearingType", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Hearing Type", rowNumber)] + }, + { + header: "Courtroom", + fieldName: "courtroom", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Courtroom", rowNumber)] + }, + { + header: "Additional information", + fieldName: "additionalInformation", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Additional information", rowNumber)] + } + ], + minRows: 1 +}; + +// Register the converter for all three list type IDs and names +// Name-based registration handles environments where the DB ID differs from the canonical seeded ID +const siacPoacPaacConverter = createConverter(SIAC_POAC_PAAC_EXCEL_CONFIG); + +registerConverter(28, siacPoacPaacConverter); +registerConverterByName("SIAC_WEEKLY_HEARING_LIST", siacPoacPaacConverter); + +registerConverter(29, siacPoacPaacConverter); +registerConverterByName("POAC_WEEKLY_HEARING_LIST", siacPoacPaacConverter); + +registerConverter(30, siacPoacPaacConverter); +registerConverterByName("PAAC_WEEKLY_HEARING_LIST", siacPoacPaacConverter); diff --git a/libs/list-types/siac-poac-paac-weekly-hearing-list/src/email-summary/summary-builder.test.ts b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/email-summary/summary-builder.test.ts new file mode 100644 index 000000000..3f7649a96 --- /dev/null +++ b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/email-summary/summary-builder.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import type { SiacPoacPaacHearingList } from "../models/types.js"; +import { extractCaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "./summary-builder.js"; + +describe("SPECIAL_CATEGORY_DATA_WARNING", () => { + it("should contain required warning text", () => { + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Special Category Data"); + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Data Protection Act 2018"); + }); +}); + +describe("extractCaseSummary", () => { + it("should extract case summaries from hearing list", () => { + // Arrange + const hearingList: SiacPoacPaacHearingList = [ + { + date: "01/01/2025", + time: "10:00am", + appellant: "Smith v Secretary of State", + caseReferenceNumber: "SC/00001/2025", + hearingType: "Substantive hearing", + courtroom: "Court 1", + additionalInformation: "" + }, + { + date: "02/01/2025", + time: "2:00pm", + appellant: "Brown v Secretary of State", + caseReferenceNumber: "SC/00002/2025", + hearingType: "Preliminary hearing", + courtroom: "Court 2", + additionalInformation: "" + } + ]; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(2); + expect(result[0]).toEqual([ + { label: "Date", value: "01/01/2025" }, + { label: "Time", value: "10:00am" }, + { label: "Case reference number", value: "SC/00001/2025" } + ]); + expect(result[1]).toEqual([ + { label: "Date", value: "02/01/2025" }, + { label: "Time", value: "2:00pm" }, + { label: "Case reference number", value: "SC/00002/2025" } + ]); + }); + + it("should handle empty hearing list", () => { + // Arrange + const hearingList: SiacPoacPaacHearingList = []; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(0); + }); + + it("should handle missing case details with empty string", () => { + // Arrange + const hearingList: SiacPoacPaacHearingList = [ + { + date: "", + time: "", + appellant: "", + caseReferenceNumber: "", + hearingType: "", + courtroom: "", + additionalInformation: "" + } + ]; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(1); + expect(result[0]).toEqual([ + { label: "Date", value: "" }, + { label: "Time", value: "" }, + { label: "Case reference number", value: "" } + ]); + }); +}); + +describe("formatCaseSummaryForEmail", () => { + it("should format single case summary correctly", () => { + // Arrange + const items = [ + [ + { label: "Date", value: "01/01/2025" }, + { label: "Time", value: "10:00am" }, + { label: "Case reference number", value: "SC/00001/2025" } + ] + ]; + + // Act + const result = formatCaseSummaryForEmail(items); + + // Assert + expect(result).toContain("---"); + expect(result).toContain("Date - 01/01/2025"); + expect(result).toContain("Time - 10:00am"); + expect(result).toContain("Case reference number - SC/00001/2025"); + }); + + it("should handle empty case list", () => { + // Act + const result = formatCaseSummaryForEmail([]); + + // Assert + expect(result).toBe("No cases scheduled."); + }); +}); diff --git a/libs/list-types/siac-poac-paac-weekly-hearing-list/src/email-summary/summary-builder.ts b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/email-summary/summary-builder.ts new file mode 100644 index 000000000..60642ae27 --- /dev/null +++ b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/email-summary/summary-builder.ts @@ -0,0 +1,12 @@ +import { type CaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "@hmcts/list-types-common"; +import type { SiacPoacPaacHearingList } from "../models/types.js"; + +export { formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING }; + +export function extractCaseSummary(jsonData: SiacPoacPaacHearingList): CaseSummary[] { + return jsonData.map((hearing) => [ + { label: "Date", value: hearing.date || "" }, + { label: "Time", value: hearing.time || "" }, + { label: "Case reference number", value: hearing.caseReferenceNumber || "" } + ]); +} diff --git a/libs/list-types/siac-poac-paac-weekly-hearing-list/src/index.ts b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/index.ts new file mode 100644 index 000000000..a99ab6959 --- /dev/null +++ b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/index.ts @@ -0,0 +1,11 @@ +import "./conversion/siac-poac-paac-config.js"; // Register converter on module load + +// Business logic exports +export type { ValidationResult } from "@hmcts/publication"; +export * from "./email-summary/summary-builder.js"; +export { cy as siacPoacPaacWeeklyHearingListCy } from "./locales/cy.js"; +// Locale exports +export { en as siacPoacPaacWeeklyHearingListEn } from "./locales/en.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export * from "./rendering/renderer.js"; diff --git a/libs/list-types/siac-poac-paac-weekly-hearing-list/src/locales/cy.ts b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/locales/cy.ts new file mode 100644 index 000000000..3b25453dd --- /dev/null +++ b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/locales/cy.ts @@ -0,0 +1,36 @@ +import { provenanceLabelsCy as provenanceLabels } from "@hmcts/list-types-common"; + +export const cy = { + listForWeekCommencing: "List for week commencing", + lastUpdated: "Last updated", + at: "at", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + importantInformationTitle: "Important information", + searchCasesTitle: "Search Cases", + searchCasesLabel: "Search by case reference number, date, appellant, or other details", + tableHeaders: { + date: "Date", + time: "Time", + appellant: "Appellant", + caseReferenceNumber: "Case reference number", + hearingType: "Hearing type", + courtroom: "Courtroom", + additionalInformation: "Additional information" + }, + dataSource: "Data source", + backToTop: "Back to top", + cautionNote: + "Note this document contains Special Category Data as defined by Data Protection Act 2018, formally known as Sensitive Personal Data, and should be handled appropriately.", + cautionReporting: + "This document contains information intended to assist the accurate reporting of court proceedings. It is vital you ensure that you safeguard the Special Category Data included and abide by reporting restrictions (for example on victims and children). HMCTS will stop sending the data if there is concern about how it will be used.", + provenanceLabels, + siacPageTitle: "Special Immigration Appeals Commission Weekly Hearing List", + poacPageTitle: "Proscribed Organisations Appeal Commission Weekly Hearing List", + paacPageTitle: "Pathogens Access Appeal Commission Weekly Hearing List", + importantInformationText: "The tribunal sometimes uses reference numbers or initials to protect the anonymity of those involved in the appeal.", + importantInformationVenue: "All hearings take place at Field House, 15-25 Bream's Buildings, London EC4A 1DZ.", + importantInformationLinkText: "Find out what to expect coming to a court or tribunal", + importantInformationLinkUrl: "https://www.gov.uk/guidance/what-to-expect-coming-to-a-court-or-tribunal" +}; diff --git a/libs/list-types/siac-poac-paac-weekly-hearing-list/src/locales/en.ts b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/locales/en.ts new file mode 100644 index 000000000..4e1c82fb7 --- /dev/null +++ b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/locales/en.ts @@ -0,0 +1,36 @@ +import { provenanceLabelsEn as provenanceLabels } from "@hmcts/list-types-common"; + +export const en = { + listForWeekCommencing: "List for week commencing", + lastUpdated: "Last updated", + at: "at", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + importantInformationTitle: "Important information", + searchCasesTitle: "Search Cases", + searchCasesLabel: "Search by case reference number, date, appellant, or other details", + tableHeaders: { + date: "Date", + time: "Time", + appellant: "Appellant", + caseReferenceNumber: "Case reference number", + hearingType: "Hearing type", + courtroom: "Courtroom", + additionalInformation: "Additional information" + }, + dataSource: "Data source", + backToTop: "Back to top", + cautionNote: + "Note this document contains Special Category Data as defined by Data Protection Act 2018, formally known as Sensitive Personal Data, and should be handled appropriately.", + cautionReporting: + "This document contains information intended to assist the accurate reporting of court proceedings. It is vital you ensure that you safeguard the Special Category Data included and abide by reporting restrictions (for example on victims and children). HMCTS will stop sending the data if there is concern about how it will be used.", + provenanceLabels, + siacPageTitle: "Special Immigration Appeals Commission Weekly Hearing List", + poacPageTitle: "Proscribed Organisations Appeal Commission Weekly Hearing List", + paacPageTitle: "Pathogens Access Appeal Commission Weekly Hearing List", + importantInformationText: "The tribunal sometimes uses reference numbers or initials to protect the anonymity of those involved in the appeal.", + importantInformationVenue: "All hearings take place at Field House, 15-25 Bream's Buildings, London EC4A 1DZ.", + importantInformationLinkText: "Find out what to expect coming to a court or tribunal", + importantInformationLinkUrl: "https://www.gov.uk/guidance/what-to-expect-coming-to-a-court-or-tribunal" +}; diff --git a/libs/list-types/siac-poac-paac-weekly-hearing-list/src/locales/locales.test.ts b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/locales/locales.test.ts new file mode 100644 index 000000000..dbd3bda0e --- /dev/null +++ b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/locales/locales.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +describe("siac-poac-paac locales", () => { + describe("en", () => { + it("should have importantInformationText as a separate field without the venue", () => { + expect(en.importantInformationText).toContain("anonymity"); + expect(en.importantInformationText).not.toContain("Field House"); + }); + + it("should have importantInformationVenue as a separate field on a new line", () => { + expect(en.importantInformationVenue).toBe("All hearings take place at Field House, 15-25 Bream's Buildings, London EC4A 1DZ."); + }); + }); + + describe("cy", () => { + it("should have importantInformationText as a separate field without the venue", () => { + expect(cy.importantInformationText).toContain("anonymity"); + expect(cy.importantInformationText).not.toContain("Field House"); + }); + + it("should have importantInformationVenue as a separate field on a new line", () => { + expect(cy.importantInformationVenue).toBe("All hearings take place at Field House, 15-25 Bream's Buildings, London EC4A 1DZ."); + }); + }); +}); diff --git a/libs/list-types/siac-poac-paac-weekly-hearing-list/src/models/types.ts b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/models/types.ts new file mode 100644 index 000000000..bbad0319e --- /dev/null +++ b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/models/types.ts @@ -0,0 +1,11 @@ +export interface SiacPoacPaacHearing { + date: string; + time: string; + appellant: string; + caseReferenceNumber: string; + hearingType: string; + courtroom: string; + additionalInformation: string; +} + +export type SiacPoacPaacHearingList = SiacPoacPaacHearing[]; diff --git a/libs/list-types/siac-poac-paac-weekly-hearing-list/src/pdf/pdf-generator.test.ts b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/pdf/pdf-generator.test.ts new file mode 100644 index 000000000..17547ed81 --- /dev/null +++ b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/pdf/pdf-generator.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockUploadBlob } = vi.hoisted(() => ({ + mockUploadBlob: vi.fn() +})); +vi.mock("@hmcts/azure-blob", () => ({ + uploadBlob: mockUploadBlob, + CONTAINER: { ARTEFACT: "artefact", PUBLICATIONS: "publications" } +})); + +vi.mock("@hmcts/pdf-generation", () => ({ + generatePdfFromHtml: vi.fn() +})); + +vi.mock("../rendering/renderer.js", () => ({ + renderSiacPoacPaacData: vi.fn() +})); + +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { renderSiacPoacPaacData } from "../rendering/renderer.js"; +import { generateSiacPoacPaacWeeklyHearingListPdf } from "./pdf-generator.js"; + +const mockRenderedData = { + header: { + listTitle: "Special Immigration Appeals Commission Weekly Hearing List", + weekCommencingDate: "01 January 2025", + lastUpdatedDate: "12 November 2025", + lastUpdatedTime: "9am" + }, + hearings: [] +}; + +const mockHearingList = [ + { + date: "01/01/2025", + time: "10:00am", + appellant: "Smith v Secretary of State", + caseReferenceNumber: "SC/00001/2025", + hearingType: "Substantive hearing", + courtroom: "Court 1", + additionalInformation: "" + } +]; + +describe("generateSiacPoacPaacWeeklyHearingListPdf", () => { + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(renderSiacPoacPaacData).mockReturnValue(mockRenderedData); + mockUploadBlob.mockResolvedValue(undefined); + }); + + it("should generate PDF successfully", async () => { + // Arrange + const pdfBuffer = Buffer.from("PDF content"); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer, + sizeBytes: 1024 + }); + + // Act + const result = await generateSiacPoacPaacWeeklyHearingListPdf({ + artefactId: "test-artefact-123", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList, + courtName: "Special Immigration Appeals Commission", + listTitle: "Special Immigration Appeals Commission Weekly Hearing List" + }); + + // Assert + expect(result.success).toBe(true); + expect(result.pdfPath).toContain("test-artefact-123.pdf"); + expect(result.sizeBytes).toBe(1024); + expect(result.exceedsMaxSize).toBe(false); + }); + + it("should return exceedsMaxSize true when PDF is over 2MB", async () => { + // Arrange + const largePdfBuffer = Buffer.alloc(3 * 1024 * 1024); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: largePdfBuffer, + sizeBytes: 3 * 1024 * 1024 + }); + + // Act + const result = await generateSiacPoacPaacWeeklyHearingListPdf({ + artefactId: "large-pdf-123", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList, + courtName: "Special Immigration Appeals Commission", + listTitle: "Special Immigration Appeals Commission Weekly Hearing List" + }); + + // Assert + expect(result.success).toBe(true); + expect(result.exceedsMaxSize).toBe(true); + }); + + it("should return error when PDF generation fails", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: false, + error: "Puppeteer crashed" + }); + + // Act + const result = await generateSiacPoacPaacWeeklyHearingListPdf({ + artefactId: "failed-pdf", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList, + courtName: "Special Immigration Appeals Commission", + listTitle: "Special Immigration Appeals Commission Weekly Hearing List" + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("Puppeteer crashed"); + }); + + it("should pass correct render options to renderer", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + const contentDate = new Date("2025-06-15"); + + // Act + await generateSiacPoacPaacWeeklyHearingListPdf({ + artefactId: "test-render-options", + contentDate, + locale: "cy", + locationId: "999", + jsonData: mockHearingList, + courtName: "Proscribed Organisations Appeal Commission", + listTitle: "Proscribed Organisations Appeal Commission Weekly Hearing List" + }); + + // Assert + expect(renderSiacPoacPaacData).toHaveBeenCalledWith(mockHearingList, { + locale: "cy", + courtName: "Proscribed Organisations Appeal Commission", + contentDate, + lastReceivedDate: expect.any(String), + listTitle: "Proscribed Organisations Appeal Commission Weekly Hearing List" + }); + }); +}); diff --git a/libs/list-types/siac-poac-paac-weekly-hearing-list/src/pdf/pdf-generator.ts b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/pdf/pdf-generator.ts new file mode 100644 index 000000000..cd818790e --- /dev/null +++ b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/pdf/pdf-generator.ts @@ -0,0 +1,28 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { type BasePdfGenerationOptions, generateFttSiacWeeklyHearingListPdf, type PdfGenerationResult } from "@hmcts/list-types-common"; +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { PROVENANCE_LABELS } from "@hmcts/publication"; +import type { SiacPoacPaacHearingList } from "../models/types.js"; +import { renderSiacPoacPaacData } from "../rendering/renderer.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface PdfGenerationOptions extends BasePdfGenerationOptions { + contentDate: Date; + courtName: string; + listTitle: string; +} + +export async function generateSiacPoacPaacWeeklyHearingListPdf(options: PdfGenerationOptions): Promise { + return generateFttSiacWeeklyHearingListPdf({ + ...options, + moduleDir: __dirname, + provenanceLabel: options.provenance ? PROVENANCE_LABELS[options.provenance as keyof typeof PROVENANCE_LABELS] || options.provenance : "", + importEn: () => import("../locales/en.js"), + importCy: () => import("../locales/cy.js"), + generatePdf: generatePdfFromHtml, + renderData: renderSiacPoacPaacData + }); +} diff --git a/libs/list-types/siac-poac-paac-weekly-hearing-list/src/pdf/pdf-template.njk b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/pdf/pdf-template.njk new file mode 100644 index 000000000..3bae0d2e3 --- /dev/null +++ b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/pdf/pdf-template.njk @@ -0,0 +1,67 @@ + + + + + + {{ header.listTitle }} + + + +
+

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForWeekCommencing }} {{ header.weekCommencingDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+
+ +
+

{{ t.importantInformationTitle }}

+

{{ t.importantInformationText }}

+

{{ t.importantInformationVenue }}

+

{{ t.importantInformationLinkText }}

+
+ + {% if hearings.length > 0 %} + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.date }}{{ t.tableHeaders.time }}{{ t.tableHeaders.appellant }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.hearingType }}{{ t.tableHeaders.courtroom }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.date }}{{ hearing.time }}{{ hearing.appellant }}{{ hearing.caseReferenceNumber }}{{ hearing.hearingType }}{{ hearing.courtroom }}{{ hearing.additionalInformation }}
+ {% else %} +

No hearings scheduled.

+ {% endif %} + + + + diff --git a/libs/list-types/siac-poac-paac-weekly-hearing-list/src/rendering/renderer.test.ts b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..c6178320f --- /dev/null +++ b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/rendering/renderer.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; +import type { SiacPoacPaacHearingList } from "../models/types.js"; +import { renderSiacPoacPaacData } from "./renderer.js"; + +describe("renderSiacPoacPaacData", () => { + it("should render hearing list with formatted dates", () => { + // Arrange + const hearingList: SiacPoacPaacHearingList = [ + { + date: "02/01/2025", + time: "10:00am", + appellant: "A Vs B", + caseReferenceNumber: "SC/00001/2025", + hearingType: "Substantive hearing", + courtroom: "Court 1", + additionalInformation: "Remote hearing" + } + ]; + + const options = { + locale: "en", + courtName: "Special Immigration Appeals Commission", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "Special Immigration Appeals Commission Weekly Hearing List" + }; + + // Act + const result = renderSiacPoacPaacData(hearingList, options); + + // Assert + expect(result.header.listTitle).toBe("Special Immigration Appeals Commission Weekly Hearing List"); + expect(result.header.weekCommencingDate).toBe("02 January 2025"); + expect(result.header.lastUpdatedDate).toBe("01 January 2025"); + expect(result.header.lastUpdatedTime).toContain("am"); + + expect(result.hearings).toHaveLength(1); + expect(result.hearings[0].date).toBe("02 January 2025"); + expect(result.hearings[0].time).toBe("10:00am"); + expect(result.hearings[0].appellant).toBe("A Vs B"); + expect(result.hearings[0].caseReferenceNumber).toBe("SC/00001/2025"); + expect(result.hearings[0].hearingType).toBe("Substantive hearing"); + expect(result.hearings[0].courtroom).toBe("Court 1"); + expect(result.hearings[0].additionalInformation).toBe("Remote hearing"); + }); + + it("should render multiple hearings", () => { + // Arrange + const hearingList: SiacPoacPaacHearingList = [ + { + date: "02/01/2025", + time: "10:00am", + appellant: "A Vs B", + caseReferenceNumber: "SC/00001/2025", + hearingType: "Substantive hearing", + courtroom: "Court 1", + additionalInformation: "" + }, + { + date: "03/01/2025", + time: "2:00pm", + appellant: "C Vs D", + caseReferenceNumber: "SC/00002/2025", + hearingType: "Preliminary hearing", + courtroom: "Court 2", + additionalInformation: "In person" + } + ]; + + const options = { + locale: "en", + courtName: "Special Immigration Appeals Commission", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "Special Immigration Appeals Commission Weekly Hearing List" + }; + + // Act + const result = renderSiacPoacPaacData(hearingList, options); + + // Assert + expect(result.hearings).toHaveLength(2); + expect(result.hearings[0].date).toBe("02 January 2025"); + expect(result.hearings[1].date).toBe("03 January 2025"); + }); + + it("should format date with zero-padded day correctly", () => { + // Arrange + const hearingList: SiacPoacPaacHearingList = [ + { + date: "01/01/2025", + time: "10:00am", + appellant: "A Vs B", + caseReferenceNumber: "SC/00001/2025", + hearingType: "Substantive hearing", + courtroom: "Court 1", + additionalInformation: "" + } + ]; + + const options = { + locale: "en", + courtName: "Special Immigration Appeals Commission", + contentDate: new Date(2025, 0, 1), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "Special Immigration Appeals Commission Weekly Hearing List" + }; + + // Act + const result = renderSiacPoacPaacData(hearingList, options); + + // Assert + expect(result.hearings[0].date).toBe("01 January 2025"); + }); + + it("should format lastUpdated with time", () => { + // Arrange + const hearingList: SiacPoacPaacHearingList = [ + { + date: "02/01/2025", + time: "10:00am", + appellant: "A Vs B", + caseReferenceNumber: "SC/00001/2025", + hearingType: "Substantive hearing", + courtroom: "Court 1", + additionalInformation: "" + } + ]; + + const options = { + locale: "en", + courtName: "Special Immigration Appeals Commission", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "Special Immigration Appeals Commission Weekly Hearing List" + }; + + // Act + const result = renderSiacPoacPaacData(hearingList, options); + + // Assert + expect(result.header.lastUpdatedTime).toBe("9:55am"); + }); + + it("should handle empty hearing list", () => { + // Arrange + const hearingList: SiacPoacPaacHearingList = []; + + const options = { + locale: "en", + courtName: "Special Immigration Appeals Commission", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "Special Immigration Appeals Commission Weekly Hearing List" + }; + + // Act + const result = renderSiacPoacPaacData(hearingList, options); + + // Assert + expect(result.hearings).toHaveLength(0); + expect(result.header.listTitle).toBe("Special Immigration Appeals Commission Weekly Hearing List"); + }); + + it("should use the provided listTitle from translations", () => { + // Arrange + const hearingList: SiacPoacPaacHearingList = [ + { + date: "02/01/2025", + time: "10:00am", + appellant: "A Vs B", + caseReferenceNumber: "SC/00001/2025", + hearingType: "Substantive hearing", + courtroom: "Court 1", + additionalInformation: "" + } + ]; + + const optionsWelsh = { + locale: "cy", + courtName: "Special Immigration Appeals Commission", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "Special Immigration Appeals Commission Weekly Hearing List" + }; + + // Act + const result = renderSiacPoacPaacData(hearingList, optionsWelsh); + + // Assert + expect(result.header.listTitle).toBe("Special Immigration Appeals Commission Weekly Hearing List"); + }); +}); diff --git a/libs/list-types/siac-poac-paac-weekly-hearing-list/src/rendering/renderer.ts b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/rendering/renderer.ts new file mode 100644 index 000000000..c55475ef7 --- /dev/null +++ b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/rendering/renderer.ts @@ -0,0 +1,45 @@ +import { formatDdMmYyyyDate, formatDisplayDate, formatLastUpdatedDateTime } from "@hmcts/list-types-common"; +import type { SiacPoacPaacHearing, SiacPoacPaacHearingList } from "../models/types.js"; + +export interface RenderOptions { + locale: string; + courtName: string; + contentDate: Date; + lastReceivedDate: string; + listTitle: string; +} + +export interface RenderedData { + header: { + listTitle: string; + weekCommencingDate: string; + lastUpdatedDate: string; + lastUpdatedTime: string; + }; + hearings: SiacPoacPaacHearing[]; +} + +export function renderSiacPoacPaacData(hearingList: SiacPoacPaacHearingList, options: RenderOptions): RenderedData { + const weekCommencingDate = formatDisplayDate(options.contentDate, options.locale); + const { date: lastUpdatedDate, time: lastUpdatedTime } = formatLastUpdatedDateTime(options.lastReceivedDate, options.locale); + + const renderedHearings = hearingList.map((hearing) => ({ + date: formatDdMmYyyyDate(hearing.date, options.locale), + time: hearing.time, + appellant: hearing.appellant, + caseReferenceNumber: hearing.caseReferenceNumber, + hearingType: hearing.hearingType, + courtroom: hearing.courtroom, + additionalInformation: hearing.additionalInformation + })); + + return { + header: { + listTitle: options.listTitle, + weekCommencingDate, + lastUpdatedDate, + lastUpdatedTime + }, + hearings: renderedHearings + }; +} diff --git a/libs/list-types/siac-poac-paac-weekly-hearing-list/src/schemas/siac-poac-paac-weekly-hearing-list.json b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/schemas/siac-poac-paac-weekly-hearing-list.json new file mode 100644 index 000000000..645ad8cdd --- /dev/null +++ b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/schemas/siac-poac-paac-weekly-hearing-list.json @@ -0,0 +1,54 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SIAC / POAC / PAAC Weekly Hearing List", + "description": "Schema for SIAC, POAC and PAAC Weekly Hearing Lists from pip-data-management", + "type": "array", + "items": { + "type": "object", + "required": ["date", "time", "appellant", "caseReferenceNumber", "hearingType", "courtroom", "additionalInformation"], + "properties": { + "date": { + "title": "Date", + "type": "string", + "pattern": "^\\d{2}/\\d{2}/\\d{4}$", + "examples": ["02/01/2025"] + }, + "time": { + "title": "Time", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["10:00am"] + }, + "appellant": { + "title": "Appellant", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["A Vs B"] + }, + "caseReferenceNumber": { + "title": "Case Reference Number", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["SC/00001/2025"] + }, + "hearingType": { + "title": "Hearing Type", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["Substantive hearing"] + }, + "courtroom": { + "title": "Courtroom", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["Court 1"] + }, + "additionalInformation": { + "title": "Additional information", + "type": "string", + "pattern": "^(?!(.|\r|\n)*<[^>]+>)(.|\r|\n)*$", + "examples": ["Remote hearing"] + } + } + } +} diff --git a/libs/list-types/siac-poac-paac-weekly-hearing-list/src/views/siac-poac-paac-weekly-hearing-list.njk b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/views/siac-poac-paac-weekly-hearing-list.njk new file mode 100644 index 000000000..dae0c884d --- /dev/null +++ b/libs/list-types/siac-poac-paac-weekly-hearing-list/src/views/siac-poac-paac-weekly-hearing-list.njk @@ -0,0 +1,76 @@ +{% extends "layouts/base-template.njk" %} + +{% block page_content %} +
+
+ +

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForWeekCommencing }} {{ header.weekCommencingDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+ +
+ + + {{ t.importantInformationTitle }} + + +
+

{{ t.importantInformationText }}

+

{{ t.importantInformationVenue }}

+

+ {{ t.importantInformationLinkText }} +

+
+
+ +
+

{{ t.searchCasesTitle }}

+ + +
+ +
+ + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.date }}{{ t.tableHeaders.time }}{{ t.tableHeaders.appellant }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.hearingType }}{{ t.tableHeaders.courtroom }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.date }}{{ hearing.time }}{{ hearing.appellant }}{{ hearing.caseReferenceNumber }}{{ hearing.hearingType }}{{ hearing.courtroom }}{{ hearing.additionalInformation }}
+
+ +

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

+ + + +
+
+{% endblock %} diff --git a/libs/list-types/siac-poac-paac-weekly-hearing-list/tsconfig.json b/libs/list-types/siac-poac-paac-weekly-hearing-list/tsconfig.json new file mode 100644 index 000000000..a029ee937 --- /dev/null +++ b/libs/list-types/siac-poac-paac-weekly-hearing-list/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules", "src/assets/"] +} diff --git a/libs/list-types/sscs-daily-hearing-list/package.json b/libs/list-types/sscs-daily-hearing-list/package.json new file mode 100644 index 000000000..6e286fa01 --- /dev/null +++ b/libs/list-types/sscs-daily-hearing-list/package.json @@ -0,0 +1,41 @@ +{ + "name": "@hmcts/sscs-daily-hearing-list", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.ts" + } + }, + "scripts": { + "build": "tsc && yarn build:nunjucks && yarn build:schemas", + "build:nunjucks": "mkdir -p dist/pdf && cd src/pdf && find . -name '*.njk' -exec sh -c 'mkdir -p ../../dist/pdf/$(dirname {}) && cp {} ../../dist/pdf/{}' \\;", + "build:schemas": "mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ." + }, + "dependencies": { + "@hmcts/list-types-common": "workspace:*", + "@hmcts/location": "workspace:*", + "@hmcts/pdf-generation": "workspace:*", + "@hmcts/postgres-prisma": "workspace:*", + "nunjucks": "3.2.4" + }, + "devDependencies": { + "@types/node": "24.10.4", + "typescript": "6.0.3", + "vitest": "4.1.8" + }, + "peerDependencies": { + "express": "^5.1.0" + } +} diff --git a/libs/list-types/sscs-daily-hearing-list/src/config.test.ts b/libs/list-types/sscs-daily-hearing-list/src/config.test.ts new file mode 100644 index 000000000..b1370d745 --- /dev/null +++ b/libs/list-types/sscs-daily-hearing-list/src/config.test.ts @@ -0,0 +1,65 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { assets, moduleRoot, schemaPath } from "./config.js"; + +describe("sscs-daily-hearing-list config", () => { + describe("moduleRoot", () => { + it("should be defined", () => { + expect(moduleRoot).toBeDefined(); + expect(typeof moduleRoot).toBe("string"); + }); + + it("should point to an existing directory", () => { + expect(existsSync(moduleRoot)).toBe(true); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(moduleRoot)).toBe(true); + }); + }); + + describe("schemaPath", () => { + it("should be defined", () => { + expect(schemaPath).toBeDefined(); + expect(typeof schemaPath).toBe("string"); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(schemaPath)).toBe(true); + }); + + it("should point to the schema file", () => { + expect(schemaPath).toContain("sscs-daily-hearing-list.json"); + }); + + it("should be a subdirectory of moduleRoot", () => { + expect(schemaPath.startsWith(moduleRoot)).toBe(true); + }); + }); + + describe("assets", () => { + it("should be defined", () => { + expect(assets).toBeDefined(); + expect(typeof assets).toBe("string"); + }); + + it("should point to assets directory", () => { + expect(assets).toContain("assets"); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(assets)).toBe(true); + }); + + it("should end with trailing slash", () => { + expect(assets).toMatch(/\/$/); + }); + }); + + describe("path relationships", () => { + it("assets should be subdirectory of moduleRoot", () => { + expect(assets.startsWith(moduleRoot)).toBe(true); + }); + }); +}); diff --git a/libs/list-types/sscs-daily-hearing-list/src/config.ts b/libs/list-types/sscs-daily-hearing-list/src/config.ts new file mode 100644 index 000000000..b652880d7 --- /dev/null +++ b/libs/list-types/sscs-daily-hearing-list/src/config.ts @@ -0,0 +1,9 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const moduleRoot = __dirname; +export const assets = path.join(__dirname, "assets/"); +export const schemaPath = path.join(__dirname, "schemas/sscs-daily-hearing-list.json"); diff --git a/libs/list-types/sscs-daily-hearing-list/src/conversion/sscs-config.test.ts b/libs/list-types/sscs-daily-hearing-list/src/conversion/sscs-config.test.ts new file mode 100644 index 000000000..4dbbbf845 --- /dev/null +++ b/libs/list-types/sscs-daily-hearing-list/src/conversion/sscs-config.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { SSCS_EXCEL_CONFIG } from "./sscs-config.js"; + +describe("SSCS_EXCEL_CONFIG", () => { + it("should have all required fields configured", () => { + const fieldNames = SSCS_EXCEL_CONFIG.fields.map((f) => f.fieldName); + + expect(fieldNames).toContain("venue"); + expect(fieldNames).toContain("appealReferenceNumber"); + expect(fieldNames).toContain("hearingType"); + expect(fieldNames).toContain("appellant"); + expect(fieldNames).toContain("courtroom"); + expect(fieldNames).toContain("hearingTime"); + expect(fieldNames).toContain("tribunal"); + expect(fieldNames).toContain("respondent"); + expect(fieldNames).toContain("additionalInformation"); + }); + + it("should have correct headers for required fields", () => { + const headers = SSCS_EXCEL_CONFIG.fields.map((f) => f.header); + + expect(headers).toContain("Venue"); + expect(headers).toContain("Appeal Reference Number"); + expect(headers).toContain("Hearing Type"); + expect(headers).toContain("Appellant"); + expect(headers).toContain("Courtroom"); + expect(headers).toContain("Hearing Time"); + expect(headers).toContain("Tribunal"); + expect(headers).toContain("FTA/Respondent"); + expect(headers).toContain("Additional Information"); + }); + + it("should mark most fields as required", () => { + const requiredFields = SSCS_EXCEL_CONFIG.fields.filter((f) => f.required).map((f) => f.fieldName); + + expect(requiredFields).toContain("venue"); + expect(requiredFields).toContain("appealReferenceNumber"); + expect(requiredFields).toContain("hearingType"); + expect(requiredFields).toContain("appellant"); + expect(requiredFields).toContain("courtroom"); + expect(requiredFields).toContain("hearingTime"); + expect(requiredFields).toContain("tribunal"); + expect(requiredFields).toContain("respondent"); + }); + + it("should mark additionalInformation as not required", () => { + const additionalInfo = SSCS_EXCEL_CONFIG.fields.find((f) => f.fieldName === "additionalInformation"); + expect(additionalInfo?.required).toBe(false); + }); + + it("should have minRows set to 1", () => { + expect(SSCS_EXCEL_CONFIG.minRows).toBe(1); + }); + + describe("field validators", () => { + const getValidator = (fieldName: string) => { + const field = SSCS_EXCEL_CONFIG.fields.find((f) => f.fieldName === fieldName); + return field!.validators![0]; + }; + + const validValues: Record = { + venue: "Manchester Tribunal Centre", + appealReferenceNumber: "SC/123/2025", + hearingType: "Oral Hearing", + appellant: "Smith, John", + courtroom: "Room 1", + hearingTime: "10:00am", + tribunal: "SSCS", + respondent: "Secretary of State for Work and Pensions", + additionalInformation: "Video hearing" + }; + + for (const [fieldName, validValue] of Object.entries(validValues)) { + it(`should accept valid value for ${fieldName}`, () => { + expect(() => getValidator(fieldName)(validValue, 1)).not.toThrow(); + }); + + it(`should reject HTML tags in ${fieldName}`, () => { + expect(() => getValidator(fieldName)("", 1)).toThrow("HTML tags are not allowed"); + }); + } + }); +}); diff --git a/libs/list-types/sscs-daily-hearing-list/src/conversion/sscs-config.ts b/libs/list-types/sscs-daily-hearing-list/src/conversion/sscs-config.ts new file mode 100644 index 000000000..ed749338d --- /dev/null +++ b/libs/list-types/sscs-daily-hearing-list/src/conversion/sscs-config.ts @@ -0,0 +1,81 @@ +import { createConverter, type ExcelConverterConfig, registerConverter, registerConverterByName, validateNoHtmlTags } from "@hmcts/list-types-common"; + +export const SSCS_EXCEL_CONFIG: ExcelConverterConfig = { + fields: [ + { + header: "Venue", + fieldName: "venue", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Venue", rowNumber)] + }, + { + header: "Appeal Reference Number", + fieldName: "appealReferenceNumber", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Appeal Reference Number", rowNumber)] + }, + { + header: "Hearing Type", + fieldName: "hearingType", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Hearing Type", rowNumber)] + }, + { + header: "Appellant", + fieldName: "appellant", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Appellant", rowNumber)] + }, + { + header: "Courtroom", + fieldName: "courtroom", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Courtroom", rowNumber)] + }, + { + header: "Hearing Time", + fieldName: "hearingTime", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Hearing Time", rowNumber)] + }, + { + header: "Tribunal", + fieldName: "tribunal", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Tribunal", rowNumber)] + }, + { + header: "FTA/Respondent", + fieldName: "respondent", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "FTA/Respondent", rowNumber)] + }, + { + header: "Additional Information", + fieldName: "additionalInformation", + required: false, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Additional Information", rowNumber)] + } + ], + minRows: 1 +}; + +const sscsConverter = createConverter(SSCS_EXCEL_CONFIG); + +// Register converters for all 8 SSCS list types by ID and by name +// IDs 28-35 correspond to the seeded SSCS list types +registerConverter(28, sscsConverter); +registerConverter(29, sscsConverter); +registerConverter(30, sscsConverter); +registerConverter(31, sscsConverter); +registerConverter(32, sscsConverter); +registerConverter(33, sscsConverter); +registerConverter(34, sscsConverter); + +registerConverterByName("SSCS_MIDLANDS_DAILY_HEARING_LIST", sscsConverter); +registerConverterByName("SSCS_SOUTH_EAST_DAILY_HEARING_LIST", sscsConverter); +registerConverterByName("SSCS_WALES_AND_SOUTH_WEST_DAILY_HEARING_LIST", sscsConverter); +registerConverterByName("SSCS_SCOTLAND_DAILY_HEARING_LIST", sscsConverter); +registerConverterByName("SSCS_NORTH_EAST_DAILY_HEARING_LIST", sscsConverter); +registerConverterByName("SSCS_NORTH_WEST_DAILY_HEARING_LIST", sscsConverter); +registerConverterByName("SSCS_LONDON_DAILY_HEARING_LIST", sscsConverter); diff --git a/libs/list-types/sscs-daily-hearing-list/src/email-summary/summary-builder.test.ts b/libs/list-types/sscs-daily-hearing-list/src/email-summary/summary-builder.test.ts new file mode 100644 index 000000000..16014f040 --- /dev/null +++ b/libs/list-types/sscs-daily-hearing-list/src/email-summary/summary-builder.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { extractCaseSummary } from "./summary-builder.js"; + +describe("extractCaseSummary", () => { + it("should extract hearing time, hearing type, and appeal reference number from each hearing", () => { + const hearings = [ + { + venue: "Manchester Tribunal Centre", + appealReferenceNumber: "SC/123/2025", + hearingType: "Oral Hearing", + appellant: "Smith, John", + courtroom: "Room 1", + hearingTime: "10:00am", + tribunal: "SSCS", + respondent: "Secretary of State for Work and Pensions", + additionalInformation: "Video hearing" + } + ]; + + const result = extractCaseSummary(hearings); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual([ + { label: "Hearing Time", value: "10:00am" }, + { label: "Hearing Type", value: "Oral Hearing" }, + { label: "Appeal Reference Number", value: "SC/123/2025" } + ]); + }); + + it("should extract summaries for multiple hearings", () => { + const hearings = [ + { + venue: "Manchester Tribunal Centre", + appealReferenceNumber: "SC/123/2025", + hearingType: "Oral Hearing", + appellant: "Smith, John", + courtroom: "Room 1", + hearingTime: "10:00am", + tribunal: "SSCS", + respondent: "Secretary of State", + additionalInformation: "" + }, + { + venue: "London Tribunal Centre", + appealReferenceNumber: "SC/456/2025", + hearingType: "Paper Hearing", + appellant: "Jones, Jane", + courtroom: "Room 2", + hearingTime: "2:00pm", + tribunal: "SSCS", + respondent: "HMRC", + additionalInformation: "In person" + } + ]; + + const result = extractCaseSummary(hearings); + + expect(result).toHaveLength(2); + expect(result[0][0].value).toBe("10:00am"); + expect(result[0][1].value).toBe("Oral Hearing"); + expect(result[0][2].value).toBe("SC/123/2025"); + expect(result[1][0].value).toBe("2:00pm"); + expect(result[1][1].value).toBe("Paper Hearing"); + expect(result[1][2].value).toBe("SC/456/2025"); + }); + + it("should handle empty hearingTime with empty string fallback", () => { + const hearings = [ + { + venue: "Venue", + appealReferenceNumber: "SC/123/2025", + hearingType: "Oral Hearing", + appellant: "Smith", + courtroom: "Room 1", + hearingTime: "", + tribunal: "SSCS", + respondent: "Secretary of State", + additionalInformation: "" + } + ]; + + const result = extractCaseSummary(hearings); + + expect(result[0][0].value).toBe(""); + }); + + it("should return an empty array for an empty hearing list", () => { + const result = extractCaseSummary([]); + expect(result).toHaveLength(0); + }); + + it("should fall back to empty string when hearingTime or hearingType are undefined", () => { + const hearings = [ + { + venue: "Venue", + appealReferenceNumber: "SC/123/2025", + hearingType: undefined as unknown as string, + appellant: "Smith", + courtroom: "Room 1", + hearingTime: undefined as unknown as string, + tribunal: "SSCS", + respondent: "Secretary of State", + additionalInformation: "" + } + ]; + + const result = extractCaseSummary(hearings); + + expect(result[0][0].value).toBe(""); + expect(result[0][1].value).toBe(""); + }); +}); diff --git a/libs/list-types/sscs-daily-hearing-list/src/email-summary/summary-builder.ts b/libs/list-types/sscs-daily-hearing-list/src/email-summary/summary-builder.ts new file mode 100644 index 000000000..596563275 --- /dev/null +++ b/libs/list-types/sscs-daily-hearing-list/src/email-summary/summary-builder.ts @@ -0,0 +1,12 @@ +import { type CaseSummary, formatCaseSummaryForEmail } from "@hmcts/list-types-common"; +import type { SscsDailyHearingList } from "../models/types.js"; + +export { formatCaseSummaryForEmail }; + +export function extractCaseSummary(jsonData: SscsDailyHearingList): CaseSummary[] { + return jsonData.map((hearing) => [ + { label: "Hearing Time", value: hearing.hearingTime || "" }, + { label: "Hearing Type", value: hearing.hearingType || "" }, + { label: "Appeal Reference Number", value: hearing.appealReferenceNumber || "" } + ]); +} diff --git a/libs/list-types/sscs-daily-hearing-list/src/index.ts b/libs/list-types/sscs-daily-hearing-list/src/index.ts new file mode 100644 index 000000000..0727e65e4 --- /dev/null +++ b/libs/list-types/sscs-daily-hearing-list/src/index.ts @@ -0,0 +1,9 @@ +import "./conversion/sscs-config.js"; // Register converters on module load + +// Business logic exports +export * from "./email-summary/summary-builder.js"; +export { cy as sscsDailyHearingListCy } from "./locales/cy.js"; +export { en as sscsDailyHearingListEn, importantInformationByListType } from "./locales/en.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export * from "./rendering/renderer.js"; diff --git a/libs/list-types/sscs-daily-hearing-list/src/locales/cy.ts b/libs/list-types/sscs-daily-hearing-list/src/locales/cy.ts new file mode 100644 index 000000000..e7adb24ed --- /dev/null +++ b/libs/list-types/sscs-daily-hearing-list/src/locales/cy.ts @@ -0,0 +1,28 @@ +import { provenanceLabelsCy as provenanceLabels } from "@hmcts/list-types-common"; + +export const cy = { + listForDate: "Rhestr ar gyfer", + lastUpdated: "Diweddarwyd ddiwethaf", + at: "am", + factLinkText: "Dod o hyd i fanylion cyswllt a gwybodaeth arall am lysoedd a thribiwnlysoedd yng Nghymru a Lloegr", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "a rhai tribiwnlysoedd heb eu datganoli yn yr Alban.", + importantInformationTitle: "Gwybodaeth bwysig", + importantInformationLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + searchCasesTitle: "Chwilio Achosion", + searchCasesLabel: "Chwilio yn ôl cyfeirnod apêl, math o wrandawiad, apellydd, neu wybodaeth arall", + tableHeaders: { + venue: "Lleoliad", + appealReferenceNumber: "Cyfeirnod Apêl", + hearingType: "Math o Wrandawiad", + appellant: "Apellydd", + courtroom: "Ystafell y Llys", + hearingTime: "Amser y Gwrandawiad", + tribunal: "Tribiwnlys", + respondent: "ATC/Ymatebydd", + additionalInformation: "Gwybodaeth Ychwanegol" + }, + dataSource: "Ffynhonnell data", + backToTop: "Yn ôl i frig y dudalen", + provenanceLabels +}; diff --git a/libs/list-types/sscs-daily-hearing-list/src/locales/en.ts b/libs/list-types/sscs-daily-hearing-list/src/locales/en.ts new file mode 100644 index 000000000..fd1d5ee98 --- /dev/null +++ b/libs/list-types/sscs-daily-hearing-list/src/locales/en.ts @@ -0,0 +1,46 @@ +import { provenanceLabelsEn as provenanceLabels } from "@hmcts/list-types-common"; + +const OPEN_JUSTICE_PREAMBLE = + "Open justice is a fundamental principle of our justice system. When considering the use of telephone and video technology, the judiciary will have regard to the principles of open justice. Judges may determine that a hearing should be held in private if this is necessary to secure the proper administration of justice."; + +const OBSERVER_INSTRUCTIONS = (email: string) => + `Social Security and Child Support Tribunal parties and representatives will be informed directly as to the arrangements for hearing cases remotely. Any other person interested in joining the hearing remotely should contact the Social Security and Child Support Tribunal Office direct, in advance of the hearing date, by emailing ${email} so that arrangements can be made. The following details should be included in the subject line of the email [OBSERVER/MEDIA] REQUEST – [case reference] – [hearing date]. If the case is to be heard in private or is subject to a reporting restriction, this will be notified.`; + +const OBSERVE_LINK = "For more information, please visit https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing"; + +export const importantInformationByListType: Record = { + SSCS_LONDON_DAILY_HEARING_LIST: `${OPEN_JUSTICE_PREAMBLE}\n${OBSERVER_INSTRUCTIONS("sscsa-sutton@justice.gov.uk")}\n${OBSERVE_LINK}`, + SSCS_MIDLANDS_DAILY_HEARING_LIST: `${OPEN_JUSTICE_PREAMBLE}\n${OBSERVER_INSTRUCTIONS("ascbirmingham@justice.gov.uk")}\n${OBSERVE_LINK}`, + SSCS_NORTH_EAST_DAILY_HEARING_LIST: `${OPEN_JUSTICE_PREAMBLE}\n${OBSERVER_INSTRUCTIONS("sscsa-leeds@Justice.gov.uk")}\n${OBSERVE_LINK}`, + SSCS_NORTH_WEST_DAILY_HEARING_LIST: `${OPEN_JUSTICE_PREAMBLE}\n${OBSERVER_INSTRUCTIONS("sscsa-liverpool@justice.gov.uk")}\n${OBSERVE_LINK}`, + SSCS_SCOTLAND_DAILY_HEARING_LIST: `${OPEN_JUSTICE_PREAMBLE}\n${OBSERVER_INSTRUCTIONS("sscsa-glasgow@justice.gov.uk")}\n${OBSERVE_LINK}`, + SSCS_SOUTH_EAST_DAILY_HEARING_LIST: `${OPEN_JUSTICE_PREAMBLE}\n${OBSERVER_INSTRUCTIONS("sscs_bradford@justice.gov.uk")}\n${OBSERVE_LINK}`, + SSCS_WALES_AND_SOUTH_WEST_DAILY_HEARING_LIST: `${OPEN_JUSTICE_PREAMBLE}\n${OBSERVER_INSTRUCTIONS("sscsa-cardiff@justice.gov.uk")}\n${OBSERVE_LINK}` +}; + +export const en = { + listForDate: "List for", + lastUpdated: "Last updated", + at: "at", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + importantInformationTitle: "Important information", + importantInformationLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + searchCasesTitle: "Search Cases", + searchCasesLabel: "Search by appeal reference, hearing type, appellant, or other details", + tableHeaders: { + venue: "Venue", + appealReferenceNumber: "Appeal reference number", + hearingType: "Hearing type", + appellant: "Appellant", + courtroom: "Courtroom", + hearingTime: "Hearing time", + tribunal: "Tribunal", + respondent: "FTA/Respondent", + additionalInformation: "Additional information" + }, + dataSource: "Data source", + backToTop: "Back to top", + provenanceLabels +}; diff --git a/libs/list-types/sscs-daily-hearing-list/src/models/types.ts b/libs/list-types/sscs-daily-hearing-list/src/models/types.ts new file mode 100644 index 000000000..5438a96e6 --- /dev/null +++ b/libs/list-types/sscs-daily-hearing-list/src/models/types.ts @@ -0,0 +1,13 @@ +export interface SscsDailyHearing { + venue: string; + appealReferenceNumber: string; + hearingType: string; + appellant: string; + courtroom: string; + hearingTime: string; + tribunal: string; + respondent: string; + additionalInformation: string; +} + +export type SscsDailyHearingList = SscsDailyHearing[]; diff --git a/libs/list-types/sscs-daily-hearing-list/src/pdf/pdf-generator.test.ts b/libs/list-types/sscs-daily-hearing-list/src/pdf/pdf-generator.test.ts new file mode 100644 index 000000000..f49e93a11 --- /dev/null +++ b/libs/list-types/sscs-daily-hearing-list/src/pdf/pdf-generator.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockUploadBlob } = vi.hoisted(() => ({ + mockUploadBlob: vi.fn() +})); +vi.mock("@hmcts/azure-blob", () => ({ + uploadBlob: mockUploadBlob, + CONTAINER: { ARTEFACT: "artefact", PUBLICATIONS: "publications" } +})); + +vi.mock("@hmcts/pdf-generation", () => ({ + generatePdfFromHtml: vi.fn() +})); + +vi.mock("../rendering/renderer.js", () => ({ + renderSscsDailyHearingListData: vi.fn() +})); + +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { renderSscsDailyHearingListData } from "../rendering/renderer.js"; +import { generateSscsDailyHearingListPdf } from "./pdf-generator.js"; + +const mockRenderedData = { + header: { + listTitle: "London Social Security and Child Support Tribunal Daily Hearing List", + listDate: "1 January 2026", + lastUpdatedDate: "1 January 2026", + lastUpdatedTime: "12:00pm" + }, + hearings: [] +}; + +const mockHearingList = [ + { + venue: "London Tribunal Centre", + appealReferenceNumber: "SC/001/2026", + hearingType: "Oral Hearing", + appellant: "Smith, John", + courtroom: "Room 1", + hearingTime: "10:00am", + tribunal: "SSCS", + respondent: "Secretary of State for Work and Pensions", + additionalInformation: "" + } +]; + +const BASE_OPTIONS = { + artefactId: "test-artefact-123", + contentDate: new Date("2026-01-01"), + locale: "en", + locationId: "19", + jsonData: mockHearingList, + listTitle: "London Social Security and Child Support Tribunal Daily Hearing List", + courtName: "London Social Security and Child Support Tribunal", + importantInformationText: "Open justice is a fundamental principle." +}; + +describe("generateSscsDailyHearingListPdf", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(renderSscsDailyHearingListData).mockReturnValue(mockRenderedData); + mockUploadBlob.mockResolvedValue(undefined); + }); + + it("should generate PDF successfully", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF content"), + sizeBytes: 1024 + }); + + const result = await generateSscsDailyHearingListPdf(BASE_OPTIONS); + + expect(result.success).toBe(true); + expect(result.pdfPath).toContain("test-artefact-123.pdf"); + expect(result.sizeBytes).toBe(1024); + expect(result.exceedsMaxSize).toBe(false); + }); + + it("should return exceedsMaxSize true when PDF is over 2MB", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.alloc(3 * 1024 * 1024), + sizeBytes: 3 * 1024 * 1024 + }); + + const result = await generateSscsDailyHearingListPdf({ ...BASE_OPTIONS, artefactId: "large-pdf-123" }); + + expect(result.success).toBe(true); + expect(result.exceedsMaxSize).toBe(true); + }); + + it("should return error when PDF generation fails with error message", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ success: false, error: "Puppeteer crashed" }); + + const result = await generateSscsDailyHearingListPdf({ ...BASE_OPTIONS, artefactId: "failed-pdf", importantInformationText: "" }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Puppeteer crashed"); + }); + + it("should return default error message when PDF generation fails without message", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ success: false }); + + const result = await generateSscsDailyHearingListPdf({ ...BASE_OPTIONS, artefactId: "failed-pdf-no-msg", importantInformationText: "" }); + + expect(result.success).toBe(false); + expect(result.error).toBe("PDF generation failed"); + }); + + it("should return error when an unexpected exception is thrown", async () => { + vi.mocked(generatePdfFromHtml).mockRejectedValue(new Error("Unexpected crash")); + + const result = await generateSscsDailyHearingListPdf({ ...BASE_OPTIONS, artefactId: "exception-pdf", importantInformationText: "" }); + + expect(result.success).toBe(false); + expect(result.error).toContain("Unexpected crash"); + }); + + it("should use raw provenance value when not in PROVENANCE_LABELS", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ success: true, pdfBuffer: Buffer.from("PDF"), sizeBytes: 100 }); + + const result = await generateSscsDailyHearingListPdf({ + ...BASE_OPTIONS, + artefactId: "unknown-provenance", + importantInformationText: "", + provenance: "UNKNOWN_SOURCE" + }); + + expect(result.success).toBe(true); + }); + + it("should pass correct render options to renderer", async () => { + vi.mocked(generatePdfFromHtml).mockResolvedValue({ success: true, pdfBuffer: Buffer.from("PDF"), sizeBytes: 100 }); + + await generateSscsDailyHearingListPdf({ ...BASE_OPTIONS, artefactId: "test-render-options", locale: "cy" }); + + expect(renderSscsDailyHearingListData).toHaveBeenCalledWith(mockHearingList, { + locale: "cy", + courtName: "London Social Security and Child Support Tribunal", + contentDate: BASE_OPTIONS.contentDate, + lastReceivedDate: expect.any(String), + listTitle: BASE_OPTIONS.listTitle + }); + }); +}); diff --git a/libs/list-types/sscs-daily-hearing-list/src/pdf/pdf-generator.ts b/libs/list-types/sscs-daily-hearing-list/src/pdf/pdf-generator.ts new file mode 100644 index 000000000..358d1222f --- /dev/null +++ b/libs/list-types/sscs-daily-hearing-list/src/pdf/pdf-generator.ts @@ -0,0 +1,68 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + type BasePdfGenerationOptions, + configureNunjucks, + createPdfErrorResult, + loadTranslations, + PDF_BASE_STYLES, + type PdfGenerationResult, + savePdfToStorage +} from "@hmcts/list-types-common"; +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { PROVENANCE_LABELS } from "@hmcts/publication"; +import type { SscsDailyHearingList } from "../models/types.js"; +import { renderSscsDailyHearingListData } from "../rendering/renderer.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface PdfGenerationOptions extends BasePdfGenerationOptions { + contentDate: Date; + listTitle: string; + courtName: string; + importantInformationText: string; +} + +export async function generateSscsDailyHearingListPdf(options: PdfGenerationOptions): Promise { + try { + const renderedData = renderSscsDailyHearingListData(options.jsonData, { + locale: options.locale, + courtName: options.courtName, + contentDate: options.contentDate, + lastReceivedDate: new Date().toISOString(), + listTitle: options.listTitle + }); + + const translations = await loadTranslations( + options.locale, + () => import("../locales/en.js"), + () => import("../locales/cy.js") + ); + + const provenanceLabel = options.provenance ? PROVENANCE_LABELS[options.provenance as keyof typeof PROVENANCE_LABELS] || options.provenance : ""; + + const env = configureNunjucks(__dirname); + const html = env.render("pdf-template.njk", { + header: renderedData.header, + hearings: renderedData.hearings, + dataSource: provenanceLabel, + importantInformationText: options.importantInformationText, + t: translations, + pdfStyles: PDF_BASE_STYLES + }); + + const pdfResult = await generatePdfFromHtml(html); + + if (!pdfResult.success || !pdfResult.pdfBuffer) { + return { + success: false, + error: pdfResult.error || "PDF generation failed" + }; + } + + return await savePdfToStorage(options.artefactId, pdfResult.pdfBuffer, pdfResult.sizeBytes!); + } catch (error) { + return createPdfErrorResult(error); + } +} diff --git a/libs/list-types/sscs-daily-hearing-list/src/pdf/pdf-template.njk b/libs/list-types/sscs-daily-hearing-list/src/pdf/pdf-template.njk new file mode 100644 index 000000000..a0468ea7a --- /dev/null +++ b/libs/list-types/sscs-daily-hearing-list/src/pdf/pdf-template.njk @@ -0,0 +1,67 @@ + + + + + + {{ header.listTitle }} + + + +
+

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForDate }} {{ header.listDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+
+ +
+

{{ t.importantInformationTitle }}

+

{{ importantInformationText | replace(t.importantInformationLinkUrl, '' + t.importantInformationLinkUrl + '') | safe }}

+
+ + {% if hearings.length > 0 %} + + + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.venue }}{{ t.tableHeaders.appealReferenceNumber }}{{ t.tableHeaders.hearingType }}{{ t.tableHeaders.appellant }}{{ t.tableHeaders.courtroom }}{{ t.tableHeaders.hearingTime }}{{ t.tableHeaders.tribunal }}{{ t.tableHeaders.respondent }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.venue }}{{ hearing.appealReferenceNumber }}{{ hearing.hearingType }}{{ hearing.appellant }}{{ hearing.courtroom }}{{ hearing.hearingTime }}{{ hearing.tribunal }}{{ hearing.respondent }}{{ hearing.additionalInformation }}
+ {% else %} +

No hearings scheduled.

+ {% endif %} + + + + diff --git a/libs/list-types/sscs-daily-hearing-list/src/rendering/renderer.test.ts b/libs/list-types/sscs-daily-hearing-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..157bd01a5 --- /dev/null +++ b/libs/list-types/sscs-daily-hearing-list/src/rendering/renderer.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderSscsDailyHearingListData } from "./renderer.js"; + +vi.mock("@hmcts/list-types-common", () => ({ + formatDisplayDate: vi.fn((_date: Date) => "1 January 2026"), + formatLastUpdatedDateTime: vi.fn(() => ({ date: "1 January 2026", time: "10:00am" })) +})); + +describe("renderSscsDailyHearingListData", () => { + const sampleHearings = [ + { + venue: "Manchester Tribunal Centre", + appealReferenceNumber: "SC/123/2025", + hearingType: "Oral Hearing", + appellant: "Smith, John", + courtroom: "Room 1", + hearingTime: "10:00am", + tribunal: "SSCS", + respondent: "Secretary of State for Work and Pensions", + additionalInformation: "Video hearing" + } + ]; + + const options = { + locale: "en", + courtName: "London Social Security and Child Support Tribunal", + contentDate: new Date("2026-01-01"), + lastReceivedDate: "2026-01-01T10:00:00Z", + listTitle: "London Social Security and Child Support Tribunal Daily Hearing List" + }; + + it("should return correct header with formatted dates", () => { + const result = renderSscsDailyHearingListData(sampleHearings, options); + + expect(result.header.listTitle).toBe("London Social Security and Child Support Tribunal Daily Hearing List"); + expect(result.header.listDate).toBe("1 January 2026"); + expect(result.header.lastUpdatedDate).toBe("1 January 2026"); + expect(result.header.lastUpdatedTime).toBe("10:00am"); + }); + + it("should return the hearings unchanged", () => { + const result = renderSscsDailyHearingListData(sampleHearings, options); + + expect(result.hearings).toHaveLength(1); + expect(result.hearings[0].venue).toBe("Manchester Tribunal Centre"); + expect(result.hearings[0].appealReferenceNumber).toBe("SC/123/2025"); + expect(result.hearings[0].hearingType).toBe("Oral Hearing"); + expect(result.hearings[0].appellant).toBe("Smith, John"); + expect(result.hearings[0].courtroom).toBe("Room 1"); + expect(result.hearings[0].hearingTime).toBe("10:00am"); + expect(result.hearings[0].tribunal).toBe("SSCS"); + expect(result.hearings[0].respondent).toBe("Secretary of State for Work and Pensions"); + expect(result.hearings[0].additionalInformation).toBe("Video hearing"); + }); + + it("should handle an empty hearing list", () => { + const result = renderSscsDailyHearingListData([], options); + + expect(result.hearings).toHaveLength(0); + expect(result.header.listTitle).toBe("London Social Security and Child Support Tribunal Daily Hearing List"); + }); + + it("should handle Welsh locale", () => { + const welshOptions = { ...options, locale: "cy" }; + const result = renderSscsDailyHearingListData(sampleHearings, welshOptions); + + expect(result.hearings).toHaveLength(1); + expect(result.header.listTitle).toBe("London Social Security and Child Support Tribunal Daily Hearing List"); + }); +}); diff --git a/libs/list-types/sscs-daily-hearing-list/src/rendering/renderer.ts b/libs/list-types/sscs-daily-hearing-list/src/rendering/renderer.ts new file mode 100644 index 000000000..979942a58 --- /dev/null +++ b/libs/list-types/sscs-daily-hearing-list/src/rendering/renderer.ts @@ -0,0 +1,35 @@ +import { formatDisplayDate, formatLastUpdatedDateTime } from "@hmcts/list-types-common"; +import type { SscsDailyHearing, SscsDailyHearingList } from "../models/types.js"; + +export interface RenderOptions { + locale: string; + courtName: string; + contentDate: Date; + lastReceivedDate: string; + listTitle: string; +} + +export interface RenderedData { + header: { + listTitle: string; + listDate: string; + lastUpdatedDate: string; + lastUpdatedTime: string; + }; + hearings: SscsDailyHearing[]; +} + +export function renderSscsDailyHearingListData(hearingList: SscsDailyHearingList, options: RenderOptions): RenderedData { + const listDate = formatDisplayDate(options.contentDate, options.locale); + const { date: lastUpdatedDate, time: lastUpdatedTime } = formatLastUpdatedDateTime(options.lastReceivedDate, options.locale); + + return { + header: { + listTitle: options.listTitle, + listDate, + lastUpdatedDate, + lastUpdatedTime + }, + hearings: hearingList + }; +} diff --git a/libs/list-types/sscs-daily-hearing-list/src/schemas/sscs-daily-hearing-list.json b/libs/list-types/sscs-daily-hearing-list/src/schemas/sscs-daily-hearing-list.json new file mode 100644 index 000000000..72976e544 --- /dev/null +++ b/libs/list-types/sscs-daily-hearing-list/src/schemas/sscs-daily-hearing-list.json @@ -0,0 +1,66 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SSCS Daily Hearing List", + "description": "Schema for Social Security and Child Support Tribunal Daily Hearing List", + "type": "array", + "items": { + "type": "object", + "required": ["venue", "appealReferenceNumber", "hearingType", "appellant", "courtroom", "hearingTime", "tribunal", "respondent", "additionalInformation"], + "properties": { + "venue": { + "title": "Venue", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$", + "examples": ["Manchester Tribunal Centre"] + }, + "appealReferenceNumber": { + "title": "Appeal Reference Number", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$", + "examples": ["SC/123/2025"] + }, + "hearingType": { + "title": "Hearing Type", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$", + "examples": ["Oral Hearing"] + }, + "appellant": { + "title": "Appellant", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$", + "examples": ["Smith, John"] + }, + "courtroom": { + "title": "Courtroom", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$", + "examples": ["Room 1"] + }, + "hearingTime": { + "title": "Hearing Time", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$", + "examples": ["10:00am"] + }, + "tribunal": { + "title": "Tribunal", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$", + "examples": ["SSCS"] + }, + "respondent": { + "title": "FTA/Respondent", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$", + "examples": ["Secretary of State for Work and Pensions"] + }, + "additionalInformation": { + "title": "Additional Information", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$", + "examples": ["Video hearing"] + } + } + } +} diff --git a/libs/list-types/sscs-daily-hearing-list/tsconfig.json b/libs/list-types/sscs-daily-hearing-list/tsconfig.json new file mode 100644 index 000000000..a029ee937 --- /dev/null +++ b/libs/list-types/sscs-daily-hearing-list/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules", "src/assets/"] +} diff --git a/libs/list-types/utiac-jr-daily-hearing-list/package.json b/libs/list-types/utiac-jr-daily-hearing-list/package.json new file mode 100644 index 000000000..2f3d5e080 --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/package.json @@ -0,0 +1,42 @@ +{ + "name": "@hmcts/utiac-jr-daily-hearing-list", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.ts" + } + }, + "scripts": { + "build": "tsc && yarn build:nunjucks && yarn build:schemas", + "build:nunjucks": "mkdir -p dist/pdf && cd src/pdf && find . -name '*.njk' -exec sh -c 'mkdir -p ../../dist/pdf/$(dirname {}) && cp {} ../../dist/pdf/{}' \\;", + "build:schemas": "mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ." + }, + "dependencies": { + "@hmcts/list-types-common": "workspace:*", + "@hmcts/pdf-generation": "workspace:*", + "@hmcts/postgres-prisma": "workspace:*", + "luxon": "3.7.2", + "nunjucks": "3.2.4" + }, + "devDependencies": { + "@types/luxon": "3.7.1", + "@types/node": "24.10.4", + "typescript": "6.0.3", + "vitest": "4.1.9" + }, + "peerDependencies": { + "express": "^5.1.0" + } +} diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/config.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/config.ts new file mode 100644 index 000000000..a292f2ff1 --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/config.ts @@ -0,0 +1,11 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const moduleRoot = __dirname; +export const assets = path.join(__dirname, "assets/"); +export const schemaPath = path.join(__dirname, "schemas/utiac-jr-daily-hearing-list.json"); +export const londonSchemaPath = path.join(__dirname, "schemas/utiac-jr-london-daily-hearing-list.json"); +export const pdfTemplateDir = path.join(__dirname, "pdf"); diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/conversion/utiac-jr-config.test.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/conversion/utiac-jr-config.test.ts new file mode 100644 index 000000000..68f9413dd --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/conversion/utiac-jr-config.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { UTIAC_JR_LONDON_EXCEL_CONFIG, UTIAC_JR_REGIONAL_EXCEL_CONFIG } from "./utiac-jr-config.js"; + +describe("UTIAC_JR_REGIONAL_EXCEL_CONFIG", () => { + it("should have correct configuration structure", () => { + expect(UTIAC_JR_REGIONAL_EXCEL_CONFIG).toBeDefined(); + expect(UTIAC_JR_REGIONAL_EXCEL_CONFIG.fields).toHaveLength(7); + expect(UTIAC_JR_REGIONAL_EXCEL_CONFIG.minRows).toBe(1); + }); + + it("should have all fields with correct names", () => { + const fieldNames = UTIAC_JR_REGIONAL_EXCEL_CONFIG.fields.map((f) => f.fieldName); + expect(fieldNames).toEqual(["venue", "judges", "hearingTime", "caseReferenceNumber", "caseTitle", "hearingType", "additionalInformation"]); + }); + + it("should have correct required flags", () => { + const requiredFields = UTIAC_JR_REGIONAL_EXCEL_CONFIG.fields.filter((f) => f.required).map((f) => f.fieldName); + const optionalFields = UTIAC_JR_REGIONAL_EXCEL_CONFIG.fields.filter((f) => !f.required).map((f) => f.fieldName); + + expect(requiredFields).toEqual(["venue", "judges", "hearingTime", "caseReferenceNumber", "caseTitle", "hearingType"]); + expect(optionalFields).toEqual(["additionalInformation"]); + }); + + describe("hearingTime field validation", () => { + const hearingTimeField = UTIAC_JR_REGIONAL_EXCEL_CONFIG.fields.find((f) => f.fieldName === "hearingTime"); + + it("should accept valid time formats", () => { + expect(hearingTimeField?.validators).toBeDefined(); + expect(() => hearingTimeField?.validators?.[0]("9:30am", 1)).not.toThrow(); + expect(() => hearingTimeField?.validators?.[0]("10:15pm", 1)).not.toThrow(); + expect(() => hearingTimeField?.validators?.[0]("9am", 1)).not.toThrow(); + }); + + it("should reject invalid time formats", () => { + expect(() => hearingTimeField?.validators?.[0]("invalid", 1)).toThrow("Invalid time format"); + expect(() => hearingTimeField?.validators?.[0]("9:30", 1)).toThrow("Invalid time format"); + }); + }); + + describe("HTML tag validation on all text fields", () => { + const textFields = ["venue", "judges", "caseReferenceNumber", "caseTitle", "hearingType", "additionalInformation"]; + + for (const fieldName of textFields) { + it(`should reject HTML tags in ${fieldName}`, () => { + const field = UTIAC_JR_REGIONAL_EXCEL_CONFIG.fields.find((f) => f.fieldName === fieldName); + expect(field).toBeDefined(); + expect(() => field?.validators?.[0]("", 1)).toThrow(); + expect(() => field?.validators?.[0]("Valid value", 1)).not.toThrow(); + }); + } + }); +}); + +describe("UTIAC_JR_LONDON_EXCEL_CONFIG", () => { + it("should have correct configuration structure", () => { + expect(UTIAC_JR_LONDON_EXCEL_CONFIG).toBeDefined(); + expect(UTIAC_JR_LONDON_EXCEL_CONFIG.fields).toHaveLength(8); + expect(UTIAC_JR_LONDON_EXCEL_CONFIG.minRows).toBe(1); + }); + + it("should have all fields with correct names", () => { + const fieldNames = UTIAC_JR_LONDON_EXCEL_CONFIG.fields.map((f) => f.fieldName); + expect(fieldNames).toEqual([ + "hearingTime", + "caseTitle", + "representative", + "caseReferenceNumber", + "judges", + "hearingType", + "location", + "additionalInformation" + ]); + }); + + it("should have correct required flags", () => { + const requiredFields = UTIAC_JR_LONDON_EXCEL_CONFIG.fields.filter((f) => f.required).map((f) => f.fieldName); + const optionalFields = UTIAC_JR_LONDON_EXCEL_CONFIG.fields.filter((f) => !f.required).map((f) => f.fieldName); + + expect(requiredFields).toEqual(["hearingTime", "caseTitle", "caseReferenceNumber", "judges", "hearingType", "location"]); + expect(optionalFields).toEqual(["representative", "additionalInformation"]); + }); + + describe("hearingTime field validation", () => { + const hearingTimeField = UTIAC_JR_LONDON_EXCEL_CONFIG.fields.find((f) => f.fieldName === "hearingTime"); + + it("should accept valid time formats", () => { + expect(hearingTimeField?.validators).toBeDefined(); + expect(() => hearingTimeField?.validators?.[0]("9:30am", 1)).not.toThrow(); + expect(() => hearingTimeField?.validators?.[0]("10:15pm", 1)).not.toThrow(); + }); + + it("should reject invalid time formats", () => { + expect(() => hearingTimeField?.validators?.[0]("invalid", 1)).toThrow("Invalid time format"); + }); + }); + + describe("HTML tag validation on text fields", () => { + const textFields = ["caseTitle", "representative", "caseReferenceNumber", "judges", "hearingType", "location", "additionalInformation"]; + + for (const fieldName of textFields) { + it(`should reject HTML tags in ${fieldName}`, () => { + const field = UTIAC_JR_LONDON_EXCEL_CONFIG.fields.find((f) => f.fieldName === fieldName); + expect(field).toBeDefined(); + expect(() => field?.validators?.[0]("", 1)).toThrow(); + expect(() => field?.validators?.[0]("Valid value", 1)).not.toThrow(); + }); + } + }); +}); diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/conversion/utiac-jr-config.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/conversion/utiac-jr-config.ts new file mode 100644 index 000000000..4304068b2 --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/conversion/utiac-jr-config.ts @@ -0,0 +1,124 @@ +import { + createConverter, + type ExcelConverterConfig, + registerConverter, + registerConverterByName, + validateNoHtmlTags, + validateTimeFormatSimple +} from "@hmcts/list-types-common"; + +export const UTIAC_JR_REGIONAL_EXCEL_CONFIG: ExcelConverterConfig = { + fields: [ + { + header: "Venue", + fieldName: "venue", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Venue", rowNumber)] + }, + { + header: "Judge(s)", + fieldName: "judges", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Judge(s)", rowNumber)] + }, + { + header: "Hearing time", + fieldName: "hearingTime", + required: true, + validators: [validateTimeFormatSimple] + }, + { + header: "Case reference number", + fieldName: "caseReferenceNumber", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case reference number", rowNumber)] + }, + { + header: "Case title", + fieldName: "caseTitle", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case title", rowNumber)] + }, + { + header: "Hearing type", + fieldName: "hearingType", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Hearing type", rowNumber)] + }, + { + header: "Additional information", + fieldName: "additionalInformation", + required: false, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Additional information", rowNumber)] + } + ], + minRows: 1 +}; + +export const UTIAC_JR_LONDON_EXCEL_CONFIG: ExcelConverterConfig = { + fields: [ + { + header: "Hearing time", + fieldName: "hearingTime", + required: true, + validators: [validateTimeFormatSimple] + }, + { + header: "Case title", + fieldName: "caseTitle", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case title", rowNumber)] + }, + { + header: "Representative", + fieldName: "representative", + required: false, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Representative", rowNumber)] + }, + { + header: "Case reference number", + fieldName: "caseReferenceNumber", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case reference number", rowNumber)] + }, + { + header: "Judge(s)", + fieldName: "judges", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Judge(s)", rowNumber)] + }, + { + header: "Hearing type", + fieldName: "hearingType", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Hearing type", rowNumber)] + }, + { + header: "Location", + fieldName: "location", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Location", rowNumber)] + }, + { + header: "Additional information", + fieldName: "additionalInformation", + required: false, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Additional information", rowNumber)] + } + ], + minRows: 1 +}; + +const utiacJrRegionalConverter = createConverter(UTIAC_JR_REGIONAL_EXCEL_CONFIG); +registerConverter(32, utiacJrRegionalConverter); +registerConverterByName("UTIAC_JR_LEEDS_DAILY_HEARING_LIST", utiacJrRegionalConverter); +registerConverter(33, utiacJrRegionalConverter); +registerConverterByName("UTIAC_JR_MANCHESTER_DAILY_HEARING_LIST", utiacJrRegionalConverter); +registerConverter(34, utiacJrRegionalConverter); +registerConverterByName("UTIAC_JR_BIRMINGHAM_DAILY_HEARING_LIST", utiacJrRegionalConverter); +registerConverter(35, utiacJrRegionalConverter); +registerConverterByName("UTIAC_JR_CARDIFF_DAILY_HEARING_LIST", utiacJrRegionalConverter); + +const utiacJrLondonConverter = createConverter(UTIAC_JR_LONDON_EXCEL_CONFIG); +registerConverter(31, utiacJrLondonConverter); +registerConverterByName("UTIAC_JR_LONDON_DAILY_HEARING_LIST", utiacJrLondonConverter); diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/email-summary/summary-builder.test.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/email-summary/summary-builder.test.ts new file mode 100644 index 000000000..f8261868b --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/email-summary/summary-builder.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "vitest"; +import type { UtiacJrLeedsHearingList, UtiacJrLondonHearingList } from "../models/types.js"; +import { extractCaseSummary, extractLondonCaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "./summary-builder.js"; + +describe("SPECIAL_CATEGORY_DATA_WARNING", () => { + it("should contain required warning text", () => { + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Special Category Data"); + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Data Protection Act 2018"); + }); +}); + +describe("extractCaseSummary", () => { + it("should extract case summaries", () => { + // Arrange + const hearingList: UtiacJrLeedsHearingList = [ + { + venue: "Leeds Combined Court Centre", + judges: "Judge Smith", + hearingTime: "10:00am", + caseReferenceNumber: "JR/2025/003", + caseTitle: "Smith v Secretary of State", + hearingType: "Permission", + additionalInformation: "" + }, + { + venue: "Leeds Combined Court Centre", + judges: "Judge Brown", + hearingTime: "2:00pm", + caseReferenceNumber: "JR/2025/004", + caseTitle: "Brown v Home Office", + hearingType: "Full hearing", + additionalInformation: "" + } + ]; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(2); + expect(result[0]).toEqual([ + { label: "Hearing time", value: "10:00am" }, + { label: "Case reference number", value: "JR/2025/003" } + ]); + expect(result[1]).toEqual([ + { label: "Hearing time", value: "2:00pm" }, + { label: "Case reference number", value: "JR/2025/004" } + ]); + }); + + it("should handle empty hearing list", () => { + // Arrange + const hearingList: UtiacJrLeedsHearingList = []; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(0); + }); + + it("should handle missing fields with empty string", () => { + // Arrange + const hearingList: UtiacJrLeedsHearingList = [ + { + venue: "", + judges: "", + hearingTime: "", + caseReferenceNumber: "", + caseTitle: "", + hearingType: "", + additionalInformation: "" + } + ]; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(1); + expect(result[0]).toEqual([ + { label: "Hearing time", value: "" }, + { label: "Case reference number", value: "" } + ]); + }); +}); + +describe("extractLondonCaseSummary", () => { + it("should extract hearing time and case reference number from each hearing", () => { + // Arrange + const hearingList: UtiacJrLondonHearingList = [ + { + hearingTime: "10:00am", + caseTitle: "Smith v Secretary of State", + representative: "", + caseReferenceNumber: "JR/2026/001", + judges: "Judge Smith", + hearingType: "Permission", + location: "Field House", + additionalInformation: "" + }, + { + hearingTime: "2:00pm", + caseTitle: "Jones v Home Office", + representative: "Mr Jones", + caseReferenceNumber: "JR/2026/002", + judges: "Judge Brown", + hearingType: "Full hearing", + location: "Field House", + additionalInformation: "Remote" + } + ]; + + // Act + const result = extractLondonCaseSummary(hearingList); + + // Assert + expect(result).toEqual([ + [ + { label: "Hearing time", value: "10:00am" }, + { label: "Case reference number", value: "JR/2026/001" } + ], + [ + { label: "Hearing time", value: "2:00pm" }, + { label: "Case reference number", value: "JR/2026/002" } + ] + ]); + }); + + it("should return empty array for empty hearing list", () => { + // Act + const result = extractLondonCaseSummary([]); + + // Assert + expect(result).toEqual([]); + }); + + it("should handle empty string fields gracefully", () => { + // Arrange + const hearingList: UtiacJrLondonHearingList = [ + { + hearingTime: "", + caseTitle: "Smith v Secretary of State", + representative: "", + caseReferenceNumber: "", + judges: "Judge Smith", + hearingType: "Permission", + location: "Field House", + additionalInformation: "" + } + ]; + + // Act + const result = extractLondonCaseSummary(hearingList); + + // Assert + expect(result).toEqual([ + [ + { label: "Hearing time", value: "" }, + { label: "Case reference number", value: "" } + ] + ]); + }); +}); + +describe("formatCaseSummaryForEmail", () => { + it("should format single case summary correctly", () => { + // Arrange + const items = [ + [ + { label: "Hearing time", value: "10:00am" }, + { label: "Case reference number", value: "JR/2025/003" } + ] + ]; + + // Act + const result = formatCaseSummaryForEmail(items); + + // Assert + expect(result).toContain("---"); + expect(result).toContain("Hearing time - 10:00am"); + expect(result).toContain("Case reference number - JR/2025/003"); + }); + + it("should handle empty case list", () => { + // Act + const result = formatCaseSummaryForEmail([]); + + // Assert + expect(result).toBe("No cases scheduled."); + }); +}); diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/email-summary/summary-builder.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/email-summary/summary-builder.ts new file mode 100644 index 000000000..74e317a60 --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/email-summary/summary-builder.ts @@ -0,0 +1,12 @@ +import { type CaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "@hmcts/list-types-common"; + +export { formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING }; + +export function extractCaseSummary(jsonData: Array<{ hearingTime: string; caseReferenceNumber: string }>): CaseSummary[] { + return jsonData.map((hearing) => [ + { label: "Hearing time", value: hearing.hearingTime || "" }, + { label: "Case reference number", value: hearing.caseReferenceNumber || "" } + ]); +} + +export const extractLondonCaseSummary = extractCaseSummary; diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/index.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/index.ts new file mode 100644 index 000000000..06ca4062a --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/index.ts @@ -0,0 +1,14 @@ +import "./conversion/utiac-jr-config.js"; // Register all UTIAC JR converters on module load + +// Business logic exports +export type { ValidationResult } from "@hmcts/list-types-common"; +export * from "./email-summary/summary-builder.js"; +export { cy as utiacJrDailyHearingListCy, londonTableHeadersCy, pageTitleByListTypeCy } from "./locales/cy.js"; +export { en as utiacJrDailyHearingListEn, londonTableHeaders, pageTitleByListType } from "./locales/en.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export { generateUtiacJrLondonDailyHearingListPdf } from "./pdf/pdf-generator-london.js"; +export * from "./rendering/renderer.js"; +export { renderUtiacJrLondonDailyHearingListData } from "./rendering/renderer-london.js"; +export { validateUtiacJrAnyDailyHearingList, validateUtiacJrDailyHearingList } from "./validation/json-validator.js"; +export { validateUtiacJrLondonDailyHearingList } from "./validation/json-validator-london.js"; diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/locales/cy.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/locales/cy.ts new file mode 100644 index 000000000..dda390a64 --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/locales/cy.ts @@ -0,0 +1,57 @@ +import { provenanceLabelsCy as provenanceLabels } from "@hmcts/list-types-common"; + +export const pageTitleByListTypeCy: Record = { + UTIAC_JR_LEEDS_DAILY_HEARING_LIST: + "[WELSH TRANSLATION REQUIRED: 'Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Leeds Daily Hearing List']", + UTIAC_JR_BIRMINGHAM_DAILY_HEARING_LIST: + "[WELSH TRANSLATION REQUIRED: 'Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Birmingham Daily Hearing List']", + UTIAC_JR_CARDIFF_DAILY_HEARING_LIST: + "[WELSH TRANSLATION REQUIRED: 'Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Cardiff Daily Hearing List']", + UTIAC_JR_MANCHESTER_DAILY_HEARING_LIST: + "[WELSH TRANSLATION REQUIRED: 'Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Manchester Daily Hearing List']", + UTIAC_JR_LONDON_DAILY_HEARING_LIST: + "[WELSH TRANSLATION REQUIRED: 'Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: London Daily Hearing List']" +}; + +export const londonTableHeadersCy = { + hearingTime: "[WELSH TRANSLATION REQUIRED: 'Hearing time']", + caseTitle: "[WELSH TRANSLATION REQUIRED: 'Case title']", + representative: "[WELSH TRANSLATION REQUIRED: 'Representative']", + caseReferenceNumber: "[WELSH TRANSLATION REQUIRED: 'Case reference number']", + judges: "[WELSH TRANSLATION REQUIRED: 'Judge(s)']", + hearingType: "[WELSH TRANSLATION REQUIRED: 'Hearing type']", + location: "[WELSH TRANSLATION REQUIRED: 'Location']", + additionalInformation: "Gwybodaeth ychwanegol" +}; + +export const cy = { + listForDate: "[WELSH TRANSLATION REQUIRED: 'List for']", + lastUpdated: "Diweddarwyd ddiwethaf", + at: "am", + factLinkText: "Dod o hyd i fanylion cyswllt a gwybodaeth arall am lysoedd a thribiwnlysoedd yng Nghymru a Lloegr", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "a rhai tribiwnlysoedd heb eu datganoli yn yr Alban.", + importantInformationTitle: "Gwybodaeth bwysig", + importantInformationText: + "[WELSH TRANSLATION REQUIRED: 'The following list is subject to change until 4:30pm. Any alterations after this time will be telephoned or emailed direct to the parties or their legal representatives.']", + importantInformationLinkText: "Arsylwi gwrandawiad llys neu dribiwnlys fel newyddiadurwr, ymchwilydd neu aelod o'r cyhoedd", + importantInformationLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + searchCasesTitle: "Chwilio Achosion", + searchCasesLabel: "[WELSH TRANSLATION REQUIRED: 'Search by case reference number, case title, judge, venue, or other details']", + tableHeaders: { + venue: "[WELSH TRANSLATION REQUIRED: 'Venue']", + judges: "[WELSH TRANSLATION REQUIRED: 'Judge(s)']", + hearingTime: "[WELSH TRANSLATION REQUIRED: 'Hearing time']", + caseReferenceNumber: "[WELSH TRANSLATION REQUIRED: 'Case reference number']", + caseTitle: "[WELSH TRANSLATION REQUIRED: 'Case title']", + hearingType: "[WELSH TRANSLATION REQUIRED: 'Hearing type']", + additionalInformation: "Gwybodaeth ychwanegol" + }, + dataSource: "Ffynhonnell data", + backToTop: "Yn ôl i frig y dudalen", + cautionNote: + "Noder bod y ddogfen hon yn cynnwys Data Categori Arbennig fel y'i diffinnir yn Neddf Gwarchod Data 2018, a elwid gynt yn Ddata Personol Sensitif, a dylid ei drin yn y ffordd briodol.", + cautionReporting: + "Mae'r ddogfen hon yn cynnwys gwybodaeth a fwriedir i gynorthwyo i roi adroddiad manwl-gywir am achosion llys. Mae'n hanfodol eich bod yn sicrhau eich bod yn gwarchod y Data Categori Arbennig sydd ynddi ac yn cadw at gyfyngiadau adrodd (er enghraifft yn achos dioddefwyr a phlant). Bydd GLlTEF yn rhoi'r gorau i anfon y data os cyfyd pryder ynghylch sut y'i defnyddir.", + provenanceLabels +}; diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/locales/en.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/locales/en.ts new file mode 100644 index 000000000..bc022eaf5 --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/locales/en.ts @@ -0,0 +1,52 @@ +import { provenanceLabelsEn as provenanceLabels } from "@hmcts/list-types-common"; + +export const pageTitleByListType: Record = { + UTIAC_JR_LEEDS_DAILY_HEARING_LIST: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Leeds Daily Hearing List", + UTIAC_JR_BIRMINGHAM_DAILY_HEARING_LIST: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Birmingham Daily Hearing List", + UTIAC_JR_CARDIFF_DAILY_HEARING_LIST: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Cardiff Daily Hearing List", + UTIAC_JR_MANCHESTER_DAILY_HEARING_LIST: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Manchester Daily Hearing List", + UTIAC_JR_LONDON_DAILY_HEARING_LIST: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: London Daily Hearing List" +}; + +export const londonTableHeaders = { + hearingTime: "Hearing time", + caseTitle: "Case title", + representative: "Representative", + caseReferenceNumber: "Case reference number", + judges: "Judge(s)", + hearingType: "Hearing type", + location: "Location", + additionalInformation: "Additional information" +}; + +export const en = { + listForDate: "List for", + lastUpdated: "Last updated", + at: "at", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + importantInformationTitle: "Important information", + importantInformationText: + "The following list is subject to change until 4:30pm. Any alterations after this time will be telephoned or emailed direct to the parties or their legal representatives.", + importantInformationLinkText: "Observe a court or tribunal hearing as a journalist, researcher or member of the public", + importantInformationLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + searchCasesTitle: "Search Cases", + searchCasesLabel: "Search by case reference number, case title, judge, venue, or other details", + tableHeaders: { + venue: "Venue", + judges: "Judge(s)", + hearingTime: "Hearing time", + caseReferenceNumber: "Case reference number", + caseTitle: "Case title", + hearingType: "Hearing type", + additionalInformation: "Additional information" + }, + dataSource: "Data source", + backToTop: "Back to top", + cautionNote: + "Note this document contains Special Category Data as defined by Data Protection Act 2018, formally known as Sensitive Personal Data, and should be handled appropriately.", + cautionReporting: + "This document contains information intended to assist the accurate reporting of court proceedings. It is vital you ensure that you safeguard the Special Category Data included and abide by reporting restrictions (for example on victims and children). HMCTS will stop sending the data if there is concern about how it will be used.", + provenanceLabels +}; diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/models/types.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/models/types.ts new file mode 100644 index 000000000..f21519768 --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/models/types.ts @@ -0,0 +1,30 @@ +export interface UtiacJrLeedsHearing { + venue: string; + judges: string; + hearingTime: string; + caseReferenceNumber: string; + caseTitle: string; + hearingType: string; + additionalInformation: string; +} + +export type UtiacJrLeedsHearingList = UtiacJrLeedsHearing[]; + +export type UtiacJrHearing = UtiacJrLeedsHearing; +export type UtiacJrHearingList = UtiacJrLeedsHearingList; +export type UtiacJrBirminghamHearingList = UtiacJrHearingList; +export type UtiacJrCardiffHearingList = UtiacJrHearingList; +export type UtiacJrManchesterHearingList = UtiacJrHearingList; + +export interface UtiacJrLondonHearing { + hearingTime: string; + caseTitle: string; + representative: string; + caseReferenceNumber: string; + judges: string; + hearingType: string; + location: string; + additionalInformation: string; +} + +export type UtiacJrLondonHearingList = UtiacJrLondonHearing[]; diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/pdf/pdf-generator-london.test.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/pdf/pdf-generator-london.test.ts new file mode 100644 index 000000000..25bfb8775 --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/pdf/pdf-generator-london.test.ts @@ -0,0 +1,231 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockSavePdfToStorage, mockCreatePdfErrorResult, mockConfigureNunjucks, mockLoadTranslations } = vi.hoisted(() => ({ + mockSavePdfToStorage: vi.fn(), + mockCreatePdfErrorResult: vi.fn(), + mockConfigureNunjucks: vi.fn(), + mockLoadTranslations: vi.fn() +})); + +vi.mock("@hmcts/list-types-common", () => ({ + savePdfToStorage: mockSavePdfToStorage, + createPdfErrorResult: mockCreatePdfErrorResult, + configureNunjucks: mockConfigureNunjucks, + loadTranslations: mockLoadTranslations, + PDF_BASE_STYLES: "/* base styles */", + provenanceLabelsEn: { + MANUAL_UPLOAD: "Manual Upload", + SNL: "SNL" + }, + provenanceLabelsCy: { + MANUAL_UPLOAD: "Lanlwytho â Llaw", + SNL: "SNL" + } +})); + +vi.mock("@hmcts/pdf-generation", () => ({ + generatePdfFromHtml: vi.fn() +})); + +vi.mock("../rendering/renderer-london.js", () => ({ + renderUtiacJrLondonDailyHearingListData: vi.fn() +})); + +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { renderUtiacJrLondonDailyHearingListData } from "../rendering/renderer-london.js"; +import { generateUtiacJrLondonDailyHearingListPdf } from "./pdf-generator-london.js"; + +const mockRenderedData = { + header: { + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: London Daily Hearing List", + listForDate: "15 January 2025", + lastUpdatedDate: "14 January 2025", + lastUpdatedTime: "9am" + }, + hearings: [] +}; + +const mockHearingList = [ + { + hearingTime: "10:00am", + caseTitle: "Smith v Secretary of State", + representative: "", + caseReferenceNumber: "JR/2025/001", + judges: "Judge Smith", + hearingType: "Permission", + location: "Field House", + additionalInformation: "" + } +]; + +describe("generateUtiacJrLondonDailyHearingListPdf", () => { + const mockNunjucksEnv = { + render: vi.fn().mockReturnValue("PDF HTML") + }; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(renderUtiacJrLondonDailyHearingListData).mockReturnValue(mockRenderedData); + mockConfigureNunjucks.mockReturnValue(mockNunjucksEnv); + mockLoadTranslations.mockResolvedValue({ pageTitle: "Test Title" }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "default.pdf", + sizeBytes: 100, + exceedsMaxSize: false + }); + mockCreatePdfErrorResult.mockImplementation((error: unknown) => ({ + success: false, + error: `Failed to generate PDF: ${error instanceof Error ? error.message : "Unknown error"}` + })); + }); + + it("should generate PDF successfully", async () => { + // Arrange + const pdfBuffer = Buffer.from("PDF content"); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer, + sizeBytes: 1024 + }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "test-artefact-123.pdf", + sizeBytes: 1024, + exceedsMaxSize: false + }); + + // Act + const result = await generateUtiacJrLondonDailyHearingListPdf({ + artefactId: "test-artefact-123", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(true); + expect(result.pdfPath).toContain("test-artefact-123.pdf"); + expect(result.sizeBytes).toBe(1024); + expect(result.exceedsMaxSize).toBe(false); + }); + + it("should return error when PDF generation fails", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: false, + error: "Puppeteer crashed" + }); + + // Act + const result = await generateUtiacJrLondonDailyHearingListPdf({ + artefactId: "failed-pdf", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("Puppeteer crashed"); + }); + + it("should pass correct render options to renderer", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + const contentDate = new Date("2025-06-15"); + + // Act + await generateUtiacJrLondonDailyHearingListPdf({ + artefactId: "test-render-options", + contentDate, + locale: "cy", + locationId: "999", + jsonData: mockHearingList + }); + + // Assert + expect(renderUtiacJrLondonDailyHearingListData).toHaveBeenCalledWith(mockHearingList, { + locale: "cy", + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate, + lastReceivedDate: expect.any(String), + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: London Daily Hearing List" + }); + }); + + it("should return error when PDF buffer is missing", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ success: true, pdfBuffer: undefined, sizeBytes: 0 }); + + // Act + const result = await generateUtiacJrLondonDailyHearingListPdf({ + artefactId: "no-buffer", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("PDF generation failed"); + }); + + it("should handle renderer errors gracefully", async () => { + // Arrange + vi.mocked(renderUtiacJrLondonDailyHearingListData).mockImplementation(() => { + throw new Error("Renderer failed"); + }); + + // Act + const result = await generateUtiacJrLondonDailyHearingListPdf({ + artefactId: "renderer-error", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("Failed to generate PDF: Renderer failed"); + }); + + it("should use provenance label when provenance is provided", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "provenance-test.pdf", + sizeBytes: 100, + exceedsMaxSize: false + }); + + // Act + const result = await generateUtiacJrLondonDailyHearingListPdf({ + artefactId: "provenance-test", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList, + provenance: "MANUAL_UPLOAD" + }); + + // Assert + expect(result.success).toBe(true); + expect(mockNunjucksEnv.render).toHaveBeenCalledWith("pdf-template-london.njk", expect.objectContaining({ dataSource: "Manual Upload" })); + }); +}); diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/pdf/pdf-generator-london.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/pdf/pdf-generator-london.ts new file mode 100644 index 000000000..76383c704 --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/pdf/pdf-generator-london.ts @@ -0,0 +1,66 @@ +import { + type BasePdfGenerationOptions, + configureNunjucks, + createPdfErrorResult, + loadTranslations, + PDF_BASE_STYLES, + type PdfGenerationResult, + provenanceLabelsEn as PROVENANCE_LABELS, + savePdfToStorage +} from "@hmcts/list-types-common"; +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { pdfTemplateDir } from "../config.js"; +import { londonTableHeadersCy } from "../locales/cy.js"; +import { londonTableHeaders } from "../locales/en.js"; +import type { UtiacJrLondonHearingList } from "../models/types.js"; +import { renderUtiacJrLondonDailyHearingListData } from "../rendering/renderer-london.js"; + +interface PdfGenerationOptions extends BasePdfGenerationOptions { + contentDate: Date; +} + +export async function generateUtiacJrLondonDailyHearingListPdf(options: PdfGenerationOptions): Promise { + try { + const renderedData = renderUtiacJrLondonDailyHearingListData(options.jsonData, { + locale: options.locale, + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: options.contentDate, + lastReceivedDate: new Date().toISOString(), + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: London Daily Hearing List" + }); + + const baseTranslations = await loadTranslations( + options.locale, + () => import("../locales/en.js"), + () => import("../locales/cy.js") + ); + const translations = { + ...baseTranslations, + tableHeaders: options.locale === "cy" ? londonTableHeadersCy : londonTableHeaders + }; + + const provenanceLabel = options.provenance ? PROVENANCE_LABELS[options.provenance as keyof typeof PROVENANCE_LABELS] || options.provenance : ""; + + const env = configureNunjucks(pdfTemplateDir); + const html = env.render("pdf-template-london.njk", { + header: renderedData.header, + hearings: renderedData.hearings, + dataSource: provenanceLabel, + t: translations, + pdfStyles: PDF_BASE_STYLES + }); + + const pdfResult = await generatePdfFromHtml(html); + + if (!pdfResult.success || !pdfResult.pdfBuffer) { + return { + success: false, + error: pdfResult.error || "PDF generation failed" + }; + } + + return await savePdfToStorage(options.artefactId, pdfResult.pdfBuffer, pdfResult.sizeBytes!); + } catch (error) { + return createPdfErrorResult(error); + } +} diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/pdf/pdf-generator.test.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/pdf/pdf-generator.test.ts new file mode 100644 index 000000000..d2989b9a0 --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/pdf/pdf-generator.test.ts @@ -0,0 +1,274 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockSavePdfToStorage, mockCreatePdfErrorResult, mockConfigureNunjucks, mockLoadTranslations } = vi.hoisted(() => ({ + mockSavePdfToStorage: vi.fn(), + mockCreatePdfErrorResult: vi.fn(), + mockConfigureNunjucks: vi.fn(), + mockLoadTranslations: vi.fn() +})); + +vi.mock("@hmcts/list-types-common", () => ({ + savePdfToStorage: mockSavePdfToStorage, + createPdfErrorResult: mockCreatePdfErrorResult, + configureNunjucks: mockConfigureNunjucks, + loadTranslations: mockLoadTranslations, + PDF_BASE_STYLES: "/* base styles */", + provenanceLabelsEn: { + MANUAL_UPLOAD: "Manual Upload", + SNL: "SNL" + } +})); + +vi.mock("@hmcts/pdf-generation", () => ({ + generatePdfFromHtml: vi.fn() +})); + +vi.mock("../rendering/renderer.js", () => ({ + renderUtiacJrLeedsDailyHearingListData: vi.fn() +})); + +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { renderUtiacJrLeedsDailyHearingListData } from "../rendering/renderer.js"; +import { createUtiacJrDailyHearingListPdfGenerator, generateUtiacJrLeedsDailyHearingListPdf } from "./pdf-generator.js"; + +const mockRenderedData = { + header: { + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Leeds Daily Hearing List", + listForDate: "15 January 2025", + lastUpdatedDate: "14 January 2025", + lastUpdatedTime: "9am" + }, + hearings: [] +}; + +const mockHearingList = [ + { + venue: "Leeds Combined Court Centre", + judges: "Judge Smith", + hearingTime: "10:00am", + caseReferenceNumber: "JR/2025/003", + caseTitle: "Smith v Secretary of State", + hearingType: "Permission", + additionalInformation: "" + } +]; + +describe("generateUtiacJrLeedsDailyHearingListPdf", () => { + const mockNunjucksEnv = { + render: vi.fn().mockReturnValue("PDF HTML") + }; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(renderUtiacJrLeedsDailyHearingListData).mockReturnValue(mockRenderedData); + mockConfigureNunjucks.mockReturnValue(mockNunjucksEnv); + mockLoadTranslations.mockResolvedValue({ pageTitle: "Test Title" }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "default.pdf", + sizeBytes: 100, + exceedsMaxSize: false + }); + mockCreatePdfErrorResult.mockImplementation((error: unknown) => ({ + success: false, + error: `Failed to generate PDF: ${error instanceof Error ? error.message : "Unknown error"}` + })); + }); + + it("should generate PDF successfully", async () => { + // Arrange + const pdfBuffer = Buffer.from("PDF content"); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer, + sizeBytes: 1024 + }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "test-artefact-123.pdf", + sizeBytes: 1024, + exceedsMaxSize: false + }); + + // Act + const result = await generateUtiacJrLeedsDailyHearingListPdf({ + artefactId: "test-artefact-123", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(true); + expect(result.pdfPath).toContain("test-artefact-123.pdf"); + expect(result.sizeBytes).toBe(1024); + expect(result.exceedsMaxSize).toBe(false); + }); + + it("should return error when PDF generation fails", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: false, + error: "Puppeteer crashed" + }); + + // Act + const result = await generateUtiacJrLeedsDailyHearingListPdf({ + artefactId: "failed-pdf", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("Puppeteer crashed"); + }); + + it("should pass correct render options to renderer", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + const contentDate = new Date("2025-06-15"); + + // Act + await generateUtiacJrLeedsDailyHearingListPdf({ + artefactId: "test-render-options", + contentDate, + locale: "cy", + locationId: "999", + jsonData: mockHearingList + }); + + // Assert + expect(renderUtiacJrLeedsDailyHearingListData).toHaveBeenCalledWith(mockHearingList, { + locale: "cy", + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate, + lastReceivedDate: expect.any(String), + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Leeds Daily Hearing List" + }); + }); + + it("should include provenance label in rendered output when provenance is provided", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + + // Act + const result = await generateUtiacJrLeedsDailyHearingListPdf({ + artefactId: "provenance-test", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList, + provenance: "MANUAL_UPLOAD" + }); + + // Assert + expect(result.success).toBe(true); + }); + + it("should return error when PDF buffer is missing", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ success: true, pdfBuffer: undefined, sizeBytes: 0 }); + + // Act + const result = await generateUtiacJrLeedsDailyHearingListPdf({ + artefactId: "no-buffer", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("PDF generation failed"); + }); + + it("should handle renderer errors gracefully", async () => { + // Arrange + vi.mocked(renderUtiacJrLeedsDailyHearingListData).mockImplementation(() => { + throw new Error("Renderer failed"); + }); + + // Act + const result = await generateUtiacJrLeedsDailyHearingListPdf({ + artefactId: "renderer-error", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("Failed to generate PDF: Renderer failed"); + }); +}); + +describe("createUtiacJrDailyHearingListPdfGenerator", () => { + const mockNunjucksEnv = { + render: vi.fn().mockReturnValue("PDF HTML") + }; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(renderUtiacJrLeedsDailyHearingListData).mockReturnValue(mockRenderedData); + mockConfigureNunjucks.mockReturnValue(mockNunjucksEnv); + mockLoadTranslations.mockResolvedValue({ pageTitle: "Test Title" }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "default.pdf", + sizeBytes: 100, + exceedsMaxSize: false + }); + mockCreatePdfErrorResult.mockImplementation((error: unknown) => ({ + success: false, + error: `Failed to generate PDF: ${error instanceof Error ? error.message : "Unknown error"}` + })); + }); + + it("should generate PDF with the provided list title", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF content"), + sizeBytes: 1024 + }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "custom-title.pdf", + sizeBytes: 1024, + exceedsMaxSize: false + }); + + const customTitle = "Custom UTIAC Hearing List"; + const generator = createUtiacJrDailyHearingListPdfGenerator(customTitle); + + // Act + const result = await generator({ + artefactId: "custom-title-test", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(true); + expect(renderUtiacJrLeedsDailyHearingListData).toHaveBeenCalledWith(mockHearingList, expect.objectContaining({ listTitle: customTitle })); + }); +}); diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/pdf/pdf-generator.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/pdf/pdf-generator.ts new file mode 100644 index 000000000..e07786529 --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/pdf/pdf-generator.ts @@ -0,0 +1,68 @@ +import { + type BasePdfGenerationOptions, + configureNunjucks, + createPdfErrorResult, + loadTranslations, + PDF_BASE_STYLES, + type PdfGenerationResult, + provenanceLabelsEn as PROVENANCE_LABELS, + savePdfToStorage +} from "@hmcts/list-types-common"; +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { pdfTemplateDir } from "../config.js"; +import type { UtiacJrLeedsHearingList } from "../models/types.js"; +import { renderUtiacJrLeedsDailyHearingListData } from "../rendering/renderer.js"; + +interface PdfGenerationOptions extends BasePdfGenerationOptions { + contentDate: Date; +} + +async function generatePdf(options: PdfGenerationOptions, listTitle: string): Promise { + try { + const renderedData = renderUtiacJrLeedsDailyHearingListData(options.jsonData, { + locale: options.locale, + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: options.contentDate, + lastReceivedDate: new Date().toISOString(), + listTitle + }); + + const translations = await loadTranslations( + options.locale, + () => import("../locales/en.js"), + () => import("../locales/cy.js") + ); + + const provenanceLabel = options.provenance ? PROVENANCE_LABELS[options.provenance as keyof typeof PROVENANCE_LABELS] || options.provenance : ""; + + const env = configureNunjucks(pdfTemplateDir); + const html = env.render("pdf-template.njk", { + header: renderedData.header, + hearings: renderedData.hearings, + dataSource: provenanceLabel, + t: translations, + pdfStyles: PDF_BASE_STYLES + }); + + const pdfResult = await generatePdfFromHtml(html); + + if (!pdfResult.success || !pdfResult.pdfBuffer) { + return { + success: false, + error: pdfResult.error || "PDF generation failed" + }; + } + + return await savePdfToStorage(options.artefactId, pdfResult.pdfBuffer, pdfResult.sizeBytes!); + } catch (error) { + return createPdfErrorResult(error); + } +} + +export async function generateUtiacJrLeedsDailyHearingListPdf(options: PdfGenerationOptions): Promise { + return generatePdf(options, "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Leeds Daily Hearing List"); +} + +export function createUtiacJrDailyHearingListPdfGenerator(listTitle: string) { + return (options: PdfGenerationOptions) => generatePdf(options, listTitle); +} diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/pdf/pdf-template-london.njk b/libs/list-types/utiac-jr-daily-hearing-list/src/pdf/pdf-template-london.njk new file mode 100644 index 000000000..0434b715f --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/pdf/pdf-template-london.njk @@ -0,0 +1,68 @@ + + + + + + {{ header.listTitle }} + + + +
+

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForDate }} {{ header.listForDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+
+ +
+

{{ t.importantInformationTitle }}

+

{{ t.importantInformationText }}

+

{{ t.importantInformationLinkText }}

+
+ + {% if hearings.length > 0 %} + + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.hearingTime }}{{ t.tableHeaders.caseTitle }}{{ t.tableHeaders.representative }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.judges }}{{ t.tableHeaders.hearingType }}{{ t.tableHeaders.location }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.hearingTime }}{{ hearing.caseTitle }}{{ hearing.representative }}{{ hearing.caseReferenceNumber }}{{ hearing.judges }}{{ hearing.hearingType }}{{ hearing.location }}{{ hearing.additionalInformation }}
+ {% else %} +

No hearings scheduled.

+ {% endif %} + + + + diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/pdf/pdf-template.njk b/libs/list-types/utiac-jr-daily-hearing-list/src/pdf/pdf-template.njk new file mode 100644 index 000000000..965a797ec --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/pdf/pdf-template.njk @@ -0,0 +1,66 @@ + + + + + + {{ header.listTitle }} + + + +
+

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForDate }} {{ header.listForDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+
+ +
+

{{ t.importantInformationTitle }}

+

{{ t.importantInformationText }}

+

{{ t.importantInformationLinkText }}

+
+ + {% if hearings.length > 0 %} + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.venue }}{{ t.tableHeaders.judges }}{{ t.tableHeaders.hearingTime }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.caseTitle }}{{ t.tableHeaders.hearingType }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.venue }}{{ hearing.judges }}{{ hearing.hearingTime }}{{ hearing.caseReferenceNumber }}{{ hearing.caseTitle }}{{ hearing.hearingType }}{{ hearing.additionalInformation }}
+ {% else %} +

No hearings scheduled.

+ {% endif %} + + + + diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/rendering/renderer-london.test.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/rendering/renderer-london.test.ts new file mode 100644 index 000000000..5264b2125 --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/rendering/renderer-london.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import type { UtiacJrLondonHearingList } from "../models/types.js"; +import { renderUtiacJrLondonDailyHearingListData } from "./renderer-london.js"; + +describe("renderUtiacJrLondonDailyHearingListData", () => { + it("should render hearing list with formatted display date", () => { + // Arrange + const hearingList: UtiacJrLondonHearingList = [ + { + hearingTime: "10:00am", + caseTitle: "Smith v Secretary of State", + representative: "Smith & Co", + caseReferenceNumber: "JR/2025/001", + judges: "Judge Smith", + hearingType: "Permission", + location: "Field House", + additionalInformation: "" + } + ]; + + const options = { + locale: "en", + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: new Date(2025, 0, 15), + lastReceivedDate: "2025-01-14T09:55:00Z", + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: London Daily Hearing List" + }; + + // Act + const result = renderUtiacJrLondonDailyHearingListData(hearingList, options); + + // Assert + expect(result.header.listTitle).toBe("Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: London Daily Hearing List"); + expect(result.header.listForDate).toBe("15 January 2025"); + expect(result.header.lastUpdatedDate).toBe("14 January 2025"); + expect(result.header.lastUpdatedTime).toContain("am"); + expect(result.hearings).toHaveLength(1); + expect(result.hearings[0].hearingTime).toBe("10:00am"); + expect(result.hearings[0].caseTitle).toBe("Smith v Secretary of State"); + expect(result.hearings[0].representative).toBe("Smith & Co"); + expect(result.hearings[0].caseReferenceNumber).toBe("JR/2025/001"); + expect(result.hearings[0].judges).toBe("Judge Smith"); + expect(result.hearings[0].hearingType).toBe("Permission"); + expect(result.hearings[0].location).toBe("Field House"); + expect(result.hearings[0].additionalInformation).toBe(""); + }); + + it("should render multiple hearings", () => { + // Arrange + const hearingList: UtiacJrLondonHearingList = [ + { + hearingTime: "10:00am", + caseTitle: "Smith v Secretary of State", + representative: "", + caseReferenceNumber: "JR/2025/001", + judges: "Judge Smith", + hearingType: "Permission", + location: "Field House", + additionalInformation: "" + }, + { + hearingTime: "2:00pm", + caseTitle: "Brown v Home Office", + representative: "Brown Solicitors", + caseReferenceNumber: "JR/2025/002", + judges: "Judge Brown", + hearingType: "Full hearing", + location: "Manchester", + additionalInformation: "Remote" + } + ]; + + const options = { + locale: "en", + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: new Date(2025, 0, 15), + lastReceivedDate: "2025-01-14T09:55:00Z", + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: London Daily Hearing List" + }; + + // Act + const result = renderUtiacJrLondonDailyHearingListData(hearingList, options); + + // Assert + expect(result.hearings).toHaveLength(2); + expect(result.hearings[0].caseTitle).toBe("Smith v Secretary of State"); + expect(result.hearings[1].caseTitle).toBe("Brown v Home Office"); + }); + + it("should handle empty hearing list", () => { + // Arrange + const hearingList: UtiacJrLondonHearingList = []; + + const options = { + locale: "en", + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: new Date(2025, 0, 15), + lastReceivedDate: "2025-01-14T09:55:00Z", + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: London Daily Hearing List" + }; + + // Act + const result = renderUtiacJrLondonDailyHearingListData(hearingList, options); + + // Assert + expect(result.hearings).toHaveLength(0); + expect(result.header.listTitle).toBe("Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: London Daily Hearing List"); + }); + + it("should format PM times correctly", () => { + // Arrange + const hearingList: UtiacJrLondonHearingList = [ + { + hearingTime: "10:00am", + caseTitle: "Smith v Secretary of State", + representative: "", + caseReferenceNumber: "JR/2025/001", + judges: "Judge Smith", + hearingType: "Permission", + location: "Field House", + additionalInformation: "" + } + ]; + + const options = { + locale: "en", + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: new Date(2025, 0, 15), + lastReceivedDate: "2025-01-14T14:30:00Z", + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: London Daily Hearing List" + }; + + // Act + const result = renderUtiacJrLondonDailyHearingListData(hearingList, options); + + // Assert + expect(result.header.lastUpdatedTime).toBe("2:30pm"); + }); +}); diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/rendering/renderer-london.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/rendering/renderer-london.ts new file mode 100644 index 000000000..b8eca853b --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/rendering/renderer-london.ts @@ -0,0 +1,29 @@ +import { formatDisplayDate, formatLastUpdatedDateTime } from "@hmcts/list-types-common"; +import type { UtiacJrLondonHearing, UtiacJrLondonHearingList } from "../models/types.js"; +import type { RenderedLondonData, RenderOptions } from "./renderer.js"; + +export function renderUtiacJrLondonDailyHearingListData(hearingList: UtiacJrLondonHearingList, options: RenderOptions): RenderedLondonData { + const listForDate = formatDisplayDate(options.contentDate, options.locale); + const { date: lastUpdatedDate, time: lastUpdatedTime } = formatLastUpdatedDateTime(options.lastReceivedDate, options.locale); + + const renderedHearings: UtiacJrLondonHearing[] = hearingList.map((hearing) => ({ + hearingTime: hearing.hearingTime, + caseTitle: hearing.caseTitle, + representative: hearing.representative, + caseReferenceNumber: hearing.caseReferenceNumber, + judges: hearing.judges, + hearingType: hearing.hearingType, + location: hearing.location, + additionalInformation: hearing.additionalInformation + })); + + return { + header: { + listTitle: options.listTitle, + listForDate, + lastUpdatedDate, + lastUpdatedTime + }, + hearings: renderedHearings + }; +} diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/rendering/renderer.test.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..9c86c95c8 --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/rendering/renderer.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import type { UtiacJrLeedsHearingList } from "../models/types.js"; +import { renderUtiacJrLeedsDailyHearingListData } from "./renderer.js"; + +describe("renderUtiacJrLeedsDailyHearingListData", () => { + it("should render hearing list with formatted display date", () => { + // Arrange + const hearingList: UtiacJrLeedsHearingList = [ + { + venue: "Leeds Combined Court Centre", + judges: "Judge Smith", + hearingTime: "10:00am", + caseReferenceNumber: "JR/2025/003", + caseTitle: "Smith v Secretary of State", + hearingType: "Permission", + additionalInformation: "" + } + ]; + + const options = { + locale: "en", + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: new Date(2025, 0, 15), + lastReceivedDate: "2025-01-14T09:55:00Z", + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Leeds Daily Hearing List" + }; + + // Act + const result = renderUtiacJrLeedsDailyHearingListData(hearingList, options); + + // Assert + expect(result.header.listTitle).toBe("Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Leeds Daily Hearing List"); + expect(result.header.listForDate).toBe("15 January 2025"); + expect(result.header.lastUpdatedDate).toBe("14 January 2025"); + expect(result.header.lastUpdatedTime).toContain("am"); + + expect(result.hearings).toHaveLength(1); + expect(result.hearings[0].venue).toBe("Leeds Combined Court Centre"); + expect(result.hearings[0].judges).toBe("Judge Smith"); + expect(result.hearings[0].hearingTime).toBe("10:00am"); + expect(result.hearings[0].caseReferenceNumber).toBe("JR/2025/003"); + expect(result.hearings[0].caseTitle).toBe("Smith v Secretary of State"); + expect(result.hearings[0].hearingType).toBe("Permission"); + expect(result.hearings[0].additionalInformation).toBe(""); + }); + + it("should render multiple hearings", () => { + // Arrange + const hearingList: UtiacJrLeedsHearingList = [ + { + venue: "Leeds Combined Court Centre", + judges: "Judge Smith", + hearingTime: "10:00am", + caseReferenceNumber: "JR/2025/003", + caseTitle: "Smith v Secretary of State", + hearingType: "Permission", + additionalInformation: "" + }, + { + venue: "Leeds Combined Court Centre", + judges: "Judge Brown", + hearingTime: "2:00pm", + caseReferenceNumber: "JR/2025/004", + caseTitle: "Brown v Home Office", + hearingType: "Full hearing", + additionalInformation: "Remote" + } + ]; + + const options = { + locale: "en", + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: new Date(2025, 0, 15), + lastReceivedDate: "2025-01-14T09:55:00Z", + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Leeds Daily Hearing List" + }; + + // Act + const result = renderUtiacJrLeedsDailyHearingListData(hearingList, options); + + // Assert + expect(result.hearings).toHaveLength(2); + expect(result.hearings[0].caseTitle).toBe("Smith v Secretary of State"); + expect(result.hearings[1].caseTitle).toBe("Brown v Home Office"); + }); + + it("should handle empty hearing list", () => { + // Arrange + const hearingList: UtiacJrLeedsHearingList = []; + + const options = { + locale: "en", + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: new Date(2025, 0, 15), + lastReceivedDate: "2025-01-14T09:55:00Z", + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Leeds Daily Hearing List" + }; + + // Act + const result = renderUtiacJrLeedsDailyHearingListData(hearingList, options); + + // Assert + expect(result.hearings).toHaveLength(0); + expect(result.header.listTitle).toBe("Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Leeds Daily Hearing List"); + }); + + it("should format PM times correctly", () => { + // Arrange + const hearingList: UtiacJrLeedsHearingList = [ + { + venue: "Leeds Combined Court Centre", + judges: "Judge Smith", + hearingTime: "10:00am", + caseReferenceNumber: "JR/2025/003", + caseTitle: "Smith v Secretary of State", + hearingType: "Permission", + additionalInformation: "" + } + ]; + + const options = { + locale: "en", + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: new Date(2025, 0, 15), + lastReceivedDate: "2025-01-14T14:30:00Z", + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber - Judicial Review: Leeds Daily Hearing List" + }; + + // Act + const result = renderUtiacJrLeedsDailyHearingListData(hearingList, options); + + // Assert + expect(result.header.lastUpdatedTime).toBe("2:30pm"); + }); +}); diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/rendering/renderer.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/rendering/renderer.ts new file mode 100644 index 000000000..a992d0cd8 --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/rendering/renderer.ts @@ -0,0 +1,58 @@ +import { formatDisplayDate, formatLastUpdatedDateTime } from "@hmcts/list-types-common"; +import type { UtiacJrLeedsHearing, UtiacJrLeedsHearingList, UtiacJrLondonHearing } from "../models/types.js"; + +export interface RenderOptions { + locale: string; + courtName: string; + contentDate: Date; + lastReceivedDate: string; + listTitle: string; +} + +export interface RenderedData { + header: { + listTitle: string; + listForDate: string; + lastUpdatedDate: string; + lastUpdatedTime: string; + }; + hearings: UtiacJrLeedsHearing[]; +} + +export interface RenderedLondonData { + header: { + listTitle: string; + listForDate: string; + lastUpdatedDate: string; + lastUpdatedTime: string; + }; + hearings: UtiacJrLondonHearing[]; +} + +export function renderUtiacJrLeedsDailyHearingListData(hearingList: UtiacJrLeedsHearingList, options: RenderOptions): RenderedData { + const listForDate = formatDisplayDate(options.contentDate, options.locale); + const { date: lastUpdatedDate, time: lastUpdatedTime } = formatLastUpdatedDateTime(options.lastReceivedDate, options.locale); + + const renderedHearings = hearingList.map((hearing) => ({ + venue: hearing.venue, + judges: hearing.judges, + hearingTime: hearing.hearingTime, + caseReferenceNumber: hearing.caseReferenceNumber, + caseTitle: hearing.caseTitle, + hearingType: hearing.hearingType, + additionalInformation: hearing.additionalInformation + })); + + return { + header: { + listTitle: options.listTitle, + listForDate, + lastUpdatedDate, + lastUpdatedTime + }, + hearings: renderedHearings + }; +} + +// Shared alias used by Manchester, Birmingham, and Cardiff variants +export const renderUtiacJrDailyHearingListData = renderUtiacJrLeedsDailyHearingListData; diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/schemas/utiac-jr-daily-hearing-list.json b/libs/list-types/utiac-jr-daily-hearing-list/src/schemas/utiac-jr-daily-hearing-list.json new file mode 100644 index 000000000..838c66032 --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/schemas/utiac-jr-daily-hearing-list.json @@ -0,0 +1,61 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Root", + "type": "array", + "items": { + "title": "Hearing list", + "type": "object", + "required": ["venue", "judges", "hearingTime", "caseReferenceNumber", "caseTitle", "hearingType", "additionalInformation"], + "properties": { + "venue": { + "title": "Venue", + "type": "string", + "default": "", + "examples": ["Venue 1"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "judges": { + "title": "Judges", + "type": "string", + "default": "", + "examples": ["Judge A"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "hearingTime": { + "title": "Time of hearing", + "type": "string", + "default": "", + "examples": ["10:30am"], + "pattern": "^\\d{1,2}([:.]\\d{2})?[ap]m\\s*$" + }, + "caseReferenceNumber": { + "title": "Case reference number", + "type": "string", + "default": "", + "examples": ["12345"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "caseTitle": { + "title": "Case title", + "type": "string", + "default": "", + "examples": ["Case ABC"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "hearingType": { + "title": "Type of hearing being presented", + "type": "string", + "default": "", + "examples": ["Substantive"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "additionalInformation": { + "title": "Additional information", + "type": "string", + "default": "", + "examples": ["This is additional information"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + } + } +} diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/schemas/utiac-jr-london-daily-hearing-list.json b/libs/list-types/utiac-jr-daily-hearing-list/src/schemas/utiac-jr-london-daily-hearing-list.json new file mode 100644 index 000000000..b771ec143 --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/schemas/utiac-jr-london-daily-hearing-list.json @@ -0,0 +1,68 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Root", + "type": "array", + "items": { + "title": "Hearing list", + "type": "object", + "required": ["hearingTime", "caseTitle", "representative", "caseReferenceNumber", "judges", "hearingType", "location", "additionalInformation"], + "properties": { + "hearingTime": { + "title": "Time of hearing", + "type": "string", + "default": "", + "examples": ["10:30am"], + "pattern": "^\\d{1,2}([:.]\\d{2})?[ap]m\\s*$" + }, + "caseTitle": { + "title": "Case Title", + "type": "string", + "default": "", + "examples": ["Case A"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "representative": { + "title": "Name of representative", + "type": "string", + "default": "", + "examples": ["Forename Surname"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "caseReferenceNumber": { + "title": "Case reference number", + "type": "string", + "default": "", + "examples": ["12345"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "judges": { + "title": "Judges", + "type": "string", + "default": "", + "examples": ["Judge A"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "hearingType": { + "title": "Type of hearing being presented", + "type": "string", + "default": "", + "examples": ["Substantive"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "location": { + "title": "location name of the hearing", + "type": "string", + "default": "", + "examples": ["This is the location of the hearing"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "additionalInformation": { + "title": "Additional information", + "type": "string", + "default": "", + "examples": ["This is additional information"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + } + } +} diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/utiac-jr-daily-hearing-list.njk b/libs/list-types/utiac-jr-daily-hearing-list/src/utiac-jr-daily-hearing-list.njk new file mode 100644 index 000000000..171dca40b --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/utiac-jr-daily-hearing-list.njk @@ -0,0 +1,77 @@ +{% extends "layouts/base-template.njk" %} + +{% block page_content %} +
+
+ +

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForDate }} {{ header.listForDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+ +
+ + + {{ t.importantInformationTitle }} + + +
+

{{ t.importantInformationText }}

+

+ + {{ t.importantInformationLinkText }} + +

+
+
+ +
+

{{ t.searchCasesTitle }}

+ + +
+ +
+ + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.venue }}{{ t.tableHeaders.judges }}{{ t.tableHeaders.hearingTime }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.caseTitle }}{{ t.tableHeaders.hearingType }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.venue }}{{ hearing.judges }}{{ hearing.hearingTime }}{{ hearing.caseReferenceNumber }}{{ hearing.caseTitle }}{{ hearing.hearingType }}{{ hearing.additionalInformation }}
+
+ +

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

+ + + +
+
+{% endblock %} diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/validation/json-validator-london.test.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/validation/json-validator-london.test.ts new file mode 100644 index 000000000..6e3ce603b --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/validation/json-validator-london.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { validateUtiacJrLondonDailyHearingList } from "./json-validator-london.js"; + +describe("validateUtiacJrLondonDailyHearingList", () => { + it("should return isValid true for valid data", () => { + // Arrange + const validData = [ + { + hearingTime: "10:00am", + caseTitle: "Smith v Secretary of State", + representative: "", + caseReferenceNumber: "JR/2025/001", + judges: "Judge Smith", + hearingType: "Permission", + location: "Field House, London", + additionalInformation: "" + } + ]; + + // Act + const result = validateUtiacJrLondonDailyHearingList(validData); + + // Assert + expect(result.isValid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("should return isValid false when required fields are missing", () => { + // Arrange + const invalidData = [ + { + hearingTime: "10:00am" + } + ]; + + // Act + const result = validateUtiacJrLondonDailyHearingList(invalidData); + + // Assert + expect(result.isValid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); +}); diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/validation/json-validator-london.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/validation/json-validator-london.ts new file mode 100644 index 000000000..336b70e5d --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/validation/json-validator-london.ts @@ -0,0 +1,6 @@ +import { createJsonValidator, type ValidationResult } from "@hmcts/list-types-common"; +import { londonSchemaPath } from "../config.js"; + +export function validateUtiacJrLondonDailyHearingList(jsonData: unknown): ValidationResult { + return createJsonValidator(londonSchemaPath)(jsonData); +} diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/validation/json-validator.test.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/validation/json-validator.test.ts new file mode 100644 index 000000000..2e842b537 --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/validation/json-validator.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { validateUtiacJrDailyHearingList } from "./json-validator.js"; + +describe("validateUtiacJrDailyHearingList", () => { + it("should return isValid true for valid data", () => { + // Arrange + const validData = [ + { + venue: "Leeds Combined Court Centre", + judges: "Judge Smith", + hearingTime: "10:00am", + caseReferenceNumber: "JR/2025/003", + caseTitle: "Smith v Secretary of State", + hearingType: "Permission", + additionalInformation: "" + } + ]; + + // Act + const result = validateUtiacJrDailyHearingList(validData); + + // Assert + expect(result.isValid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("should return isValid false when required fields are missing", () => { + // Arrange + const invalidData = [ + { + venue: "Leeds Combined Court Centre" + } + ]; + + // Act + const result = validateUtiacJrDailyHearingList(invalidData); + + // Assert + expect(result.isValid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); +}); diff --git a/libs/list-types/utiac-jr-daily-hearing-list/src/validation/json-validator.ts b/libs/list-types/utiac-jr-daily-hearing-list/src/validation/json-validator.ts new file mode 100644 index 000000000..53e3dfe3d --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/src/validation/json-validator.ts @@ -0,0 +1,12 @@ +import { createJsonValidator, type ValidationResult } from "@hmcts/list-types-common"; +import { londonSchemaPath, schemaPath } from "../config.js"; + +export function validateUtiacJrDailyHearingList(jsonData: unknown): ValidationResult { + return createJsonValidator(schemaPath)(jsonData); +} + +export function validateUtiacJrAnyDailyHearingList(jsonData: unknown): ValidationResult { + const regional = createJsonValidator(schemaPath)(jsonData); + if (regional.isValid) return regional; + return createJsonValidator(londonSchemaPath)(jsonData); +} diff --git a/libs/list-types/utiac-jr-daily-hearing-list/tsconfig.json b/libs/list-types/utiac-jr-daily-hearing-list/tsconfig.json new file mode 100644 index 000000000..a029ee937 --- /dev/null +++ b/libs/list-types/utiac-jr-daily-hearing-list/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules", "src/assets/"] +} diff --git a/libs/list-types/utiac-statutory-appeal-daily-hearing-list/package.json b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/package.json new file mode 100644 index 000000000..d67ff0c97 --- /dev/null +++ b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/package.json @@ -0,0 +1,42 @@ +{ + "name": "@hmcts/utiac-statutory-appeal-daily-hearing-list", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.ts" + } + }, + "scripts": { + "build": "tsc && yarn build:nunjucks && yarn build:schemas", + "build:nunjucks": "mkdir -p dist/pdf && cd src/pdf && find . -name '*.njk' -exec sh -c 'mkdir -p ../../dist/pdf/$(dirname {}) && cp {} ../../dist/pdf/{}' \\;", + "build:schemas": "mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ." + }, + "dependencies": { + "@hmcts/list-types-common": "workspace:*", + "@hmcts/pdf-generation": "workspace:*", + "@hmcts/postgres-prisma": "workspace:*", + "luxon": "3.7.2", + "nunjucks": "3.2.4" + }, + "devDependencies": { + "@types/luxon": "3.7.1", + "@types/node": "24.10.4", + "typescript": "6.0.3", + "vitest": "4.1.9" + }, + "peerDependencies": { + "express": "^5.1.0" + } +} diff --git a/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/config.ts b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/config.ts new file mode 100644 index 000000000..ada0353de --- /dev/null +++ b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/config.ts @@ -0,0 +1,9 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const moduleRoot = __dirname; +export const assets = path.join(__dirname, "assets/"); +export const schemaPath = path.join(__dirname, "schemas/utiac-statutory-appeal-daily-hearing-list.json"); diff --git a/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/conversion/utiac-sa-config.ts b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/conversion/utiac-sa-config.ts new file mode 100644 index 000000000..14881e097 --- /dev/null +++ b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/conversion/utiac-sa-config.ts @@ -0,0 +1,66 @@ +import { + createConverter, + type ExcelConverterConfig, + registerConverter, + registerConverterByName, + validateNoHtmlTags, + validateTimeFormatSimple +} from "@hmcts/list-types-common"; + +export const UTIAC_SA_EXCEL_CONFIG: ExcelConverterConfig = { + fields: [ + { + header: "Hearing time", + fieldName: "hearingTime", + required: true, + validators: [validateTimeFormatSimple] + }, + { + header: "Appellant", + fieldName: "appellant", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Appellant", rowNumber)] + }, + { + header: "Representative", + fieldName: "representative", + required: false, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Representative", rowNumber)] + }, + { + header: "Appeal reference number", + fieldName: "appealReferenceNumber", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Appeal reference number", rowNumber)] + }, + { + header: "Judge(s)", + fieldName: "judges", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Judge(s)", rowNumber)] + }, + { + header: "Hearing type", + fieldName: "hearingType", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Hearing type", rowNumber)] + }, + { + header: "Location", + fieldName: "location", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Location", rowNumber)] + }, + { + header: "Additional information", + fieldName: "additionalInformation", + required: false, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Additional information", rowNumber)] + } + ], + minRows: 1 +}; + +const utiacSaConverter = createConverter(UTIAC_SA_EXCEL_CONFIG); +registerConverter(30, utiacSaConverter); +registerConverterByName("UTIAC_STATUTORY_APPEAL_DAILY_HEARING_LIST", utiacSaConverter); diff --git a/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/email-summary/summary-builder.test.ts b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/email-summary/summary-builder.test.ts new file mode 100644 index 000000000..45a258fd4 --- /dev/null +++ b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/email-summary/summary-builder.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import type { UtiacStatutoryAppealHearingList } from "../models/types.js"; +import { extractCaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "./summary-builder.js"; + +describe("SPECIAL_CATEGORY_DATA_WARNING", () => { + it("should contain required warning text", () => { + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Special Category Data"); + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Data Protection Act 2018"); + }); +}); + +describe("extractCaseSummary", () => { + it("should extract case summaries", () => { + // Arrange + const hearingList: UtiacStatutoryAppealHearingList = [ + { + hearingTime: "10:00am", + appellant: "John Smith", + representative: "Smith & Co", + appealReferenceNumber: "IA/2025/001", + judges: "Judge Smith", + hearingType: "Substantive", + location: "Field House", + additionalInformation: "" + }, + { + hearingTime: "2:00pm", + appellant: "Jane Brown", + representative: "", + appealReferenceNumber: "IA/2025/002", + judges: "Judge Brown", + hearingType: "Preliminary", + location: "Manchester", + additionalInformation: "" + } + ]; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(2); + expect(result[0]).toEqual([ + { label: "Hearing time", value: "10:00am" }, + { label: "Appeal reference number", value: "IA/2025/001" } + ]); + expect(result[1]).toEqual([ + { label: "Hearing time", value: "2:00pm" }, + { label: "Appeal reference number", value: "IA/2025/002" } + ]); + }); + + it("should handle empty hearing list", () => { + // Arrange + const hearingList: UtiacStatutoryAppealHearingList = []; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(0); + }); + + it("should handle missing fields with empty string", () => { + // Arrange + const hearingList: UtiacStatutoryAppealHearingList = [ + { + hearingTime: "", + appellant: "", + representative: "", + appealReferenceNumber: "", + judges: "", + hearingType: "", + location: "", + additionalInformation: "" + } + ]; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(1); + expect(result[0]).toEqual([ + { label: "Hearing time", value: "" }, + { label: "Appeal reference number", value: "" } + ]); + }); +}); + +describe("formatCaseSummaryForEmail", () => { + it("should format single case summary correctly", () => { + // Arrange + const items = [ + [ + { label: "Hearing time", value: "10:00am" }, + { label: "Appeal reference number", value: "IA/2025/001" } + ] + ]; + + // Act + const result = formatCaseSummaryForEmail(items); + + // Assert + expect(result).toContain("---"); + expect(result).toContain("Hearing time - 10:00am"); + expect(result).toContain("Appeal reference number - IA/2025/001"); + }); + + it("should handle empty case list", () => { + // Act + const result = formatCaseSummaryForEmail([]); + + // Assert + expect(result).toBe("No cases scheduled."); + }); +}); diff --git a/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/email-summary/summary-builder.ts b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/email-summary/summary-builder.ts new file mode 100644 index 000000000..5568d36c1 --- /dev/null +++ b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/email-summary/summary-builder.ts @@ -0,0 +1,11 @@ +import { type CaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "@hmcts/list-types-common"; +import type { UtiacStatutoryAppealHearingList } from "../models/types.js"; + +export { formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING }; + +export function extractCaseSummary(jsonData: UtiacStatutoryAppealHearingList): CaseSummary[] { + return jsonData.map((hearing) => [ + { label: "Hearing time", value: hearing.hearingTime || "" }, + { label: "Appeal reference number", value: hearing.appealReferenceNumber || "" } + ]); +} diff --git a/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/index.ts b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/index.ts new file mode 100644 index 000000000..ebaeaffab --- /dev/null +++ b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/index.ts @@ -0,0 +1,12 @@ +import "./conversion/utiac-sa-config.js"; // Register converter on module load + +// Business logic exports +export type { ValidationResult } from "@hmcts/list-types-common"; +export * from "./email-summary/summary-builder.js"; +export { cy as utiacStatutoryAppealDailyHearingListCy } from "./locales/cy.js"; +// Locale exports +export { en as utiacStatutoryAppealDailyHearingListEn } from "./locales/en.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export * from "./rendering/renderer.js"; +export { validateUtiacStatutoryAppealDailyHearingList } from "./validation/json-validator.js"; diff --git a/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/locales/cy.ts b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/locales/cy.ts new file mode 100644 index 000000000..7971a8df5 --- /dev/null +++ b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/locales/cy.ts @@ -0,0 +1,37 @@ +import { provenanceLabelsCy as provenanceLabels } from "@hmcts/list-types-common"; + +export const cy = { + pageTitle: "Uwch Dribiwnlys (Siambr Mewnfudo a Lloches) - Rhestr o Wrandawiadau Dyddiol - Apeliadau Statudol", + listForDate: "[WELSH TRANSLATION REQUIRED: 'List for']", + lastUpdated: "Diweddarwyd ddiwethaf", + at: "am", + factLinkText: "Dod o hyd i fanylion cyswllt a gwybodaeth arall am lysoedd a thribiwnlysoedd yng Nghymru a Lloegr", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "a rhai tribiwnlysoedd heb eu datganoli yn yr Alban.", + importantInformationTitle: "Gwybodaeth bwysig", + importantInformationText: + "[WELSH TRANSLATION REQUIRED: 'We update this list by 5pm for the following day. If there are late changes to the list, we'll update no later than 9am on the day of the hearing.']", + importantInformationEmailText: + "[WELSH TRANSLATION REQUIRED: 'For details on attending a UTIAC remote hearing, please email uppertribunallistingteam@justice.gov.uk.']", + importantInformationLinkText: "Arsylwi gwrandawiad llys neu dribiwnlys fel newyddiadurwr, ymchwilydd neu aelod o'r cyhoedd", + importantInformationLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + searchCasesTitle: "Chwilio Achosion", + searchCasesLabel: "[WELSH TRANSLATION REQUIRED: 'Search by appeal reference number, appellant, judge, or other details']", + tableHeaders: { + hearingTime: "[WELSH TRANSLATION REQUIRED: 'Hearing time']", + appellant: "[WELSH TRANSLATION REQUIRED: 'Appellant']", + representative: "[WELSH TRANSLATION REQUIRED: 'Representative']", + appealReferenceNumber: "[WELSH TRANSLATION REQUIRED: 'Appeal reference number']", + judges: "[WELSH TRANSLATION REQUIRED: 'Judge(s)']", + hearingType: "[WELSH TRANSLATION REQUIRED: 'Hearing type']", + location: "[WELSH TRANSLATION REQUIRED: 'Location']", + additionalInformation: "Gwybodaeth ychwanegol" + }, + dataSource: "Ffynhonnell data", + backToTop: "Yn ôl i frig y dudalen", + cautionNote: + "Noder bod y ddogfen hon yn cynnwys Data Categori Arbennig fel y'i diffinnir yn Neddf Gwarchod Data 2018, a elwid gynt yn Ddata Personol Sensitif, a dylid ei drin yn y ffordd briodol.", + cautionReporting: + "Mae'r ddogfen hon yn cynnwys gwybodaeth a fwriedir i gynorthwyo i roi adroddiad manwl-gywir am achosion llys. Mae'n hanfodol eich bod yn sicrhau eich bod yn gwarchod y Data Categori Arbennig sydd ynddi ac yn cadw at gyfyngiadau adrodd (er enghraifft yn achos dioddefwyr a phlant). Bydd GLlTEF yn rhoi'r gorau i anfon y data os cyfyd pryder ynghylch sut y'i defnyddir.", + provenanceLabels +}; diff --git a/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/locales/en.ts b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/locales/en.ts new file mode 100644 index 000000000..38c56b8ca --- /dev/null +++ b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/locales/en.ts @@ -0,0 +1,36 @@ +import { provenanceLabelsEn as provenanceLabels } from "@hmcts/list-types-common"; + +export const en = { + pageTitle: "Upper Tribunal (Immigration and Asylum) Chamber Statutory Appeal Daily Hearing List", + listForDate: "List for", + lastUpdated: "Last updated", + at: "at", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + importantInformationTitle: "Important information", + importantInformationText: + "We update this list by 5pm for the following day. If there are late changes to the list, we'll update no later than 9am on the day of the hearing.", + importantInformationEmailText: "For details on attending a UTIAC remote hearing, please email uppertribunallistingteam@justice.gov.uk.", + importantInformationLinkText: "Observe a court or tribunal hearing as a journalist, researcher or member of the public", + importantInformationLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + searchCasesTitle: "Search Cases", + searchCasesLabel: "Search by appeal reference number, appellant, judge, or other details", + tableHeaders: { + hearingTime: "Hearing time", + appellant: "Appellant", + representative: "Representative", + appealReferenceNumber: "Appeal reference number", + judges: "Judge(s)", + hearingType: "Hearing type", + location: "Location", + additionalInformation: "Additional information" + }, + dataSource: "Data source", + backToTop: "Back to top", + cautionNote: + "Note this document contains Special Category Data as defined by Data Protection Act 2018, formally known as Sensitive Personal Data, and should be handled appropriately.", + cautionReporting: + "This document contains information intended to assist the accurate reporting of court proceedings. It is vital you ensure that you safeguard the Special Category Data included and abide by reporting restrictions (for example on victims and children). HMCTS will stop sending the data if there is concern about how it will be used.", + provenanceLabels +}; diff --git a/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/models/types.ts b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/models/types.ts new file mode 100644 index 000000000..a4f769dd0 --- /dev/null +++ b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/models/types.ts @@ -0,0 +1,12 @@ +export interface UtiacStatutoryAppealHearing { + hearingTime: string; + appellant: string; + representative: string; + appealReferenceNumber: string; + judges: string; + hearingType: string; + location: string; + additionalInformation: string; +} + +export type UtiacStatutoryAppealHearingList = UtiacStatutoryAppealHearing[]; diff --git a/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/pdf/pdf-generator.test.ts b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/pdf/pdf-generator.test.ts new file mode 100644 index 000000000..b6139a662 --- /dev/null +++ b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/pdf/pdf-generator.test.ts @@ -0,0 +1,227 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockSavePdfToStorage, mockCreatePdfErrorResult, mockConfigureNunjucks, mockLoadTranslations } = vi.hoisted(() => ({ + mockSavePdfToStorage: vi.fn(), + mockCreatePdfErrorResult: vi.fn(), + mockConfigureNunjucks: vi.fn(), + mockLoadTranslations: vi.fn() +})); + +vi.mock("@hmcts/list-types-common", () => ({ + savePdfToStorage: mockSavePdfToStorage, + createPdfErrorResult: mockCreatePdfErrorResult, + configureNunjucks: mockConfigureNunjucks, + loadTranslations: mockLoadTranslations, + PDF_BASE_STYLES: "/* base styles */", + provenanceLabelsEn: { + MANUAL_UPLOAD: "Manual Upload", + SNL: "SNL" + } +})); + +vi.mock("@hmcts/pdf-generation", () => ({ + generatePdfFromHtml: vi.fn() +})); + +vi.mock("../rendering/renderer.js", () => ({ + renderUtiacStatutoryAppealDailyHearingListData: vi.fn() +})); + +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { renderUtiacStatutoryAppealDailyHearingListData } from "../rendering/renderer.js"; +import { generateUtiacStatutoryAppealDailyHearingListPdf } from "./pdf-generator.js"; + +const mockRenderedData = { + header: { + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber Statutory Appeal Daily Hearing List", + listForDate: "15 January 2025", + lastUpdatedDate: "14 January 2025", + lastUpdatedTime: "9am" + }, + hearings: [] +}; + +const mockHearingList = [ + { + hearingTime: "10:00am", + appellant: "John Smith", + representative: "", + appealReferenceNumber: "IA/2025/001", + judges: "Judge Smith", + hearingType: "Substantive", + location: "Field House", + additionalInformation: "" + } +]; + +describe("generateUtiacStatutoryAppealDailyHearingListPdf", () => { + const mockNunjucksEnv = { + render: vi.fn().mockReturnValue("PDF HTML") + }; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(renderUtiacStatutoryAppealDailyHearingListData).mockReturnValue(mockRenderedData); + mockConfigureNunjucks.mockReturnValue(mockNunjucksEnv); + mockLoadTranslations.mockResolvedValue({ pageTitle: "Test Title" }); + mockCreatePdfErrorResult.mockImplementation((error: unknown) => ({ + success: false, + error: `Failed to generate PDF: ${error instanceof Error ? error.message : "Unknown error"}` + })); + }); + + it("should generate PDF successfully", async () => { + // Arrange + const pdfBuffer = Buffer.from("PDF content"); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer, + sizeBytes: 1024 + }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "test-artefact-123.pdf", + sizeBytes: 1024, + exceedsMaxSize: false + }); + + // Act + const result = await generateUtiacStatutoryAppealDailyHearingListPdf({ + artefactId: "test-artefact-123", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(true); + expect(result.pdfPath).toContain("test-artefact-123.pdf"); + expect(result.sizeBytes).toBe(1024); + expect(result.exceedsMaxSize).toBe(false); + }); + + it("should return exceedsMaxSize true when PDF is over 2MB", async () => { + // Arrange + const largePdfBuffer = Buffer.alloc(3 * 1024 * 1024); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: largePdfBuffer, + sizeBytes: 3 * 1024 * 1024 + }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "large-pdf-123.pdf", + sizeBytes: 3 * 1024 * 1024, + exceedsMaxSize: true + }); + + // Act + const result = await generateUtiacStatutoryAppealDailyHearingListPdf({ + artefactId: "large-pdf-123", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(true); + expect(result.exceedsMaxSize).toBe(true); + }); + + it("should return error when PDF generation fails", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: false, + error: "Puppeteer crashed" + }); + + // Act + const result = await generateUtiacStatutoryAppealDailyHearingListPdf({ + artefactId: "failed-pdf", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("Puppeteer crashed"); + }); + + it("should pass correct render options to renderer", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "test-render-options.pdf", + sizeBytes: 100, + exceedsMaxSize: false + }); + + const contentDate = new Date("2025-06-15"); + + // Act + await generateUtiacStatutoryAppealDailyHearingListPdf({ + artefactId: "test-render-options", + contentDate, + locale: "cy", + locationId: "999", + jsonData: mockHearingList + }); + + // Assert + expect(renderUtiacStatutoryAppealDailyHearingListData).toHaveBeenCalledWith(mockHearingList, { + locale: "cy", + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate, + lastReceivedDate: expect.any(String), + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber Statutory Appeal Daily Hearing List" + }); + }); + + it("should return error when PDF buffer is missing", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ success: true, pdfBuffer: undefined, sizeBytes: 0 }); + + // Act + const result = await generateUtiacStatutoryAppealDailyHearingListPdf({ + artefactId: "no-buffer", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("PDF generation failed"); + }); + + it("should handle renderer errors gracefully", async () => { + // Arrange + vi.mocked(renderUtiacStatutoryAppealDailyHearingListData).mockImplementation(() => { + throw new Error("Renderer failed"); + }); + + // Act + const result = await generateUtiacStatutoryAppealDailyHearingListPdf({ + artefactId: "renderer-error", + contentDate: new Date("2025-01-15"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("Failed to generate PDF: Renderer failed"); + }); +}); diff --git a/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/pdf/pdf-generator.ts b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/pdf/pdf-generator.ts new file mode 100644 index 000000000..3a2fcaf10 --- /dev/null +++ b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/pdf/pdf-generator.ts @@ -0,0 +1,64 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + type BasePdfGenerationOptions, + configureNunjucks, + createPdfErrorResult, + loadTranslations, + PDF_BASE_STYLES, + type PdfGenerationResult, + provenanceLabelsEn as PROVENANCE_LABELS, + savePdfToStorage +} from "@hmcts/list-types-common"; +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import type { UtiacStatutoryAppealHearingList } from "../models/types.js"; +import { renderUtiacStatutoryAppealDailyHearingListData } from "../rendering/renderer.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface PdfGenerationOptions extends BasePdfGenerationOptions { + contentDate: Date; +} + +export async function generateUtiacStatutoryAppealDailyHearingListPdf(options: PdfGenerationOptions): Promise { + try { + const renderedData = renderUtiacStatutoryAppealDailyHearingListData(options.jsonData, { + locale: options.locale, + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: options.contentDate, + lastReceivedDate: new Date().toISOString(), + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber Statutory Appeal Daily Hearing List" + }); + + const translations = await loadTranslations( + options.locale, + () => import("../locales/en.js"), + () => import("../locales/cy.js") + ); + + const provenanceLabel = options.provenance ? PROVENANCE_LABELS[options.provenance as keyof typeof PROVENANCE_LABELS] || options.provenance : ""; + + const env = configureNunjucks(__dirname); + const html = env.render("pdf-template.njk", { + header: renderedData.header, + hearings: renderedData.hearings, + dataSource: provenanceLabel, + t: translations, + pdfStyles: PDF_BASE_STYLES + }); + + const pdfResult = await generatePdfFromHtml(html); + + if (!pdfResult.success || !pdfResult.pdfBuffer) { + return { + success: false, + error: pdfResult.error || "PDF generation failed" + }; + } + + return await savePdfToStorage(options.artefactId, pdfResult.pdfBuffer, pdfResult.sizeBytes!); + } catch (error) { + return createPdfErrorResult(error); + } +} diff --git a/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/pdf/pdf-template.njk b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/pdf/pdf-template.njk new file mode 100644 index 000000000..f3355b6c1 --- /dev/null +++ b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/pdf/pdf-template.njk @@ -0,0 +1,69 @@ + + + + + + {{ header.listTitle }} + + + +
+

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForDate }} {{ header.listForDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+
+ +
+

{{ t.importantInformationTitle }}

+

{{ t.importantInformationText }}

+

{{ t.importantInformationEmailText }}

+

{{ t.importantInformationLinkText }}

+
+ + {% if hearings.length > 0 %} + + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.hearingTime }}{{ t.tableHeaders.appellant }}{{ t.tableHeaders.representative }}{{ t.tableHeaders.appealReferenceNumber }}{{ t.tableHeaders.judges }}{{ t.tableHeaders.hearingType }}{{ t.tableHeaders.location }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.hearingTime }}{{ hearing.appellant }}{{ hearing.representative }}{{ hearing.appealReferenceNumber }}{{ hearing.judges }}{{ hearing.hearingType }}{{ hearing.location }}{{ hearing.additionalInformation }}
+ {% else %} +

No hearings scheduled.

+ {% endif %} + + + + diff --git a/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/rendering/renderer.test.ts b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..1121e1aed --- /dev/null +++ b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/rendering/renderer.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import type { UtiacStatutoryAppealHearingList } from "../models/types.js"; +import { renderUtiacStatutoryAppealDailyHearingListData } from "./renderer.js"; + +describe("renderUtiacStatutoryAppealDailyHearingListData", () => { + it("should render hearing list with formatted display date", () => { + // Arrange + const hearingList: UtiacStatutoryAppealHearingList = [ + { + hearingTime: "10:00am", + appellant: "John Smith", + representative: "Smith & Co", + appealReferenceNumber: "IA/2025/001", + judges: "Judge Smith", + hearingType: "Substantive", + location: "Field House", + additionalInformation: "" + } + ]; + + const options = { + locale: "en", + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: new Date(2025, 0, 15), + lastReceivedDate: "2025-01-14T09:55:00Z", + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber Statutory Appeal Daily Hearing List" + }; + + // Act + const result = renderUtiacStatutoryAppealDailyHearingListData(hearingList, options); + + // Assert + expect(result.header.listTitle).toBe("Upper Tribunal (Immigration and Asylum) Chamber Statutory Appeal Daily Hearing List"); + expect(result.header.listForDate).toBe("15 January 2025"); + expect(result.header.lastUpdatedDate).toBe("14 January 2025"); + expect(result.header.lastUpdatedTime).toContain("am"); + + expect(result.hearings).toHaveLength(1); + expect(result.hearings[0].hearingTime).toBe("10:00am"); + expect(result.hearings[0].appellant).toBe("John Smith"); + expect(result.hearings[0].representative).toBe("Smith & Co"); + expect(result.hearings[0].appealReferenceNumber).toBe("IA/2025/001"); + expect(result.hearings[0].judges).toBe("Judge Smith"); + expect(result.hearings[0].hearingType).toBe("Substantive"); + expect(result.hearings[0].location).toBe("Field House"); + expect(result.hearings[0].additionalInformation).toBe(""); + }); + + it("should render multiple hearings", () => { + // Arrange + const hearingList: UtiacStatutoryAppealHearingList = [ + { + hearingTime: "10:00am", + appellant: "John Smith", + representative: "", + appealReferenceNumber: "IA/2025/001", + judges: "Judge Smith", + hearingType: "Substantive", + location: "Field House", + additionalInformation: "" + }, + { + hearingTime: "2:00pm", + appellant: "Jane Brown", + representative: "Brown Solicitors", + appealReferenceNumber: "IA/2025/002", + judges: "Judge Brown", + hearingType: "Preliminary", + location: "Manchester", + additionalInformation: "Remote" + } + ]; + + const options = { + locale: "en", + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: new Date(2025, 0, 15), + lastReceivedDate: "2025-01-14T09:55:00Z", + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber Statutory Appeal Daily Hearing List" + }; + + // Act + const result = renderUtiacStatutoryAppealDailyHearingListData(hearingList, options); + + // Assert + expect(result.hearings).toHaveLength(2); + expect(result.hearings[0].appellant).toBe("John Smith"); + expect(result.hearings[1].appellant).toBe("Jane Brown"); + }); + + it("should handle empty hearing list", () => { + // Arrange + const hearingList: UtiacStatutoryAppealHearingList = []; + + const options = { + locale: "en", + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: new Date(2025, 0, 15), + lastReceivedDate: "2025-01-14T09:55:00Z", + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber Statutory Appeal Daily Hearing List" + }; + + // Act + const result = renderUtiacStatutoryAppealDailyHearingListData(hearingList, options); + + // Assert + expect(result.hearings).toHaveLength(0); + expect(result.header.listTitle).toBe("Upper Tribunal (Immigration and Asylum) Chamber Statutory Appeal Daily Hearing List"); + }); + + it("should format PM times correctly", () => { + // Arrange + const hearingList: UtiacStatutoryAppealHearingList = [ + { + hearingTime: "10:00am", + appellant: "John Smith", + representative: "", + appealReferenceNumber: "IA/2025/001", + judges: "Judge Smith", + hearingType: "Substantive", + location: "Field House", + additionalInformation: "" + } + ]; + + const options = { + locale: "en", + courtName: "Upper Tribunal (Immigration and Asylum) Chamber", + contentDate: new Date(2025, 0, 15), + lastReceivedDate: "2025-01-14T14:30:00Z", + listTitle: "Upper Tribunal (Immigration and Asylum) Chamber Statutory Appeal Daily Hearing List" + }; + + // Act + const result = renderUtiacStatutoryAppealDailyHearingListData(hearingList, options); + + // Assert + expect(result.header.lastUpdatedTime).toBe("2:30pm"); + }); +}); diff --git a/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/rendering/renderer.ts b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/rendering/renderer.ts new file mode 100644 index 000000000..faa9464d6 --- /dev/null +++ b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/rendering/renderer.ts @@ -0,0 +1,46 @@ +import { formatDisplayDate, formatLastUpdatedDateTime } from "@hmcts/list-types-common"; +import type { UtiacStatutoryAppealHearing, UtiacStatutoryAppealHearingList } from "../models/types.js"; + +export interface RenderOptions { + locale: string; + courtName: string; + contentDate: Date; + lastReceivedDate: string; + listTitle: string; +} + +export interface RenderedData { + header: { + listTitle: string; + listForDate: string; + lastUpdatedDate: string; + lastUpdatedTime: string; + }; + hearings: UtiacStatutoryAppealHearing[]; +} + +export function renderUtiacStatutoryAppealDailyHearingListData(hearingList: UtiacStatutoryAppealHearingList, options: RenderOptions): RenderedData { + const listForDate = formatDisplayDate(options.contentDate, options.locale); + const { date: lastUpdatedDate, time: lastUpdatedTime } = formatLastUpdatedDateTime(options.lastReceivedDate, options.locale); + + const renderedHearings = hearingList.map((hearing) => ({ + hearingTime: hearing.hearingTime, + appellant: hearing.appellant, + representative: hearing.representative, + appealReferenceNumber: hearing.appealReferenceNumber, + judges: hearing.judges, + hearingType: hearing.hearingType, + location: hearing.location, + additionalInformation: hearing.additionalInformation + })); + + return { + header: { + listTitle: options.listTitle, + listForDate, + lastUpdatedDate, + lastUpdatedTime + }, + hearings: renderedHearings + }; +} diff --git a/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/schemas/utiac-statutory-appeal-daily-hearing-list.json b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/schemas/utiac-statutory-appeal-daily-hearing-list.json new file mode 100644 index 000000000..b1f788619 --- /dev/null +++ b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/schemas/utiac-statutory-appeal-daily-hearing-list.json @@ -0,0 +1,61 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "UTIAC Statutory Appeal Daily Hearing List", + "description": "Schema for Upper Tribunal (Immigration and Asylum) Chamber Statutory Appeal Daily Hearing List", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["hearingTime", "appellant", "appealReferenceNumber", "judges", "hearingType", "location"], + "properties": { + "hearingTime": { + "title": "Hearing time", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$", + "examples": ["10:00am"] + }, + "appellant": { + "title": "Appellant", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$", + "examples": ["John Smith"] + }, + "representative": { + "title": "Representative", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$", + "examples": ["Smith & Co Solicitors"] + }, + "appealReferenceNumber": { + "title": "Appeal reference number", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$", + "examples": ["IA/2025/001"] + }, + "judges": { + "title": "Judge(s)", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$", + "examples": ["Judge Smith"] + }, + "hearingType": { + "title": "Hearing type", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$", + "examples": ["Substantive"] + }, + "location": { + "title": "Location", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$", + "examples": ["Field House"] + }, + "additionalInformation": { + "title": "Additional information", + "type": "string", + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$", + "examples": ["Remote hearing"] + } + } + } +} diff --git a/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/validation/json-validator.test.ts b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/validation/json-validator.test.ts new file mode 100644 index 000000000..0d698af66 --- /dev/null +++ b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/validation/json-validator.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { validateUtiacStatutoryAppealDailyHearingList } from "./json-validator.js"; + +describe("validateUtiacStatutoryAppealDailyHearingList", () => { + it("should return isValid true for valid data", () => { + // Arrange + const validData = [ + { + hearingTime: "10:00am", + appellant: "John Smith", + appealReferenceNumber: "IA/2025/001", + judges: "Judge Smith", + hearingType: "Substantive", + location: "Field House" + } + ]; + + // Act + const result = validateUtiacStatutoryAppealDailyHearingList(validData); + + // Assert + expect(result.isValid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("should return isValid false when required fields are missing", () => { + // Arrange + const invalidData = [ + { + hearingTime: "10:00am" + } + ]; + + // Act + const result = validateUtiacStatutoryAppealDailyHearingList(invalidData); + + // Assert + expect(result.isValid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); +}); diff --git a/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/validation/json-validator.ts b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/validation/json-validator.ts new file mode 100644 index 000000000..60935f9fe --- /dev/null +++ b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/src/validation/json-validator.ts @@ -0,0 +1,6 @@ +import { createJsonValidator, type ValidationResult } from "@hmcts/list-types-common"; +import { schemaPath } from "../config.js"; + +export function validateUtiacStatutoryAppealDailyHearingList(jsonData: unknown): ValidationResult { + return createJsonValidator(schemaPath)(jsonData); +} diff --git a/libs/list-types/utiac-statutory-appeal-daily-hearing-list/tsconfig.json b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/tsconfig.json new file mode 100644 index 000000000..a029ee937 --- /dev/null +++ b/libs/list-types/utiac-statutory-appeal-daily-hearing-list/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules", "src/assets/"] +} diff --git a/libs/list-types/wpafcc-weekly-hearing-list/package.json b/libs/list-types/wpafcc-weekly-hearing-list/package.json new file mode 100644 index 000000000..ec85b5f1f --- /dev/null +++ b/libs/list-types/wpafcc-weekly-hearing-list/package.json @@ -0,0 +1,42 @@ +{ + "name": "@hmcts/wpafcc-weekly-hearing-list", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "production": "./dist/index.js", + "default": "./src/index.ts" + }, + "./config": { + "production": "./dist/config.js", + "default": "./src/config.ts" + } + }, + "scripts": { + "build": "tsc && yarn build:nunjucks && yarn build:schemas", + "build:nunjucks": "mkdir -p dist/pdf && cd src/pdf && find . -name '*.njk' -exec sh -c 'mkdir -p ../../dist/pdf/$(dirname {}) && cp {} ../../dist/pdf/{}' \\;", + "build:schemas": "mkdir -p dist/schemas && cp src/schemas/*.json dist/schemas/", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write --unsafe ." + }, + "dependencies": { + "@hmcts/list-types-common": "workspace:*", + "@hmcts/pdf-generation": "workspace:*", + "@hmcts/postgres-prisma": "workspace:*", + "luxon": "3.7.2", + "nunjucks": "3.2.4" + }, + "devDependencies": { + "@types/luxon": "3.7.1", + "@types/node": "24.10.4", + "typescript": "6.0.3", + "vitest": "4.1.9" + }, + "peerDependencies": { + "express": "^5.1.0" + } +} diff --git a/libs/list-types/wpafcc-weekly-hearing-list/src/config.ts b/libs/list-types/wpafcc-weekly-hearing-list/src/config.ts new file mode 100644 index 000000000..327009c8c --- /dev/null +++ b/libs/list-types/wpafcc-weekly-hearing-list/src/config.ts @@ -0,0 +1,9 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const moduleRoot = __dirname; +export const assets = path.join(__dirname, "assets/"); +export const schemaPath = path.join(__dirname, "schemas/wpafcc-weekly-hearing-list.json"); diff --git a/libs/list-types/wpafcc-weekly-hearing-list/src/conversion/wpafcc-config.ts b/libs/list-types/wpafcc-weekly-hearing-list/src/conversion/wpafcc-config.ts new file mode 100644 index 000000000..1dc208a04 --- /dev/null +++ b/libs/list-types/wpafcc-weekly-hearing-list/src/conversion/wpafcc-config.ts @@ -0,0 +1,68 @@ +import { + createConverter, + DD_MM_YYYY_PATTERN, + type ExcelConverterConfig, + registerConverter, + registerConverterByName, + validateDateFormat, + validateNoHtmlTags, + validateTimeFormatSimple +} from "@hmcts/list-types-common"; + +export const WPAFCC_EXCEL_CONFIG: ExcelConverterConfig = { + fields: [ + { + header: "Date", + fieldName: "date", + required: true, + validators: [validateDateFormat(DD_MM_YYYY_PATTERN, "dd/MM/yyyy (e.g., 02/01/2025)")] + }, + { + header: "Hearing time", + fieldName: "hearingTime", + required: true, + validators: [validateTimeFormatSimple] + }, + { + header: "Case reference number", + fieldName: "caseReferenceNumber", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case reference number", rowNumber)] + }, + { + header: "Case name", + fieldName: "caseName", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Case name", rowNumber)] + }, + { + header: "Panel", + fieldName: "panel", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Panel", rowNumber)] + }, + { + header: "Mode of hearing", + fieldName: "modeOfHearing", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Mode of hearing", rowNumber)] + }, + { + header: "Venue", + fieldName: "venue", + required: true, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Venue", rowNumber)] + }, + { + header: "Additional information", + fieldName: "additionalInformation", + required: false, + validators: [(value, rowNumber) => validateNoHtmlTags(value, "Additional information", rowNumber)] + } + ], + minRows: 1 +}; + +const wpafccConverter = createConverter(WPAFCC_EXCEL_CONFIG); +registerConverter(29, wpafccConverter); +registerConverterByName("WPAFCC_WEEKLY_HEARING_LIST", wpafccConverter); diff --git a/libs/list-types/wpafcc-weekly-hearing-list/src/email-summary/summary-builder.test.ts b/libs/list-types/wpafcc-weekly-hearing-list/src/email-summary/summary-builder.test.ts new file mode 100644 index 000000000..f1743516d --- /dev/null +++ b/libs/list-types/wpafcc-weekly-hearing-list/src/email-summary/summary-builder.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import type { WpafccWeeklyHearingList } from "../models/types.js"; +import { extractCaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "./summary-builder.js"; + +describe("SPECIAL_CATEGORY_DATA_WARNING", () => { + it("should contain required warning text", () => { + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Special Category Data"); + expect(SPECIAL_CATEGORY_DATA_WARNING).toContain("Data Protection Act 2018"); + }); +}); + +describe("extractCaseSummary", () => { + it("should extract case summaries from hearing list", () => { + // Arrange + const hearingList: WpafccWeeklyHearingList = [ + { + date: "01/01/2025", + hearingTime: "10:00am", + caseReferenceNumber: "WPAFCC/2025/001", + caseName: "Smith v MOD", + panel: "Judge Smith", + modeOfHearing: "Remote", + venue: "WPAFCC Hearing Centre", + additionalInformation: "" + }, + { + date: "02/01/2025", + hearingTime: "2:00pm", + caseReferenceNumber: "WPAFCC/2025/002", + caseName: "Brown v Armed Forces", + panel: "Judge Brown, Member Jones", + modeOfHearing: "In person", + venue: "WPAFCC Office", + additionalInformation: "" + } + ]; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(2); + expect(result[0]).toEqual([ + { label: "Date", value: "01/01/2025" }, + { label: "Hearing time", value: "10:00am" }, + { label: "Case reference number", value: "WPAFCC/2025/001" } + ]); + expect(result[1]).toEqual([ + { label: "Date", value: "02/01/2025" }, + { label: "Hearing time", value: "2:00pm" }, + { label: "Case reference number", value: "WPAFCC/2025/002" } + ]); + }); + + it("should handle empty hearing list", () => { + // Arrange + const hearingList: WpafccWeeklyHearingList = []; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(0); + }); + + it("should handle missing fields with empty string", () => { + // Arrange + const hearingList: WpafccWeeklyHearingList = [ + { + date: "", + hearingTime: "", + caseReferenceNumber: "", + caseName: "", + panel: "", + modeOfHearing: "", + venue: "", + additionalInformation: "" + } + ]; + + // Act + const result = extractCaseSummary(hearingList); + + // Assert + expect(result).toHaveLength(1); + expect(result[0]).toEqual([ + { label: "Date", value: "" }, + { label: "Hearing time", value: "" }, + { label: "Case reference number", value: "" } + ]); + }); +}); + +describe("formatCaseSummaryForEmail", () => { + it("should format single case summary correctly", () => { + // Arrange + const items = [ + [ + { label: "Date", value: "01/01/2025" }, + { label: "Hearing time", value: "10:00am" }, + { label: "Case reference number", value: "WPAFCC/2025/001" } + ] + ]; + + // Act + const result = formatCaseSummaryForEmail(items); + + // Assert + expect(result).toContain("---"); + expect(result).toContain("Date - 01/01/2025"); + expect(result).toContain("Hearing time - 10:00am"); + expect(result).toContain("Case reference number - WPAFCC/2025/001"); + }); + + it("should handle empty case list", () => { + // Act + const result = formatCaseSummaryForEmail([]); + + // Assert + expect(result).toBe("No cases scheduled."); + }); +}); diff --git a/libs/list-types/wpafcc-weekly-hearing-list/src/email-summary/summary-builder.ts b/libs/list-types/wpafcc-weekly-hearing-list/src/email-summary/summary-builder.ts new file mode 100644 index 000000000..dc0793b49 --- /dev/null +++ b/libs/list-types/wpafcc-weekly-hearing-list/src/email-summary/summary-builder.ts @@ -0,0 +1,12 @@ +import { type CaseSummary, formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING } from "@hmcts/list-types-common"; +import type { WpafccWeeklyHearingList } from "../models/types.js"; + +export { formatCaseSummaryForEmail, SPECIAL_CATEGORY_DATA_WARNING }; + +export function extractCaseSummary(jsonData: WpafccWeeklyHearingList): CaseSummary[] { + return jsonData.map((hearing) => [ + { label: "Date", value: hearing.date || "" }, + { label: "Hearing time", value: hearing.hearingTime || "" }, + { label: "Case reference number", value: hearing.caseReferenceNumber || "" } + ]); +} diff --git a/libs/list-types/wpafcc-weekly-hearing-list/src/index.ts b/libs/list-types/wpafcc-weekly-hearing-list/src/index.ts new file mode 100644 index 000000000..e37e82af4 --- /dev/null +++ b/libs/list-types/wpafcc-weekly-hearing-list/src/index.ts @@ -0,0 +1,12 @@ +import "./conversion/wpafcc-config.js"; // Register converter on module load + +// Business logic exports +export type { ValidationResult } from "@hmcts/list-types-common"; +export * from "./email-summary/summary-builder.js"; +export { cy as wpafccWeeklyHearingListCy } from "./locales/cy.js"; +// Locale exports +export { en as wpafccWeeklyHearingListEn } from "./locales/en.js"; +export * from "./models/types.js"; +export * from "./pdf/pdf-generator.js"; +export * from "./rendering/renderer.js"; +export { validateWpafccWeeklyHearingList } from "./validation/json-validator.js"; diff --git a/libs/list-types/wpafcc-weekly-hearing-list/src/locales/cy.ts b/libs/list-types/wpafcc-weekly-hearing-list/src/locales/cy.ts new file mode 100644 index 000000000..a87e865fd --- /dev/null +++ b/libs/list-types/wpafcc-weekly-hearing-list/src/locales/cy.ts @@ -0,0 +1,35 @@ +import { provenanceLabelsCy as provenanceLabels } from "@hmcts/list-types-common"; + +export const cy = { + pageTitle: "[WELSH TRANSLATION REQUIRED: 'First-tier Tribunal (War Pensions and Armed Forces Compensation) Weekly Hearing List']", + listForWeekCommencing: "Rhestr ar gyfer yr wythnos yn dechrau ar", + lastUpdated: "Diweddarwyd ddiwethaf", + at: "am", + factLinkText: "Dod o hyd i fanylion cyswllt a gwybodaeth arall am lysoedd a thribiwnlysoedd yng Nghymru a Lloegr", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "a rhai tribiwnlysoedd heb eu datganoli yn yr Alban.", + importantInformationTitle: "Gwybodaeth bwysig", + importantInformationText: + "[WELSH TRANSLATION REQUIRED: 'Members of the public wishing to observe a hearing or representatives of the media may, on their request, join any telephone or video hearing remotely while they are taking place by sending an email in advance to the tribunal at armedforces.listing@justice.gov.uk with the following details in the subject line \"[OBSERVER/MEDIA] REQUEST – [case reference] – [hearing date] (need to include any other information required by the tribunal)\" and appropriate arrangements will be made to allow access where reasonably practicable.']", + importantInformationLinkText: "Arsylwi gwrandawiad llys neu dribiwnlys fel newyddiadurwr, ymchwilydd neu aelod o'r cyhoedd", + importantInformationLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + searchCasesTitle: "Chwilio Achosion", + searchCasesLabel: "[WELSH TRANSLATION REQUIRED: 'Search by case reference number, case name, date, venue, or other details']", + tableHeaders: { + date: "[WELSH TRANSLATION REQUIRED: 'Date']", + hearingTime: "[WELSH TRANSLATION REQUIRED: 'Hearing time']", + caseReferenceNumber: "[WELSH TRANSLATION REQUIRED: 'Case reference number']", + caseName: "[WELSH TRANSLATION REQUIRED: 'Case name']", + panel: "[WELSH TRANSLATION REQUIRED: 'Panel']", + modeOfHearing: "[WELSH TRANSLATION REQUIRED: 'Mode of hearing']", + venue: "[WELSH TRANSLATION REQUIRED: 'Venue']", + additionalInformation: "Gwybodaeth ychwanegol" + }, + dataSource: "Ffynhonnell data", + backToTop: "Yn ôl i frig y dudalen", + cautionNote: + "Noder bod y ddogfen hon yn cynnwys Data Categori Arbennig fel y'i diffinnir yn Neddf Gwarchod Data 2018, a elwid gynt yn Ddata Personol Sensitif, a dylid ei drin yn y ffordd briodol.", + cautionReporting: + "Mae'r ddogfen hon yn cynnwys gwybodaeth a fwriedir i gynorthwyo i roi adroddiad manwl-gywir am achosion llys. Mae'n hanfodol eich bod yn sicrhau eich bod yn gwarchod y Data Categori Arbennig sydd ynddi ac yn cadw at gyfyngiadau adrodd (er enghraifft yn achos dioddefwyr a phlant). Bydd GLlTEF yn rhoi'r gorau i anfon y data os cyfyd pryder ynghylch sut y'i defnyddir.", + provenanceLabels +}; diff --git a/libs/list-types/wpafcc-weekly-hearing-list/src/locales/en.ts b/libs/list-types/wpafcc-weekly-hearing-list/src/locales/en.ts new file mode 100644 index 000000000..bb7d5f8e9 --- /dev/null +++ b/libs/list-types/wpafcc-weekly-hearing-list/src/locales/en.ts @@ -0,0 +1,35 @@ +import { provenanceLabelsEn as provenanceLabels } from "@hmcts/list-types-common"; + +export const en = { + pageTitle: "First-tier Tribunal (War Pensions and Armed Forces Compensation) Weekly Hearing List", + listForWeekCommencing: "List for week commencing", + lastUpdated: "Last updated", + at: "at", + factLinkText: "Find contact details and other information about courts and tribunals", + factLinkUrl: "https://www.find-court-tribunal.service.gov.uk/", + factAdditionalText: "in England and Wales, and some non-devolved tribunals in Scotland.", + importantInformationTitle: "Important information", + importantInformationText: + 'Members of the public wishing to observe a hearing or representatives of the media may, on their request, join any telephone or video hearing remotely while they are taking place by sending an email in advance to the tribunal at armedforces.listing@justice.gov.uk with the following details in the subject line "[OBSERVER/MEDIA] REQUEST – [case reference] – [hearing date] (need to include any other information required by the tribunal)" and appropriate arrangements will be made to allow access where reasonably practicable.', + importantInformationLinkText: "Observe a court or tribunal hearing as a journalist, researcher or member of the public", + importantInformationLinkUrl: "https://www.gov.uk/guidance/observe-a-court-or-tribunal-hearing", + searchCasesTitle: "Search Cases", + searchCasesLabel: "Search by case reference number, case name, date, venue, or other details", + tableHeaders: { + date: "Date", + hearingTime: "Hearing time", + caseReferenceNumber: "Case reference number", + caseName: "Case name", + panel: "Panel", + modeOfHearing: "Mode of hearing", + venue: "Venue", + additionalInformation: "Additional information" + }, + dataSource: "Data source", + backToTop: "Back to top", + cautionNote: + "Note this document contains Special Category Data as defined by Data Protection Act 2018, formally known as Sensitive Personal Data, and should be handled appropriately.", + cautionReporting: + "This document contains information intended to assist the accurate reporting of court proceedings. It is vital you ensure that you safeguard the Special Category Data included and abide by reporting restrictions (for example on victims and children). HMCTS will stop sending the data if there is concern about how it will be used.", + provenanceLabels +}; diff --git a/libs/list-types/wpafcc-weekly-hearing-list/src/models/types.ts b/libs/list-types/wpafcc-weekly-hearing-list/src/models/types.ts new file mode 100644 index 000000000..d0851306f --- /dev/null +++ b/libs/list-types/wpafcc-weekly-hearing-list/src/models/types.ts @@ -0,0 +1,12 @@ +export interface WpafccWeeklyHearing { + date: string; + hearingTime: string; + caseReferenceNumber: string; + caseName: string; + panel: string; + modeOfHearing: string; + venue: string; + additionalInformation: string; +} + +export type WpafccWeeklyHearingList = WpafccWeeklyHearing[]; diff --git a/libs/list-types/wpafcc-weekly-hearing-list/src/pdf/pdf-generator.test.ts b/libs/list-types/wpafcc-weekly-hearing-list/src/pdf/pdf-generator.test.ts new file mode 100644 index 000000000..234be384d --- /dev/null +++ b/libs/list-types/wpafcc-weekly-hearing-list/src/pdf/pdf-generator.test.ts @@ -0,0 +1,256 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockSavePdfToStorage, mockCreatePdfErrorResult, mockConfigureNunjucks, mockLoadTranslations } = vi.hoisted(() => ({ + mockSavePdfToStorage: vi.fn(), + mockCreatePdfErrorResult: vi.fn(), + mockConfigureNunjucks: vi.fn(), + mockLoadTranslations: vi.fn() +})); + +vi.mock("@hmcts/list-types-common", () => ({ + savePdfToStorage: mockSavePdfToStorage, + createPdfErrorResult: mockCreatePdfErrorResult, + configureNunjucks: mockConfigureNunjucks, + loadTranslations: mockLoadTranslations, + PDF_BASE_STYLES: "/* base styles */", + provenanceLabelsEn: { + MANUAL_UPLOAD: "Manual Upload", + SNL: "SNL" + } +})); + +vi.mock("@hmcts/pdf-generation", () => ({ + generatePdfFromHtml: vi.fn() +})); + +vi.mock("../rendering/renderer.js", () => ({ + renderWpafccWeeklyHearingListData: vi.fn() +})); + +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import { renderWpafccWeeklyHearingListData } from "../rendering/renderer.js"; +import { generateWpafccWeeklyHearingListPdf } from "./pdf-generator.js"; + +const mockRenderedData = { + header: { + listTitle: "First-tier Tribunal (War Pensions and Armed Forces Compensation) Weekly Hearing List", + weekCommencingDate: "01 January 2025", + lastUpdatedDate: "12 November 2025", + lastUpdatedTime: "9am" + }, + hearings: [] +}; + +const mockHearingList = [ + { + date: "01/01/2025", + hearingTime: "10:00am", + caseReferenceNumber: "WPAFCC/2025/001", + caseName: "Smith v MOD", + panel: "Judge Smith", + modeOfHearing: "Remote", + venue: "WPAFCC Hearing Centre", + additionalInformation: "" + } +]; + +describe("generateWpafccWeeklyHearingListPdf", () => { + const mockNunjucksEnv = { + render: vi.fn().mockReturnValue("PDF HTML") + }; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.mocked(renderWpafccWeeklyHearingListData).mockReturnValue(mockRenderedData); + mockConfigureNunjucks.mockReturnValue(mockNunjucksEnv); + mockLoadTranslations.mockResolvedValue({ pageTitle: "Test Title" }); + mockCreatePdfErrorResult.mockImplementation((error: unknown) => ({ + success: false, + error: `Failed to generate PDF: ${error instanceof Error ? error.message : "Unknown error"}` + })); + }); + + it("should generate PDF successfully", async () => { + // Arrange + const pdfBuffer = Buffer.from("PDF content"); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer, + sizeBytes: 1024 + }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "test-artefact-123.pdf", + sizeBytes: 1024, + exceedsMaxSize: false + }); + + // Act + const result = await generateWpafccWeeklyHearingListPdf({ + artefactId: "test-artefact-123", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(true); + expect(result.pdfPath).toContain("test-artefact-123.pdf"); + expect(result.sizeBytes).toBe(1024); + expect(result.exceedsMaxSize).toBe(false); + }); + + it("should return exceedsMaxSize true when PDF is over 2MB", async () => { + // Arrange + const largePdfBuffer = Buffer.alloc(3 * 1024 * 1024); + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: largePdfBuffer, + sizeBytes: 3 * 1024 * 1024 + }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "large-pdf-123.pdf", + sizeBytes: 3 * 1024 * 1024, + exceedsMaxSize: true + }); + + // Act + const result = await generateWpafccWeeklyHearingListPdf({ + artefactId: "large-pdf-123", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(true); + expect(result.exceedsMaxSize).toBe(true); + }); + + it("should return error when PDF generation fails", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: false, + error: "Puppeteer crashed" + }); + + // Act + const result = await generateWpafccWeeklyHearingListPdf({ + artefactId: "failed-pdf", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("Puppeteer crashed"); + }); + + it("should pass correct render options to renderer", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "test-render-options.pdf", + sizeBytes: 100, + exceedsMaxSize: false + }); + + const contentDate = new Date("2025-06-15"); + + // Act + await generateWpafccWeeklyHearingListPdf({ + artefactId: "test-render-options", + contentDate, + locale: "cy", + locationId: "999", + jsonData: mockHearingList + }); + + // Assert + expect(renderWpafccWeeklyHearingListData).toHaveBeenCalledWith(mockHearingList, { + locale: "cy", + courtName: "First-tier Tribunal (War Pensions and Armed Forces Compensation)", + contentDate, + lastReceivedDate: expect.any(String), + listTitle: "First-tier Tribunal (War Pensions and Armed Forces Compensation) Weekly Hearing List" + }); + }); + + it("should return error when PDF buffer is missing", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ success: true, pdfBuffer: undefined, sizeBytes: 0 }); + + // Act + const result = await generateWpafccWeeklyHearingListPdf({ + artefactId: "no-buffer", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("PDF generation failed"); + }); + + it("should handle renderer errors gracefully", async () => { + // Arrange + vi.mocked(renderWpafccWeeklyHearingListData).mockImplementation(() => { + throw new Error("Renderer failed"); + }); + + // Act + const result = await generateWpafccWeeklyHearingListPdf({ + artefactId: "renderer-error", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList + }); + + // Assert + expect(result.success).toBe(false); + expect(result.error).toBe("Failed to generate PDF: Renderer failed"); + }); + + it("should use provenance label when provenance is provided", async () => { + // Arrange + vi.mocked(generatePdfFromHtml).mockResolvedValue({ + success: true, + pdfBuffer: Buffer.from("PDF"), + sizeBytes: 100 + }); + mockSavePdfToStorage.mockResolvedValue({ + success: true, + pdfPath: "provenance-test.pdf", + sizeBytes: 100, + exceedsMaxSize: false + }); + + // Act + const result = await generateWpafccWeeklyHearingListPdf({ + artefactId: "provenance-test", + contentDate: new Date("2025-01-01"), + locale: "en", + locationId: "240", + jsonData: mockHearingList, + provenance: "MANUAL_UPLOAD" + }); + + // Assert + expect(result.success).toBe(true); + expect(mockNunjucksEnv.render).toHaveBeenCalledWith("pdf-template.njk", expect.objectContaining({ dataSource: "Manual Upload" })); + }); +}); diff --git a/libs/list-types/wpafcc-weekly-hearing-list/src/pdf/pdf-generator.ts b/libs/list-types/wpafcc-weekly-hearing-list/src/pdf/pdf-generator.ts new file mode 100644 index 000000000..241c2d67e --- /dev/null +++ b/libs/list-types/wpafcc-weekly-hearing-list/src/pdf/pdf-generator.ts @@ -0,0 +1,64 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + type BasePdfGenerationOptions, + configureNunjucks, + createPdfErrorResult, + loadTranslations, + PDF_BASE_STYLES, + type PdfGenerationResult, + provenanceLabelsEn as PROVENANCE_LABELS, + savePdfToStorage +} from "@hmcts/list-types-common"; +import { generatePdfFromHtml } from "@hmcts/pdf-generation"; +import type { WpafccWeeklyHearingList } from "../models/types.js"; +import { renderWpafccWeeklyHearingListData } from "../rendering/renderer.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface PdfGenerationOptions extends BasePdfGenerationOptions { + contentDate: Date; +} + +export async function generateWpafccWeeklyHearingListPdf(options: PdfGenerationOptions): Promise { + try { + const renderedData = renderWpafccWeeklyHearingListData(options.jsonData, { + locale: options.locale, + courtName: "First-tier Tribunal (War Pensions and Armed Forces Compensation)", + contentDate: options.contentDate, + lastReceivedDate: new Date().toISOString(), + listTitle: "First-tier Tribunal (War Pensions and Armed Forces Compensation) Weekly Hearing List" + }); + + const translations = await loadTranslations( + options.locale, + () => import("../locales/en.js"), + () => import("../locales/cy.js") + ); + + const provenanceLabel = options.provenance ? PROVENANCE_LABELS[options.provenance as keyof typeof PROVENANCE_LABELS] || options.provenance : ""; + + const env = configureNunjucks(__dirname); + const html = env.render("pdf-template.njk", { + header: renderedData.header, + hearings: renderedData.hearings, + dataSource: provenanceLabel, + t: translations, + pdfStyles: PDF_BASE_STYLES + }); + + const pdfResult = await generatePdfFromHtml(html); + + if (!pdfResult.success || !pdfResult.pdfBuffer) { + return { + success: false, + error: pdfResult.error || "PDF generation failed" + }; + } + + return await savePdfToStorage(options.artefactId, pdfResult.pdfBuffer, pdfResult.sizeBytes!); + } catch (error) { + return createPdfErrorResult(error); + } +} diff --git a/libs/list-types/wpafcc-weekly-hearing-list/src/pdf/pdf-template.njk b/libs/list-types/wpafcc-weekly-hearing-list/src/pdf/pdf-template.njk new file mode 100644 index 000000000..a132fcef3 --- /dev/null +++ b/libs/list-types/wpafcc-weekly-hearing-list/src/pdf/pdf-template.njk @@ -0,0 +1,68 @@ + + + + + + {{ header.listTitle }} + + + +
+

{{ header.listTitle }}

+ +

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

+ +

{{ t.listForWeekCommencing }} {{ header.weekCommencingDate }}

+

{{ t.lastUpdated }} {{ header.lastUpdatedDate }} {{ t.at }} {{ header.lastUpdatedTime }}

+
+ +
+

{{ t.importantInformationTitle }}

+

{{ t.importantInformationText }}

+

{{ t.importantInformationLinkText }}

+
+ + {% if hearings.length > 0 %} + + + + + + + + + + + + + + + {% for hearing in hearings %} + + + + + + + + + + + {% endfor %} + +
{{ t.tableHeaders.date }}{{ t.tableHeaders.hearingTime }}{{ t.tableHeaders.caseReferenceNumber }}{{ t.tableHeaders.caseName }}{{ t.tableHeaders.panel }}{{ t.tableHeaders.modeOfHearing }}{{ t.tableHeaders.venue }}{{ t.tableHeaders.additionalInformation }}
{{ hearing.date }}{{ hearing.hearingTime }}{{ hearing.caseReferenceNumber }}{{ hearing.caseName }}{{ hearing.panel }}{{ hearing.modeOfHearing }}{{ hearing.venue }}{{ hearing.additionalInformation }}
+ {% else %} +

No hearings scheduled.

+ {% endif %} + + + + diff --git a/libs/list-types/wpafcc-weekly-hearing-list/src/rendering/renderer.test.ts b/libs/list-types/wpafcc-weekly-hearing-list/src/rendering/renderer.test.ts new file mode 100644 index 000000000..420e683ef --- /dev/null +++ b/libs/list-types/wpafcc-weekly-hearing-list/src/rendering/renderer.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import type { WpafccWeeklyHearingList } from "../models/types.js"; +import { renderWpafccWeeklyHearingListData } from "./renderer.js"; + +describe("renderWpafccWeeklyHearingListData", () => { + it("should render hearing list with formatted dates", () => { + // Arrange + const hearingList: WpafccWeeklyHearingList = [ + { + date: "02/01/2025", + hearingTime: "10:00am", + caseReferenceNumber: "WPAFCC/2025/001", + caseName: "A Vs B", + panel: "Judge Smith", + modeOfHearing: "Remote", + venue: "WPAFCC Hearing Centre", + additionalInformation: "Remote hearing" + } + ]; + + const options = { + locale: "en", + courtName: "First-tier Tribunal (War Pensions and Armed Forces Compensation)", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "First-tier Tribunal (War Pensions and Armed Forces Compensation) Weekly Hearing List" + }; + + // Act + const result = renderWpafccWeeklyHearingListData(hearingList, options); + + // Assert + expect(result.header.listTitle).toBe("First-tier Tribunal (War Pensions and Armed Forces Compensation) Weekly Hearing List"); + expect(result.header.weekCommencingDate).toBe("02 January 2025"); + expect(result.header.lastUpdatedDate).toBe("01 January 2025"); + expect(result.header.lastUpdatedTime).toContain("am"); + + expect(result.hearings).toHaveLength(1); + expect(result.hearings[0].date).toBe("02 January 2025"); + expect(result.hearings[0].hearingTime).toBe("10:00am"); + expect(result.hearings[0].caseReferenceNumber).toBe("WPAFCC/2025/001"); + expect(result.hearings[0].caseName).toBe("A Vs B"); + expect(result.hearings[0].panel).toBe("Judge Smith"); + expect(result.hearings[0].modeOfHearing).toBe("Remote"); + expect(result.hearings[0].venue).toBe("WPAFCC Hearing Centre"); + expect(result.hearings[0].additionalInformation).toBe("Remote hearing"); + }); + + it("should render multiple hearings", () => { + // Arrange + const hearingList: WpafccWeeklyHearingList = [ + { + date: "02/01/2025", + hearingTime: "10:00am", + caseReferenceNumber: "WPAFCC/2025/001", + caseName: "A Vs B", + panel: "Judge Smith", + modeOfHearing: "Remote", + venue: "WPAFCC Hearing Centre", + additionalInformation: "" + }, + { + date: "03/01/2025", + hearingTime: "2:00pm", + caseReferenceNumber: "WPAFCC/2025/002", + caseName: "C Vs D", + panel: "Judge Brown, Member Green", + modeOfHearing: "In person", + venue: "WPAFCC Office", + additionalInformation: "In person" + } + ]; + + const options = { + locale: "en", + courtName: "First-tier Tribunal (War Pensions and Armed Forces Compensation)", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "First-tier Tribunal (War Pensions and Armed Forces Compensation) Weekly Hearing List" + }; + + // Act + const result = renderWpafccWeeklyHearingListData(hearingList, options); + + // Assert + expect(result.hearings).toHaveLength(2); + expect(result.hearings[0].date).toBe("02 January 2025"); + expect(result.hearings[1].date).toBe("03 January 2025"); + }); + + it("should handle empty hearing list", () => { + // Arrange + const hearingList: WpafccWeeklyHearingList = []; + + const options = { + locale: "en", + courtName: "First-tier Tribunal (War Pensions and Armed Forces Compensation)", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T09:55:00Z", + listTitle: "First-tier Tribunal (War Pensions and Armed Forces Compensation) Weekly Hearing List" + }; + + // Act + const result = renderWpafccWeeklyHearingListData(hearingList, options); + + // Assert + expect(result.hearings).toHaveLength(0); + expect(result.header.listTitle).toBe("First-tier Tribunal (War Pensions and Armed Forces Compensation) Weekly Hearing List"); + }); + + it("should format lastUpdated PM time correctly", () => { + // Arrange + const hearingList: WpafccWeeklyHearingList = [ + { + date: "02/01/2025", + hearingTime: "10:00am", + caseReferenceNumber: "WPAFCC/2025/001", + caseName: "A Vs B", + panel: "Judge Smith", + modeOfHearing: "Remote", + venue: "WPAFCC Hearing Centre", + additionalInformation: "" + } + ]; + + const options = { + locale: "en", + courtName: "First-tier Tribunal (War Pensions and Armed Forces Compensation)", + contentDate: new Date(2025, 0, 2), + lastReceivedDate: "2025-01-01T14:30:00Z", + listTitle: "First-tier Tribunal (War Pensions and Armed Forces Compensation) Weekly Hearing List" + }; + + // Act + const result = renderWpafccWeeklyHearingListData(hearingList, options); + + // Assert + expect(result.header.lastUpdatedTime).toBe("2:30pm"); + }); +}); diff --git a/libs/list-types/wpafcc-weekly-hearing-list/src/rendering/renderer.ts b/libs/list-types/wpafcc-weekly-hearing-list/src/rendering/renderer.ts new file mode 100644 index 000000000..bd6f5118f --- /dev/null +++ b/libs/list-types/wpafcc-weekly-hearing-list/src/rendering/renderer.ts @@ -0,0 +1,46 @@ +import { formatDdMmYyyyDate, formatDisplayDate, formatLastUpdatedDateTime } from "@hmcts/list-types-common"; +import type { WpafccWeeklyHearing, WpafccWeeklyHearingList } from "../models/types.js"; + +export interface RenderOptions { + locale: string; + courtName: string; + contentDate: Date; + lastReceivedDate: string; + listTitle: string; +} + +export interface RenderedData { + header: { + listTitle: string; + weekCommencingDate: string; + lastUpdatedDate: string; + lastUpdatedTime: string; + }; + hearings: WpafccWeeklyHearing[]; +} + +export function renderWpafccWeeklyHearingListData(hearingList: WpafccWeeklyHearingList, options: RenderOptions): RenderedData { + const weekCommencingDate = formatDisplayDate(options.contentDate, options.locale); + const { date: lastUpdatedDate, time: lastUpdatedTime } = formatLastUpdatedDateTime(options.lastReceivedDate, options.locale); + + const renderedHearings = hearingList.map((hearing) => ({ + date: formatDdMmYyyyDate(hearing.date, options.locale), + hearingTime: hearing.hearingTime, + caseReferenceNumber: hearing.caseReferenceNumber, + caseName: hearing.caseName, + panel: hearing.panel, + modeOfHearing: hearing.modeOfHearing, + venue: hearing.venue, + additionalInformation: hearing.additionalInformation + })); + + return { + header: { + listTitle: options.listTitle, + weekCommencingDate, + lastUpdatedDate, + lastUpdatedTime + }, + hearings: renderedHearings + }; +} diff --git a/libs/list-types/wpafcc-weekly-hearing-list/src/schemas/wpafcc-weekly-hearing-list.json b/libs/list-types/wpafcc-weekly-hearing-list/src/schemas/wpafcc-weekly-hearing-list.json new file mode 100644 index 000000000..0bafe7d5b --- /dev/null +++ b/libs/list-types/wpafcc-weekly-hearing-list/src/schemas/wpafcc-weekly-hearing-list.json @@ -0,0 +1,68 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Root", + "type": "array", + "items": { + "title": "Hearing list", + "type": "object", + "required": ["date", "hearingTime", "caseReferenceNumber", "caseName", "panel", "modeOfHearing", "venue", "additionalInformation"], + "properties": { + "date": { + "title": "Date of hearing", + "type": "string", + "default": "", + "examples": ["02/01/2025"], + "pattern": "^\\d{2}/\\d{2}/\\d{4}$" + }, + "hearingTime": { + "title": "Time of hearing", + "type": "string", + "default": "", + "examples": ["10:30am"], + "pattern": "^\\d{1,2}([:.]\\d{2})?[ap]m\\s*$" + }, + "caseReferenceNumber": { + "title": "Case reference number", + "type": "string", + "default": "", + "examples": ["12345"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "caseName": { + "title": "Case name", + "type": "string", + "default": "", + "examples": ["A Vs B"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "panel": { + "title": "Panel", + "type": "string", + "default": "", + "examples": ["Firstname Surname"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "modeOfHearing": { + "title": "Mode of hearing being presented", + "type": "string", + "default": "", + "examples": ["Oral Hearing"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "venue": { + "title": "Venue name of the hearing", + "type": "string", + "default": "", + "examples": ["This is the venue of the hearing"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + }, + "additionalInformation": { + "title": "Additional information", + "type": "string", + "default": "", + "examples": ["This is additional information"], + "pattern": "^(?!(.|\\r|\\n)*<[^>]+>)(.|\\r|\\n)*$" + } + } + } +} diff --git a/libs/list-types/wpafcc-weekly-hearing-list/src/validation/json-validator.test.ts b/libs/list-types/wpafcc-weekly-hearing-list/src/validation/json-validator.test.ts new file mode 100644 index 000000000..8b3ba219d --- /dev/null +++ b/libs/list-types/wpafcc-weekly-hearing-list/src/validation/json-validator.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { validateWpafccWeeklyHearingList } from "./json-validator.js"; + +describe("validateWpafccWeeklyHearingList", () => { + it("should return isValid true for valid data", () => { + // Arrange + const validData = [ + { + date: "01/01/2025", + hearingTime: "10:30am", + caseReferenceNumber: "12345", + caseName: "A Vs B", + panel: "Firstname Surname", + modeOfHearing: "Oral Hearing", + venue: "This is the venue of the hearing", + additionalInformation: "This is additional information" + } + ]; + + // Act + const result = validateWpafccWeeklyHearingList(validData); + + // Assert + expect(result.isValid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("should return isValid false when required fields are missing", () => { + // Arrange + const invalidData = [ + { + date: "01/01/2025" + } + ]; + + // Act + const result = validateWpafccWeeklyHearingList(invalidData); + + // Assert + expect(result.isValid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); +}); diff --git a/libs/list-types/wpafcc-weekly-hearing-list/src/validation/json-validator.ts b/libs/list-types/wpafcc-weekly-hearing-list/src/validation/json-validator.ts new file mode 100644 index 000000000..75bce4d1e --- /dev/null +++ b/libs/list-types/wpafcc-weekly-hearing-list/src/validation/json-validator.ts @@ -0,0 +1,6 @@ +import { createJsonValidator, type ValidationResult } from "@hmcts/list-types-common"; +import { schemaPath } from "../config.js"; + +export function validateWpafccWeeklyHearingList(jsonData: unknown): ValidationResult { + return createJsonValidator(schemaPath)(jsonData); +} diff --git a/libs/list-types/wpafcc-weekly-hearing-list/tsconfig.json b/libs/list-types/wpafcc-weekly-hearing-list/tsconfig.json new file mode 100644 index 000000000..a029ee937 --- /dev/null +++ b/libs/list-types/wpafcc-weekly-hearing-list/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules", "src/assets/"] +} diff --git a/requirements/migrations/.gitkeep b/requirements/migrations/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/requirements/migrations/001_inferred_links.sql b/requirements/migrations/001_inferred_links.sql new file mode 100644 index 000000000..59629770f --- /dev/null +++ b/requirements/migrations/001_inferred_links.sql @@ -0,0 +1,87 @@ +-- 001: inferred traceability links between requirements +-- +-- 66 links derived from requirement CONTENT (not GitHub structure) by a +-- multi-agent analysis: cluster -> propose per cluster -> adversarial verification +-- (each candidate had to survive a skeptic that defaulted to rejection). +-- +-- These are JUDGEMENT, not fact. Every row is origin='inferred' and is_suspect=1 +-- (needs human review). The 21 structural links in seed.sql (github_subissue, +-- issue_reference) are origin facts and are NOT touched here. +-- +-- To review: SELECT * FROM requirement_link WHERE origin='inferred' ORDER BY confidence DESC; +-- To confirm: UPDATE requirement_link SET is_suspect=0 WHERE id=...; +-- To reject: DELETE FROM requirement_link WHERE id=...; + +BEGIN TRANSACTION; + +INSERT INTO requirement_link + (source_id, target_id, type, origin, confidence, rationale, is_suspect, created_at) +VALUES + (6, 5, 'depends_on', 'inferred', 0.85, 'The CFT IDAM sign-in (6) is one of the sign-in methods a user reaches after choosing on the ''How do you want to sign in'' selection page (5), so it depends on that page existing.', 1, '2026-06-09T00:00:00Z'), + (6, 8, 'depends_on', 'inferred', 0.6, 'Sign-in (6) stores/reads the details of users who sign in, which requires the user table (8) to exist.', 1, '2026-06-09T00:00:00Z'), + (6, 34, 'depends_on', 'inferred', 0.6, 'Signing in as a verified user (6) requires a verified account to have been created first (34).', 1, '2026-06-09T00:00:00Z'), + (9, 6, 'depends_on', 'inferred', 0.7, 'Forgotten-password recovery (9) operates on the verified account credentials used by the CFT IDAM sign-in flow (6), so it presupposes that sign-in mechanism.', 1, '2026-06-09T00:00:00Z'), + (11, 66, 'depends_on', 'inferred', 0.6, 'Restricting public users from private/classified information depends on the authentication on classified publications mechanism (REQ-0066) that assigns sensitivity levels.', 1, '2026-06-09T00:00:00Z'), + (20, 19, 'depends_on', 'inferred', 0.85, 'The upload form is reached from the local admin dashboard, so the form step needs the dashboard entry point to exist first.', 1, '2026-06-09T00:00:00Z'), + (21, 20, 'depends_on', 'inferred', 0.9, 'Confirming upload details requires the upload form to have submitted the file and its metadata first.', 1, '2026-06-09T00:00:00Z'), + (27, 23, 'depends_on', 'inferred', 0.85, 'Completing the excel upload process requires the initial Excel file upload step to have provided the file.', 1, '2026-06-09T00:00:00Z'), + (29, 47, 'depends_on', 'inferred', 0.55, 'Removal is restricted to admins who must first authenticate via admin SSO (REQ-0047) to access the removal functionality.', 1, '2026-06-09T00:00:00Z'), + (31, 29, 'refines', 'inferred', 0.9, '''Select content to remove'' is a specific step within the broader ''Remove publication'' multi-step process described in REQ-0029.', 1, '2026-06-09T00:00:00Z'), + (31, 30, 'depends_on', 'inferred', 0.85, 'Selecting content to remove requires first finding the venue/content, so the selection step depends on the find step preceding it in the flow.', 1, '2026-06-09T00:00:00Z'), + (32, 29, 'refines', 'inferred', 0.9, 'The ''Are you sure you want to remove this content?'' confirmation page is a specific step within the broader ''Remove publication'' process in REQ-0029.', 1, '2026-06-09T00:00:00Z'), + (32, 31, 'depends_on', 'inferred', 0.85, 'The removal confirmation page operates on the content chosen in the ''Select content to remove'' step, so it depends on that selection having been made.', 1, '2026-06-09T00:00:00Z'), + (33, 29, 'refines', 'inferred', 0.9, '''File Removal Successful'' is the final step of the broader ''Remove publication'' process described in REQ-0029.', 1, '2026-06-09T00:00:00Z'), + (34, 8, 'depends_on', 'inferred', 0.85, 'Creating a verified account requires persisting user details, which depends on the user table created in REQ-0008.', 1, '2026-06-09T00:00:00Z'), + (36, 38, 'depends_on', 'inferred', 0.85, 'Uploading reference data (location details) needs the location database schema in place to store it.', 1, '2026-06-09T00:00:00Z'), + (39, 6, 'depends_on', 'inferred', 0.7, 'The Verified User dashboard is shown after a verified user signs in via CFT IDAM, so it depends on the verified sign-in flow.', 1, '2026-06-09T00:00:00Z'), + (40, 34, 'depends_on', 'inferred', 0.78, 'Email subscriptions require an approved verified account, which is created via the account-creation requirement (REQ-0034).', 1, '2026-06-09T00:00:00Z'), + (41, 40, 'refines', 'inferred', 0.8, '''How do you want to add an email subscription'' is a specific step within the broader email-subscriptions feature (REQ-0040).', 1, '2026-06-09T00:00:00Z'), + (42, 40, 'refines', 'inferred', 0.78, 'This duplicate ''how do you want to add a subscription'' page is a specific step refining the overall email-subscriptions feature (REQ-0040).', 1, '2026-06-09T00:00:00Z'), + (43, 40, 'depends_on', 'inferred', 0.78, 'Unsubscribing requires existing email subscriptions created via the subscriptions feature (REQ-0040).', 1, '2026-06-09T00:00:00Z'), + (45, 44, 'depends_on', 'inferred', 0.72, 'Choosing which version of the list to receive presupposes a list type has already been selected (REQ-0044).', 1, '2026-06-09T00:00:00Z'), + (48, 46, 'depends_on', 'inferred', 0.78, 'The System Admin dashboard is only reachable after a system admin signs in via SSO, so it depends on the System Admin single sign-on.', 1, '2026-06-09T00:00:00Z'), + (49, 10, 'depends_on', 'inferred', 0.6, 'Landing Page - Part 2 builds on the initial Landing Page header/footer work (REQ-0010), continuing the same landing page navigation.', 1, '2026-06-09T00:00:00Z'), + (50, 36, 'depends_on', 'inferred', 0.6, 'Creating a court venue relies on reference data (jurisdictions, regions) being uploaded so courts can be associated with valid reference values.', 1, '2026-06-09T00:00:00Z'), + (50, 38, 'depends_on', 'inferred', 0.9, 'Creating courts requires the location details schema (location, jurisdiction, sub-jurisdiction, region) to exist to store the court against.', 1, '2026-06-09T00:00:00Z'), + (51, 37, 'depends_on', 'inferred', 0.85, 'Blob ingestion explicitly ingests and validates a JSON file from a source system through an API connection.', 1, '2026-06-09T00:00:00Z'), + (53, 16, 'refines', 'inferred', 0.6, 'Display of Pubs ''What court or tribunal are you interested in?'' is the publication-display-specific version of the generic select-a-court-or-tribunal step.', 1, '2026-06-09T00:00:00Z'), + (56, 55, 'depends_on', 'inferred', 0.68, 'Viewing a flat file requires first selecting a publication from the Summary of Pubs page that lists available publications.', 1, '2026-06-09T00:00:00Z'), + (58, 59, 'depends_on', 'inferred', 0.72, 'REQ-0058 handles errors in the JSON-HTML conversion/rendering process, which is the process defined and governed by the validation schema and style guide integration spec in REQ-0059.', 1, '2026-06-09T00:00:00Z'), + (60, 40, 'depends_on', 'inferred', 0.82, 'Subscription fulfilment (sending email notifications) can only trigger once users have set up email subscriptions (REQ-0040).', 1, '2026-06-09T00:00:00Z'), + (62, 34, 'depends_on', 'inferred', 0.85, 'Approving a media account request requires an application that was submitted via the account creation form in REQ-0034.', 1, '2026-06-09T00:00:00Z'), + (63, 34, 'depends_on', 'inferred', 0.85, 'Rejecting a media account request requires an application submitted via the account creation form in REQ-0034.', 1, '2026-06-09T00:00:00Z'), + (66, 7, 'depends_on', 'inferred', 0.65, 'Authentication on classified publications (66) gates content by user group, which relies on the public/media authentication established by the B2C sign-in (7).', 1, '2026-06-09T00:00:00Z'), + (72, 43, 'refines', 'inferred', 0.72, 'Bulk unsubscribe is a more specific batch version of the single unsubscribe process (REQ-0043).', 1, '2026-06-09T00:00:00Z'), + (75, 48, 'depends_on', 'inferred', 0.9, 'Blob explorer and re-submission trigger are accessed through the System Admin dashboard, which must exist first as the control panel hosting these functions.', 1, '2026-06-09T00:00:00Z'), + (76, 48, 'depends_on', 'inferred', 0.9, 'The Audit Log View is reached via the System Admin dashboard tile, so the dashboard must exist to host and route to it.', 1, '2026-06-09T00:00:00Z'), + (77, 50, 'depends_on', 'inferred', 0.9, 'Deleting a court process presupposes courts have been created in CaTH via the create-court functionality.', 1, '2026-06-09T00:00:00Z'), + (78, 48, 'depends_on', 'inferred', 0.88, 'Third Party User Management (Future) is a system administrative function surfaced through the System Admin dashboard, which it depends on.', 1, '2026-06-09T00:00:00Z'), + (80, 23, 'depends_on', 'inferred', 0.7, 'RCJ hearing lists use the non-strategic Excel upload route, so they depend on the Excel file upload capability.', 1, '2026-06-09T00:00:00Z'), + (81, 27, 'depends_on', 'inferred', 0.72, 'REQ-0081 implements Welsh translation for the Care Standards weekly hearing list, which depends on the Care Standards list publishing/display established in REQ-0027.', 1, '2026-06-09T00:00:00Z'), + (83, 57, 'depends_on', 'inferred', 0.6, 'REQ-0083''s PDF and email summary for the Civil and Family list depends on the list''s validation schema and style guide created in REQ-0057.', 1, '2026-06-09T00:00:00Z'), + (84, 38, 'depends_on', 'inferred', 0.7, 'The RCJ/Rolls Building caution message requires location metadata changes in the database, depending on the location details schema.', 1, '2026-06-09T00:00:00Z'), + (85, 8, 'depends_on', 'inferred', 0.8, 'User Management administers user records, which must first exist in the user table created by REQ-0008.', 1, '2026-06-09T00:00:00Z'), + (85, 48, 'depends_on', 'inferred', 0.8, 'User Management is a system administrative function performed through the System Admin dashboard and depends on it as the host.', 1, '2026-06-09T00:00:00Z'), + (88, 20, 'depends_on', 'inferred', 0.8, 'Merging manual upload (flat file) tests requires the manual upload functionality and its existing test files to exist.', 1, '2026-06-09T00:00:00Z'), + (91, 48, 'depends_on', 'inferred', 0.88, 'Third Party User Management (Current) is accessed through the System Admin dashboard and depends on it as the host control panel.', 1, '2026-06-09T00:00:00Z'), + (95, 38, 'depends_on', 'inferred', 0.8, 'Uploading reference data (location details) requires the location database schema to store it.', 1, '2026-06-09T00:00:00Z'), + (99, 40, 'depends_on', 'inferred', 0.55, 'Delivering subscription emails for SJP presupposes verified users have set up email subscriptions, the capability defined in REQ-0040.', 1, '2026-06-09T00:00:00Z'), + (99, 60, 'depends_on', 'inferred', 0.8, 'REQ-0099 is about users receiving the four configured subscription emails, which relies on the backend subscription fulfilment (email notification triggering) defined in REQ-0060.', 1, '2026-06-09T00:00:00Z'), + (99, 117, 'depends_on', 'inferred', 0.95, 'REQ-0099 explicitly opens ''Once excel generation for SJP has been implemented'', so the complete SJP subscription email journey depends on REQ-0117 generating the SJP Excel file.', 1, '2026-06-09T00:00:00Z'), + (100, 34, 'depends_on', 'inferred', 0.6, 'The B2C media user creation journey completes the media account application started by the account creation form in REQ-0034.', 1, '2026-06-09T00:00:00Z'), + (100, 62, 'depends_on', 'inferred', 0.85, 'Azure B2C media user creation is explicitly triggered once the CTSC Admin approves the media application, which is REQ-0062.', 1, '2026-06-09T00:00:00Z'), + (103, 48, 'depends_on', 'inferred', 0.72, 'System Admin Data Management (reference/jurisdiction data) is an administrative function accessed via the System Admin dashboard control panel.', 1, '2026-06-09T00:00:00Z'), + (115, 74, 'depends_on', 'inferred', 0.75, 'Pages can only read list types from the database once the list type configuration and database tables have been set up.', 1, '2026-06-09T00:00:00Z'), + (122, 63, 'refines', 'inferred', 0.88, 'REQ-0122 specifies the proof-of-ID document cleanup behaviour that must occur during the media application rejection flow defined in REQ-0063.', 1, '2026-06-09T00:00:00Z'), + (123, 132, 'depends_on', 'inferred', 0.5, 'A working STG deployment from master (123) depends on STG/master builds correctly loading secrets from the cath-owned Key Vaults configured in 132.', 1, '2026-06-09T00:00:00Z'), + (126, 61, 'depends_on', 'inferred', 0.7, 'Creating Flux overlays for additional environments (126) extends the base flux config / k8s namespace and postgres flux db originally set up in 61.', 1, '2026-06-09T00:00:00Z'), + (128, 36, 'refines', 'inferred', 0.8, 'Adding provenance to reference data upload backend logic is a more specific extension of the reference data upload requirement.', 1, '2026-06-09T00:00:00Z'), + (128, 103, 'depends_on', 'inferred', 0.65, 'The reference data upload backend logic update builds on the Data Management functionality that handles reference and jurisdiction data upload.', 1, '2026-06-09T00:00:00Z'), + (129, 127, 'depends_on', 'inferred', 0.6, 'Provisioning per-environment Azure resources and Key Vaults (129) builds on the bootstrap Key Vault infrastructure pattern established in the cath-service infra folder by 127.', 1, '2026-06-09T00:00:00Z'), + (130, 126, 'depends_on', 'inferred', 0.75, 'Deploy workflows pushing promoted images (130) target the ITHC/Demo/Test environments whose Flux overlays are created in 126.', 1, '2026-06-09T00:00:00Z'), + (130, 129, 'depends_on', 'inferred', 0.8, 'Environment-specific deploy workflows (130) require the GitHub Actions credentials and Azure resources for those environments configured in 129.', 1, '2026-06-09T00:00:00Z'), + (131, 130, 'depends_on', 'inferred', 0.8, 'Auto-syncing master to lower environment branches (131) only triggers deployments if the per-branch deploy workflows from 130 exist to react to those pushes.', 1, '2026-06-09T00:00:00Z'), + (132, 127, 'depends_on', 'inferred', 0.9, 'Configuring Helm values to load secrets from cath-bootstrap-stg-kv and cath-stg (132) requires those bootstrap Key Vaults to be provisioned first by 127.', 1, '2026-06-09T00:00:00Z'), + (134, 38, 'depends_on', 'inferred', 0.8, 'Seeding regions and sub-jurisdictions requires the location schema that defines region and sub-jurisdiction structures.', 1, '2026-06-09T00:00:00Z'); + +COMMIT; diff --git a/requirements/migrations/002_sync_github_2026_06_18.sql b/requirements/migrations/002_sync_github_2026_06_18.sql new file mode 100644 index 000000000..d31bbc3aa --- /dev/null +++ b/requirements/migrations/002_sync_github_2026_06_18.sql @@ -0,0 +1,178 @@ +-- 002: sync github 2026-06-18 +-- +-- Reconciles the requirements baseline with the live state of GitHub Project #43 +-- (CaTH Kanban) as of 2026-06-18. Generated by a manual, reviewed sync (not the +-- nightly job) while feat/requirements-doors-db is still unmerged. Matches the +-- conventions of seed.sql and 001_inferred_links.sql. +-- +-- Delta captured (gate = "Refined Tickets" and beyond): +-- * 3 NEW requirements: REQ-0135 (#569), REQ-0136 (#716), REQ-0137 (#729) +-- * 6 STATUS changes: REQ-0105 (#428), REQ-0107 (#431), REQ-0109 (#436), +-- REQ-0112 (#467), REQ-0121 (#545), REQ-0132 (#586) +-- * 4 STATUS + IMPL changes: REQ-0111 (#466), REQ-0119 (#511), REQ-0133 (#594), +-- REQ-0134 (#678) — moved to verified and gained a +-- merged closing PR (impl_commit_sha + impl_paths). +-- * 4 REGRESSIONS recorded: REQ-0126 (#566) in_progress->draft [Backlog], +-- REQ-0129 (#583), REQ-0130 (#584), REQ-0131 (#585) +-- approved->proposed [Prioritised Backlog]. +-- Recorded as normal status_changed history (the DOORS way: identifiers and +-- baselines are immutable, but a status attribute may regress and the change is +-- logged). Below-gate boards map: Backlog->draft, Prioritised Backlog->proposed. +-- No row is deleted and no ref is reused. +-- +-- All affected rows are at version 1, so each edit below bumps them to version 2. + +BEGIN TRANSACTION; + +-- ============================================================================ +-- NEW requirements (REQ-0135..REQ-0137), ordered by issue number. +-- kind defaults to 'functional'; no priority/granularity labels on these issues. +-- ============================================================================ +INSERT INTO requirement + (id, ref, title, statement, kind, status, priority, granularity, + issue_number, issue_url, impl_commit_sha, impl_paths, + created_at, updated_at, created_by, updated_by) +VALUES + (135, 'REQ-0135', 'Add Azure Blob Storage to cath-service infrastructure Terraform', '## User Story + +As a platform engineer, I want Azure Blob Storage provisioned for the cath-service so that application files (publication JSON, generated PDFs, media application ID proof images) can be stored in durable cloud storage rather than the local pod filesystem. + +## Background + +Currently all file storage in cath-service writes to the local filesystem (`storage/temp/uploads`, `storage/temp/files`). Files are lost on every Kubernetes pod restart or redeployment. Azure Blob Storage is the correct durable store for these files. + +Both `pip-data-management` and `pip-account-management` use a shared storage account provisioned in [`pip-shared-infrastructures`](https://github.com/hmcts/pip-shared-infrastructures) via `cnp-module-storage-account@4.x`. cath-service needs its own equivalent. + +## Infrastructure Changes Required + +### 1. New `infrastructure/storage.tf` + +Create using `cnp-module-storage-account@4.x` with: + +- **Storage account name**: `cathsa${env}` → e.g. `cathsastg`, `cathsaprod`, `cathsaithc`, `cathsademo`, `cathsatest` +- **3 private blob containers**: `artefact`, `files`, `publications` +- **Managed identity access**: reference the existing `cath-${var.env}-mi` (created by `cnp-module-key-vault` when `create_managed_identity = true`) via a `data "azurerm_user_assigned_identity"` lookup in `managed-identities-${var.env}-rg`. Grant it `Storage Blob Data Contributor` role. +- **No separate managed identity is needed** — the existing KV module MI is reused (same pattern as pip-shared-infrastructures) +- Standard settings: StorageV2, Standard tier, RAGRS replication, Cool access tier + +### 2. Update `infrastructure/variables.tf` + +Add storage account configuration variables (with defaults matching pip pattern): +- `sa_account_tier` — default `"Standard"` +- `sa_account_kind` — default `"StorageV2"` +- `sa_account_replication_type` — default `"RAGRS"` +- `sa_access_tier` — default `"Cool"` + +### 3. Add Key Vault secrets + +Add `azurerm_key_vault_secret` resources to store: +- `storageaccount-connection-string` → `module.sa.storageaccount_primary_connection_string` +- `storageaccount-name` → `module.sa.storageaccount_name` + +### 4. Update Helm values + +Update `apps/*/helm/values.yaml` (stg/prod) to inject from Key Vault: +- `storageaccount-name` → alias `AZURE_STORAGE_ACCOUNT_NAME` +- `storageaccount-connection-string` → alias `AZURE_STORAGE_CONNECTION_STRING` + +Add `MANAGED_IDENTITY_CLIENT_ID` as a direct environment variable in `values.stg.template.yaml` / `values.prod.template.yaml` (same pip pattern — not from Key Vault, injected directly as the MI client ID for the environment). + +## Reference + +- `pip-shared-infrastructures/tf-sa-main.tf` — storage account module call +- `pip-shared-infrastructures/main.tf` — `data "azurerm_user_assigned_identity" "app_mi"` pattern +- `pip-shared-infrastructures/tf-kv-secrets.tf` — Key Vault secrets +- `pip-data-management/charts/pip-data-management/values.yaml` — Helm values pattern + +## Acceptance Criteria + +- [ ] `infrastructure/storage.tf` created using `cnp-module-storage-account@4.x` +- [ ] Storage account named `cathsa${env}` (e.g. `cathsastg`, `cathsaprod`) +- [ ] 3 private containers provisioned: `artefact`, `files`, `publications` +- [ ] Existing `cath-${env}-mi` managed identity granted `Storage Blob Data Contributor` role on the storage account +- [ ] Key Vault secrets `storageaccount-connection-string` and `storageaccount-name` created +- [ ] Helm values updated to inject storage env vars from Key Vault +- [ ] `MANAGED_IDENTITY_CLIENT_ID` injected in stg/prod template values files +- [ ] `terraform plan` produces no errors', 'functional', 'in_progress', NULL, NULL, 569, 'https://github.com/hmcts/cath-service/issues/569', NULL, NULL, '2026-05-12T11:00:17Z', '2026-05-12T11:00:17Z', 'github-actions[bot]', 'github-actions[bot]'), + (136, 'REQ-0136', 'Create App Registration for CaTH API', 'We need to ask PlatOps team to create App registration for CaTH API Service with role api.publisher.user. + + 1. Create cath-service-api app registration + 2. Add api.publisher.user custom app role on it + 3. Grant cath-service-api''s own service principal the api.publisher.user role on itself + +By doing this, we will not use pip-data-management app registration anymore.', 'functional', 'verified', NULL, NULL, 716, 'https://github.com/hmcts/cath-service/issues/716', NULL, NULL, '2026-06-12T08:08:48Z', '2026-06-12T08:08:48Z', 'github-actions[bot]', 'github-actions[bot]'), + (137, 'REQ-0137', 'Make the footer font size the same as in OG CaTH', 'Currently the footer font size in AI CaTH is bigger than the OG CaTH. Make them consistent', 'functional', 'approved', NULL, NULL, 729, 'https://github.com/hmcts/cath-service/issues/729', NULL, NULL, '2026-06-17T09:11:01Z', '2026-06-17T09:11:01Z', 'github-actions[bot]', 'github-actions[bot]'); + +-- 'created' history for the new requirements (changed_at = issue createdAt). +INSERT INTO requirement_change + (requirement_id, version, change_type, change_summary, changed_by, changed_at) +VALUES + (135, 1, 'created', 'imported from GitHub issue', 'github-actions[bot]', '2026-05-12T11:00:17Z'), + (136, 1, 'created', 'imported from GitHub issue', 'github-actions[bot]', '2026-06-12T08:08:48Z'), + (137, 1, 'created', 'imported from GitHub issue', 'github-actions[bot]', '2026-06-17T09:11:01Z'); + +-- ============================================================================ +-- STATUS-only changes (board moved forward; no merged PR on these issues). +-- ============================================================================ +UPDATE requirement SET status='in_progress', version=version+1, updated_at='2026-06-18T00:00:00Z' WHERE id=105; -- REQ-0105 #428 approved->in_progress +UPDATE requirement SET status='implemented', version=version+1, updated_at='2026-06-18T00:00:00Z' WHERE id=107; -- REQ-0107 #431 approved->implemented +UPDATE requirement SET status='implemented', version=version+1, updated_at='2026-06-18T00:00:00Z' WHERE id=109; -- REQ-0109 #436 approved->implemented +UPDATE requirement SET status='implemented', version=version+1, updated_at='2026-06-18T00:00:00Z' WHERE id=112; -- REQ-0112 #467 in_progress->implemented +UPDATE requirement SET status='verified', version=version+1, updated_at='2026-06-18T00:00:00Z' WHERE id=121; -- REQ-0121 #545 implemented->verified +UPDATE requirement SET status='verified', version=version+1, updated_at='2026-06-18T00:00:00Z' WHERE id=132; -- REQ-0132 #586 approved->verified + +INSERT INTO requirement_change + (requirement_id, version, field, old_value, new_value, change_type, change_summary, changed_by, changed_at) +VALUES + (105, 2, 'status', 'approved', 'in_progress', 'status_changed', 'board status moved', 'github-actions[bot]', '2026-06-18T00:00:00Z'), + (107, 2, 'status', 'approved', 'implemented', 'status_changed', 'board status moved', 'github-actions[bot]', '2026-06-18T00:00:00Z'), + (109, 2, 'status', 'approved', 'implemented', 'status_changed', 'board status moved', 'github-actions[bot]', '2026-06-18T00:00:00Z'), + (112, 2, 'status', 'in_progress', 'implemented', 'status_changed', 'board status moved', 'github-actions[bot]', '2026-06-18T00:00:00Z'), + (121, 2, 'status', 'implemented', 'verified', 'status_changed', 'board status moved', 'github-actions[bot]', '2026-06-18T00:00:00Z'), + (132, 2, 'status', 'approved', 'verified', 'status_changed', 'board status moved', 'github-actions[bot]', '2026-06-18T00:00:00Z'); + +-- ============================================================================ +-- STATUS + IMPL changes (moved to verified AND gained a merged closing PR). +-- One UPDATE per row, version bumped once, all change rows at the new version. +-- ============================================================================ +UPDATE requirement SET status='verified', impl_commit_sha='8aec7a54d61f8a20338c68afcf6c893bcf470fde', impl_paths='["apps/web/src/assets/css/admin-pages.scss","apps/web/src/pages/(admin)/remove-list-search-results/cy.ts","apps/web/src/pages/(admin)/remove-list-search-results/en.ts","apps/web/src/pages/(admin)/remove-list-search-results/index.njk","apps/web/src/pages/(admin)/remove-list-search-results/index.test.ts","apps/web/src/pages/(admin)/remove-list-search-results/index.ts"]', version=version+1, updated_at='2026-06-18T00:00:00Z' WHERE id=111; -- REQ-0111 #466 +UPDATE requirement SET status='verified', impl_commit_sha='b5e95289f9b16a5130ce6c0326b76979dc24c102', impl_paths='["apps/postgres/prisma/migrations/20260608155746_remove_channel_sensitivity_from_legacy_third_party_subscription/migration.sql","apps/web/src/pages/(system-admin)/manage-third-party-subscriptions/cy.ts","apps/web/src/pages/(system-admin)/manage-third-party-subscriptions/en.ts","apps/web/src/pages/(system-admin)/manage-third-party-subscriptions/index.njk","apps/web/src/pages/(system-admin)/manage-third-party-subscriptions/index.test.ts","apps/web/src/pages/(system-admin)/manage-third-party-subscriptions/index.ts","apps/web/src/pages/(system-admin)/manage-third-party-user/cy.ts","apps/web/src/pages/(system-admin)/manage-third-party-user/en.ts","apps/web/src/pages/(system-admin)/manage-third-party-user/index.njk","apps/web/src/pages/(system-admin)/manage-third-party-user/index.test.ts","apps/web/src/pages/(system-admin)/manage-third-party-user/index.ts","apps/web/src/pages/(system-admin)/manage-third-party-users/cy.ts","apps/web/src/pages/(system-admin)/manage-third-party-users/en.ts","apps/web/src/pages/(system-admin)/manage-third-party-users/index.njk","apps/web/src/pages/(system-admin)/manage-third-party-users/index.test.ts","apps/web/src/pages/(system-admin)/manage-third-party-users/index.ts","libs/legacy-third-party-fulfilment/src/queries.test.ts","libs/legacy-third-party-fulfilment/src/queries.ts","libs/legacy-third-party-fulfilment/src/service.ts","libs/list-types/common/src/validation/list-type-validator.test.ts","libs/postgres-prisma/prisma/schema/base.prisma","libs/system-admin-pages/src/index.ts","libs/system-admin-pages/src/third-party-user/queries.test.ts","libs/system-admin-pages/src/third-party-user/queries.ts","libs/system-admin-pages/src/third-party-user/validation.test.ts","libs/system-admin-pages/src/third-party-user/validation.ts"]', version=version+1, updated_at='2026-06-18T00:00:00Z' WHERE id=119; -- REQ-0119 #511 +UPDATE requirement SET status='verified', impl_commit_sha='c1a32f8980445875c44f2da95195f915a28d02e7', impl_paths='["apps/web/src/pages/(list-types)/family-daily-cause-list/family-daily-cause-list.njk","libs/list-types/civil-daily-cause-list/src/email-summary/summary-builder.test.ts","libs/list-types/family-daily-cause-list/src/email-summary/summary-builder.test.ts","libs/list-types/family-daily-cause-list/src/email-summary/summary-builder.ts"]', version=version+1, updated_at='2026-06-18T00:00:00Z' WHERE id=133; -- REQ-0133 #594 +UPDATE requirement SET status='verified', impl_commit_sha='f12926d172cdd521694d7b8f2b8c2d1fb3b8f28a', impl_paths='["apps/web/src/pages/(public)/courts-tribunals-list/index.test.ts","e2e-tests/utils/seed-reference-data.ts","libs/list-types/common/src/validation/list-type-validator.test.ts","libs/location/src/location-data.ts"]', version=version+1, updated_at='2026-06-18T00:00:00Z' WHERE id=134; -- REQ-0134 #678 + +INSERT INTO requirement_change + (requirement_id, version, field, old_value, new_value, change_type, change_summary, changed_by, changed_at) +VALUES + (111, 2, 'status', 'implemented', 'verified', 'status_changed', 'board status moved', 'github-actions[bot]', '2026-06-18T00:00:00Z'), + (111, 2, 'impl_commit_sha', NULL, '8aec7a54d61f8a20338c68afcf6c893bcf470fde', 'modified', 'merged PR(s) changed', 'github-actions[bot]', '2026-06-18T00:00:00Z'), + (111, 2, 'impl_paths', NULL, '["apps/web/src/assets/css/admin-pages.scss","apps/web/src/pages/(admin)/remove-list-search-results/cy.ts","apps/web/src/pages/(admin)/remove-list-search-results/en.ts","apps/web/src/pages/(admin)/remove-list-search-results/index.njk","apps/web/src/pages/(admin)/remove-list-search-results/index.test.ts","apps/web/src/pages/(admin)/remove-list-search-results/index.ts"]', 'modified', 'merged PR(s) changed', 'github-actions[bot]', '2026-06-18T00:00:00Z'), + (119, 2, 'status', 'implemented', 'verified', 'status_changed', 'board status moved', 'github-actions[bot]', '2026-06-18T00:00:00Z'), + (119, 2, 'impl_commit_sha', NULL, 'b5e95289f9b16a5130ce6c0326b76979dc24c102', 'modified', 'merged PR(s) changed', 'github-actions[bot]', '2026-06-18T00:00:00Z'), + (119, 2, 'impl_paths', NULL, '["apps/postgres/prisma/migrations/20260608155746_remove_channel_sensitivity_from_legacy_third_party_subscription/migration.sql","apps/web/src/pages/(system-admin)/manage-third-party-subscriptions/cy.ts","apps/web/src/pages/(system-admin)/manage-third-party-subscriptions/en.ts","apps/web/src/pages/(system-admin)/manage-third-party-subscriptions/index.njk","apps/web/src/pages/(system-admin)/manage-third-party-subscriptions/index.test.ts","apps/web/src/pages/(system-admin)/manage-third-party-subscriptions/index.ts","apps/web/src/pages/(system-admin)/manage-third-party-user/cy.ts","apps/web/src/pages/(system-admin)/manage-third-party-user/en.ts","apps/web/src/pages/(system-admin)/manage-third-party-user/index.njk","apps/web/src/pages/(system-admin)/manage-third-party-user/index.test.ts","apps/web/src/pages/(system-admin)/manage-third-party-user/index.ts","apps/web/src/pages/(system-admin)/manage-third-party-users/cy.ts","apps/web/src/pages/(system-admin)/manage-third-party-users/en.ts","apps/web/src/pages/(system-admin)/manage-third-party-users/index.njk","apps/web/src/pages/(system-admin)/manage-third-party-users/index.test.ts","apps/web/src/pages/(system-admin)/manage-third-party-users/index.ts","libs/legacy-third-party-fulfilment/src/queries.test.ts","libs/legacy-third-party-fulfilment/src/queries.ts","libs/legacy-third-party-fulfilment/src/service.ts","libs/list-types/common/src/validation/list-type-validator.test.ts","libs/postgres-prisma/prisma/schema/base.prisma","libs/system-admin-pages/src/index.ts","libs/system-admin-pages/src/third-party-user/queries.test.ts","libs/system-admin-pages/src/third-party-user/queries.ts","libs/system-admin-pages/src/third-party-user/validation.test.ts","libs/system-admin-pages/src/third-party-user/validation.ts"]', 'modified', 'merged PR(s) changed', 'github-actions[bot]', '2026-06-18T00:00:00Z'), + (133, 2, 'status', 'implemented', 'verified', 'status_changed', 'board status moved', 'github-actions[bot]', '2026-06-18T00:00:00Z'), + (133, 2, 'impl_commit_sha', NULL, 'c1a32f8980445875c44f2da95195f915a28d02e7', 'modified', 'merged PR(s) changed', 'github-actions[bot]', '2026-06-18T00:00:00Z'), + (133, 2, 'impl_paths', NULL, '["apps/web/src/pages/(list-types)/family-daily-cause-list/family-daily-cause-list.njk","libs/list-types/civil-daily-cause-list/src/email-summary/summary-builder.test.ts","libs/list-types/family-daily-cause-list/src/email-summary/summary-builder.test.ts","libs/list-types/family-daily-cause-list/src/email-summary/summary-builder.ts"]', 'modified', 'merged PR(s) changed', 'github-actions[bot]', '2026-06-18T00:00:00Z'), + (134, 2, 'status', 'implemented', 'verified', 'status_changed', 'board status moved', 'github-actions[bot]', '2026-06-18T00:00:00Z'), + (134, 2, 'impl_commit_sha', NULL, 'f12926d172cdd521694d7b8f2b8c2d1fb3b8f28a', 'modified', 'merged PR(s) changed', 'github-actions[bot]', '2026-06-18T00:00:00Z'), + (134, 2, 'impl_paths', NULL, '["apps/web/src/pages/(public)/courts-tribunals-list/index.test.ts","e2e-tests/utils/seed-reference-data.ts","libs/list-types/common/src/validation/list-type-validator.test.ts","libs/location/src/location-data.ts"]', 'modified', 'merged PR(s) changed', 'github-actions[bot]', '2026-06-18T00:00:00Z'); + +-- ============================================================================ +-- REGRESSIONS — issues that moved BELOW the gate since the baseline. Recorded +-- as status_changed history (DOORS: log the regression, never delete/reuse a +-- ref). Below-gate mapping: Backlog->draft, Prioritised Backlog->proposed. +-- Flagged for human attention in the PR body. +-- ============================================================================ +UPDATE requirement SET status='draft', version=version+1, updated_at='2026-06-18T00:00:00Z' WHERE id=126; -- REQ-0126 #566 Backlog +UPDATE requirement SET status='proposed', version=version+1, updated_at='2026-06-18T00:00:00Z' WHERE id=129; -- REQ-0129 #583 Prioritised Backlog +UPDATE requirement SET status='proposed', version=version+1, updated_at='2026-06-18T00:00:00Z' WHERE id=130; -- REQ-0130 #584 Prioritised Backlog +UPDATE requirement SET status='proposed', version=version+1, updated_at='2026-06-18T00:00:00Z' WHERE id=131; -- REQ-0131 #585 Prioritised Backlog + +INSERT INTO requirement_change + (requirement_id, version, field, old_value, new_value, change_type, change_summary, changed_by, changed_at) +VALUES + (126, 2, 'status', 'in_progress', 'draft', 'status_changed', 'board status regressed below gate (Backlog)', 'github-actions[bot]', '2026-06-18T00:00:00Z'), + (129, 2, 'status', 'approved', 'proposed', 'status_changed', 'board status regressed below gate (Prioritised Backlog)', 'github-actions[bot]', '2026-06-18T00:00:00Z'), + (130, 2, 'status', 'approved', 'proposed', 'status_changed', 'board status regressed below gate (Prioritised Backlog)', 'github-actions[bot]', '2026-06-18T00:00:00Z'), + (131, 2, 'status', 'approved', 'proposed', 'status_changed', 'board status regressed below gate (Prioritised Backlog)', 'github-actions[bot]', '2026-06-18T00:00:00Z'); + +COMMIT; diff --git a/requirements/migrations/003_infer_links_for_github_synced_reqs_135_137.sql b/requirements/migrations/003_infer_links_for_github_synced_reqs_135_137.sql new file mode 100644 index 000000000..150071d8a --- /dev/null +++ b/requirements/migrations/003_infer_links_for_github_synced_reqs_135_137.sql @@ -0,0 +1,31 @@ +-- 003: infer links for github-synced reqs 135-137 +-- +-- Inferred traceability links for the three requirements added by the GitHub +-- sync in 002 (REQ-0135 #569, REQ-0136 #716, REQ-0137 #729). Live issue text +-- was read via the GitHub MCP server; sub-issue structure was checked and none +-- of the three has any (so there are no structural links to record — every link +-- below is judgement, not GitHub fact). +-- +-- Same conventions as 001_inferred_links.sql: origin='inferred', is_suspect=1 +-- (needs human review). Only net-new links are added here; nothing in 001 is +-- touched, and the UNIQUE(source_id, target_id, type) constraint guards dupes. +-- +-- Candidates considered but DROPPED as too weak (shared topic / retrofitted +-- dependency on a pre-existing requirement, no concrete tie in the text): +-- 135 -> 51 depends_on (blob ingestion predates the durable store) +-- 37 -> 136 depends_on (API connection predates the app registration) +-- 137 -> 121 refines (same footer, but only a shared topic area) +-- +-- To review: SELECT * FROM requirement_link WHERE origin='inferred' ORDER BY confidence DESC; +-- To confirm: UPDATE requirement_link SET is_suspect=0 WHERE id=...; +-- To reject: DELETE FROM requirement_link WHERE id=...; + +BEGIN TRANSACTION; + +INSERT INTO requirement_link + (source_id, target_id, type, origin, confidence, rationale, is_suspect, created_at) +VALUES + (135, 127, 'depends_on', 'inferred', 0.6, 'The storage Terraform (135) adds azurerm_key_vault_secret resources into the cath Key Vault and reuses the cath-${env}-mi managed identity from the KV module, so it presupposes the bootstrap Key Vault infrastructure provisioned by 127.', 1, '2026-06-22T00:00:00Z'), + (137, 86, 'refines', 'inferred', 0.6, 'Making the footer font size consistent (137) is a narrower, specific aspect of the broader header and footer update work described in REQ-0086.', 1, '2026-06-22T00:00:00Z'); + +COMMIT; diff --git a/requirements/requirements.db b/requirements/requirements.db new file mode 100644 index 0000000000000000000000000000000000000000..f6d0d18473136559a4bf45eb3cfb264b83b626bc GIT binary patch literal 1126400 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 JWAG0101fb9 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�!r6G=8SnK|<|N2Dhg*``EW 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 0.0 AND confidence <= 1.0)), + -- For inferred links: why the link was proposed. NULL for structural. + rationale TEXT, + + -- Set when either end is edited (on a meaningful field) after the last + -- review, prompting someone to re-check the link still holds. Also used to + -- mark inferred links as needing review. + is_suspect INTEGER NOT NULL DEFAULT 0 + CHECK (is_suspect IN (0, 1)), + + created_at TEXT NOT NULL, + created_by TEXT, + + UNIQUE (source_id, target_id, type), + CHECK (source_id <> target_id) +); + +CREATE INDEX idx_requirement_link_source ON requirement_link (source_id); +CREATE INDEX idx_requirement_link_target ON requirement_link (target_id); + +-- Append-only, field-level change log. One row per changed field; rows sharing +-- the same (requirement_id, version) form one logical edit. The DOORS-native +-- approach: queryable audit trail and precise suspect-link flagging. +CREATE TABLE requirement_change ( + id INTEGER PRIMARY KEY, + + requirement_id INTEGER NOT NULL REFERENCES requirement (id) ON DELETE CASCADE, + version INTEGER NOT NULL, -- groups fields changed in one edit + + field TEXT, -- column name that changed; NULL for 'created' + old_value TEXT, -- stringified previous value + new_value TEXT, -- stringified new value + + -- 'deleted' is reserved for manual obsolete/removal operations; the automated + -- sync never hard-deletes, so it is unused by generated data by design. + change_type TEXT NOT NULL + CHECK (change_type IN ('created', 'modified', + 'status_changed', 'deleted')), + change_summary TEXT, -- optional reason for the change + + changed_by TEXT, + changed_at TEXT NOT NULL +); + +CREATE INDEX idx_requirement_change_requirement ON requirement_change (requirement_id, version); +CREATE INDEX idx_requirement_change_field ON requirement_change (field); diff --git a/requirements/scripts/init_db.sh b/requirements/scripts/init_db.sh new file mode 100755 index 000000000..551eb59a6 --- /dev/null +++ b/requirements/scripts/init_db.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Build the requirements SQLite database from scratch. +# +# Applies, in order: schema.sql, seed.sql, then every migrations/*.sql. +# The resulting .db is a disposable build artefact (gitignored) — the SQL +# files are the source of truth. Re-runnable: rebuilds cleanly every time. +# +# Usage: requirements/scripts/init_db.sh [path/to/output.db] + +set -euo pipefail + +REQ_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DB_PATH="${1:-$REQ_DIR/requirements.db}" + +rm -f "$DB_PATH" + +sqlite3 "$DB_PATH" < "$REQ_DIR/schema.sql" +sqlite3 "$DB_PATH" < "$REQ_DIR/seed.sql" + +shopt -s nullglob +for migration in "$REQ_DIR"/migrations/*.sql; do + echo "Applying $(basename "$migration")" + sqlite3 "$DB_PATH" < "$migration" +done + +count="$(sqlite3 "$DB_PATH" "SELECT count(*) FROM requirement;")" +echo "Built $DB_PATH ($count requirements)" diff --git a/requirements/scripts/new_migration.sh b/requirements/scripts/new_migration.sh new file mode 100755 index 000000000..7cd8f1256 --- /dev/null +++ b/requirements/scripts/new_migration.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Scaffold the next numbered migration file. +# +# Usage: requirements/scripts/new_migration.sh "short description" +# e.g. requirements/scripts/new_migration.sh "split REQ-0042 into two" +# -> migrations/002_split_req_0042.sql + +set -euo pipefail + +REQ_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MIG_DIR="$REQ_DIR/migrations" +mkdir -p "$MIG_DIR" + +if [[ $# -lt 1 ]]; then + echo "Usage: $0 \"short description\"" >&2 + exit 1 +fi + +desc="$1" +slug="$(echo "$desc" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9' '_' | sed 's/^_//;s/_$//')" + +last="$(ls "$MIG_DIR"/[0-9]*.sql 2>/dev/null | sed -E 's#.*/([0-9]+)_.*#\1#' | sort -n | tail -1 || true)" +next="$(printf '%03d' "$(( 10#${last:-0} + 1 ))")" + +file="$MIG_DIR/${next}_${slug}.sql" +cat > "$file" <,* **I want to** *,* **so that** *.* +#### Acceptance criteria + # **Given that** *,* **when** *,* **then** *.*', 'functional', 'verified', 'medium', 'story', 226, 'https://github.com/hmcts/cath-service/issues/226', NULL, NULL, '2026-01-20T17:01:02Z', '2026-01-30T15:02:55Z', 'linusnorton', 'linusnorton'), + (5, 'REQ-0005', '‘How do you want to sign in’ Page', '**PROBLEM STATEMENT** + +Verified users are required to sign into CaTH before accessing restricted information. This would require access to a ''sign in'' page. + +  + +**AS A** Verified User + +**I WANT** to sign into CaTH + +**SO THAT** I can have access to my account and to restricted hearing information published in CaTH + +  + +**ACCEPTANCE CRITERIA** + * User can see the links to Gov.UK, Court and tribunal hearings and sign in at the top of the ‘How do you want to sign in?’ page + * User is provided with various sign in account routes on the ‘How do you want to sign in?’ page (HMCTS, Common Platform or CaTH account) + * User can make an account selection by clicking a radio button beside the specific account and clicking the continue button + * Where the User does not have a CaTH account, the User is provided a link to create a CaTH account + * User can see the general information links at the bottom of the page ( Help, privacy, cookies, accessibility statement, contact, terms and conditions, welsh, government digital service and open government licence) + * User has the option of switching to the Welsh translated page', 'functional', 'verified', 'medium', 'story', 227, 'https://github.com/hmcts/cath-service/issues/227', NULL, NULL, '2026-01-20T17:01:14Z', '2026-01-30T15:02:58Z', 'linusnorton', 'linusnorton'), + (6, 'REQ-0006', 'CaTH ‘Sign in’ - CFT IDAM', '**PROBLEM STATEMENT** + +Verified users are required to sign into CaTH before accessing restricted information. This required the input of verified sign in details. + +  + +**AS A** Verified User + +**I WANT** to sign into CaTH + +**SO THAT** I can have access to my account and to restricted hearing information published in CaTH + +  + +**Technical Criteria** + # User should be re~~directed to the CFT IDAM flow when selecting and submitting the CFT IDAM radio button on the /sign~~in page + # When navigating to any verified route, user should be redirected to /sign-in page + # Verified authenticated pages include: /account-home + # All login and associated user screens are part of the CFT IDAM, and not to be added as part of this + # When user has successfully authenticated with the CFT IDAM they should be redirected back to /account-home + # When the user is redirected, a call to the CFT IDAM user endpoint to be made to retrieve user details including the role + # All roles should be accepted, other than citizen and letter holder. Regex: ^citizen(~~.*)?$|^letter~~holder$ + # If user does not have the correct roles, they are redirect to the CFT Rejected login page (same as current CaTH) + # Associated config for CFT IDAM read from KV (or env variables locally) and used for redirection and token processing. The processes uses oAuth + # The users role is stored in the session and used to authenticate on each of the pages. The role is ''VERIFIED'' + # Sign Out and session expiry does not need to be handled. This will be done in a separate ticket ({**}To be raised){**} + # After the user is signed in, all pages should display Sign out instead of Sign In in the banner at the top right + # E2E tests should utilise the existing CFT IDAM Test Users for each role + # Use passport as the authentication middleware, in a similar way to the SSO integration + # If CFT IDAM user attempts to access admin pages, then user should be redirected back to /account~~home. Similarly, if an Admin user tries to access the verified pages, they should be redirect back to /admin~~dashboard or /system~~admin~~dashboard (depending on if they are a standard or system admin) + +  + +**ACCEPTANCE CRITERIA** + * On the CaTH sign in page, the title provided in the header is ''How do you want to sign in''. The CFT IDAM User can see 3 radio buttons that lead to 3 different sign in routes. The User selects the myHMCTS sign in route and clicks continue + * User inputs the verified log in information into the data log in fields and click the ‘sign in’ button to continue the sign in process + * Where the User inputs the correct log in information into the data log in fields and clicks the ‘sign in’ button to complete the sign in process, then the user is logged in and taken to the appropriate dashboard with 3 tiles (Court and tribunal hearings, Single Justice Procedure cases and Email subscriptions) + * Where the User inputs an incorrect log in information into the HMCTS account data log in fields and clicks the ‘sign in’ button to complete the sign in process, then the user is notified with the following message written in a red box under the header ''Incorrect email or password'' ''Please fix the following''. Underneath the message, user sees the following messages written boldly and underlined in red ''check your email address'' on the first line and ''check your password'' on the second line. The sign in fields are highlighted in red  + * User is provided with a ‘forgotten password’ link on the sign in page which takes the user to a screen where the user can input email address and click the ''submit'' button to receive an email to reset password. After inputting email address and submitting, user sees the following message under the header ''{**}Check your email''{**} which states ''If you entered a valid email address, we will send you an email with details of how to reset your password. +If you have entered an email address that is not connected with an account, you will not receive an email. You will need to (https://hmcts~~access.service.gov.uk/contact~~us) to create an account.''. Underneath the message, user sees another message user the header ''{**}Can''t see the email?''{**} '' + +It can take a few minutes to arrive. Check your junk mail if you can''t see it in your inbox. + +If the email doesn''t arrive, you can (https://idam~~web~~public.aat.platform.hmcts.net/reset/forgotpassword?redirectUri=https%3a%2f%2fpip~~frontend.staging.platform.hmcts.net%2fcft~~login%2freturn&client_id=app~~pip~~frontend&state=&nonce=&scope=).'' + * Where a User is logged into CaTH and the account remains inactive for the allocated timeframe, then a notice will be displayed stating ''You will soon be signed out due to inactivity'' + * Where the notice has been displayed and the account still remains inactive for the additional allocated timeframe, then the user is signed out and a notice is displayed stating ''You have been signed out due to inactivity'' + * In both scenarios above, if the user re-activates the account by clicking the continue button, then the user is not signed out and the inactivity time calculator resets. + * Where the user signs in successfully, user can sign out by clicking the Sign out'' link at the top right of the screen + +  + +  + +**Welsh translation:** + +Check your email + +If you entered a valid email address, we will send you an email with details of how to reset your password. + +If you have entered an email address that is not connected with an account, you will not receive an email. You will need to (https://hmcts~~access.service.gov.uk/contact~~us) to create an account. +## Can''t see the email? + +It can take a few minutes to arrive. Check your junk mail if you can''t see it in your inbox. + +If the email doesn''t arrive, you can (https://idam~~web~~public.aat.platform.hmcts.net/reset/forgotpassword?redirectUri=https%3a%2f%2fpip~~frontend.staging.platform.hmcts.net%2fcft~~login%2freturn&client_id=app~~pip~~frontend&state=&nonce=&scope=). +# Gwiriwch eich negeseuon e-bost + +Os bu ichi roi cyfeiriad e~~bost dilys, byddwn yn anfon neges e~~bost atoch gyda manylion ynghylch sut i ailosod eich cyfrinair. + +Os ydych wedi rhoi cyfeiriad e~~bost nas ddefnyddiwyd i greu cyfrif, ni fyddwch yn cael neges e~~bost. Bydd angen ichi (https://hmcts~~access.service.gov.uk/contact~~us) i greu cyfrif. +## Heb gael y neges e-bost? + +Gall gymryd ychydig o funudau i gyrraedd. Gwiriwch eich blwch negeseuon ‘junk’ os na allwch ei weld yn eich mewnflwch. + +Os na fydd yr e~~bost yn cyrraedd, gallwch (https://idam~~web~~public.aat.platform.hmcts.net/reset/forgotpassword?redirectUri=https%3a%2f%2fpip~~frontend.staging.platform.hmcts.net%2fcft~~login%2freturn&client_id=app~~pip~~frontend&state=&nonce=&scope=). + +  + +You will soon be signed out, due to inactivity - Byddwch yn cael eich allgofnodi’n fuan, o ganlyniad i wneud dim + +You have been signed out, due to inactivity - Rydych wedi cael eich allgofnodi oherwydd anweithgarwch + + “Sign in”  - “Mewngofnodi” + +“Email address”, “Password”  - “cyfeiriad ebost”, “Cyfrinair” + + “Sign in”  - “Mewngofnodi” + + “Forgot your password?”  - “Wedi anghofio eich cyfrinair?” + + “Court and tribunal hearings”  - “Gwrandawiadau llys a thribiwnlys” + +  + +  + +  + # VIBE~~140 Verified User Sign~~In Specification + +> Owner: {**}`**`VIBE-140`**`{**} · Updated: {**}`**`24 Oct 2025`**`{**} + +— + # + ## Problem Statement + +Verified users are required to sign into CaTH before accessing restricted hearing information.   +This requires verified credentials input through the CaTH sign-in process and validated authentication to ensure only authorised users can access restricted data. + +— + # + ## User Story + +{**}`**`As a`**`{**} **Verified User**   +{**}`**`I want to`**`{**} **sign into CaTH**   +{**}`**`So that`**`{**} **I can have access to my account and to restricted hearing information published in CaTH** + +— + # + ## Acceptance Criteria + +1. On the CaTH sign-in page, the {**}`**`header title`**`{**} is:   +   > “How do you want to sign in?”   +2. The CFT IDAM user sees {**}`**`three radio button options`**`{**}, each leading to a different sign-in route:   +   - myHMCTS   +   - Judicial Office   +   - Professional user (for example)   +3. The user selects {**}`**`myHMCTS`**`{**} and clicks the {**}`**`Continue`**`{**} button.   +4. The user is directed to the myHMCTS {**}`**`Sign In page`**`{**} containing two fields:   +   - {**}`**`Email address`**`{**}   +   - {**}`**`Password`**`{**}   +   and a {**}`**`‘Sign in’`**`{**} button.   +5. If the user enters correct credentials and clicks {**}`**`Sign in{**}`**`, the system authenticates successfully and redirects the user to their dashboard displaying {**}`**`three tiles`**`{**}:   +   - Court and tribunal hearings   +   - Single Justice Procedure cases   +   - Email subscriptions   +6. If the user enters incorrect credentials, a red error box is displayed with:   +   - Header: {**}`**`“Incorrect email or password”`**`{**}   +   - Subheader: {**}`**`“Please fix the following”`**`{**}   +   - Bold red underlined messages:   +     - “Check your email address”   +     - “Check your password”   +   - The input fields are highlighted in red.   +7. The user is provided with a {**}`**`‘Forgot your password?’`**`{**} link below the sign-in button.   +8. Clicking {**}`**`‘Forgot your password?’`**`{**} takes the user to the {**}`**`Reset Password page{**}`**`, where they can input their email address and click {**}`**`‘Submit’`**`{**} to receive a reset email.   +9. After submitting their email address, the user sees a confirmation screen titled {**}`**`‘Check your email’`**`{**} with the following message:   +   > “If you entered a valid email address, we will send you an email with details of how to reset your password.   +   >   +   > If you have entered an email address that is not connected with an account, you will not receive an email. You will need to contact us to create an account.”   +10. Underneath the confirmation, a section titled {**}`**`“Can’t see the email?”`**`{**} appears with the text:   +    > “It can take a few minutes to arrive. Check your junk mail if you can''t see it in your inbox.   +    >   +    > If the email doesn''t arrive, you can request another password reset email.”   +11. If the user remains inactive while signed in, a notice banner appears:   +    - “You will soon be signed out due to inactivity.”   +12. If inactivity continues for the defined time period, the system automatically signs the user out and displays:   +    - “You have been signed out due to inactivity.”   +13. If the user interacts (clicks Continue) after the inactivity notice, the timer resets, and the session remains active.   +14. Once signed in, users can sign out by clicking {**}`**`‘Sign out’`**`{**} in the top-right corner of any page. + +— + # + ## User Journey Flow + +1. User navigates to CaTH homepage → clicks {**}`**`Continue`**`{**} → selects {**}`**`Sign in`**`{**} route.   +2. The system presents the {**}`**`Sign In options page`**`{**} (“How do you want to sign in?”).   +3. User selects {**}`**`myHMCTS`**`{**} and clicks {**}`**`Continue`**`{**}.   +4. User enters credentials on the myHMCTS sign-in screen.   +5. System validates credentials:   +   - On success → redirect to Dashboard.   +   - On failure → show error box.   +6. User can click {**}`**`‘Forgot your password?’`**`{**} to initiate reset process.   +7. If password reset request is successful, user sees the {**}`**`Check your email`**`{**} page.   +8. Once authenticated, user session is active until sign-out or timeout. + +— + # + ## Wireframes + + # + ## + ### A. Sign-In Options Page + +┌──────────────────────────────────────────────────────────────────────────────┐ +│ GOV.UK │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ HMCTS – Courts and Tribunals Hearings (CaTH) │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ < Back │ +│ How do you want to sign in? │ +│ │ +│ ○ myHMCTS │ +│ ○ Judicial Office │ +│ ○ Professional user │ +│ │ +│ (Green Button) │ +└──────────────────────────────────────────────────────────────────────────────┘ + +  + +— + # + ## + ### B. myHMCTS Sign-In Page + +┌──────────────────────────────────────────────────────────────────────────────┐ +│ myHMCTS Sign-In │ +│ │ +│ Email address: <\\\{**}>(file://\{%2A}/) │\{**} +{**}│ Password: <\\\{**}>(file://\{%2A}/) │ +│ │ +│ (Green Button) │ +│ │ +│ Forgot your password? (Link) │ +│ │ +│ (Error State Example) │ +│ ┌────────────────────────────────────────────────────────────────────────┐ │ +│ │ 🔴 Incorrect email or password │ │ +│ │ Please fix the following │ │ +│ │ - Check your email address │ │ +│ │ - Check your password │ │ +│ └────────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────────┘ + +  + +— + # + ## + ### C. Check Your Email Page (Password Reset Confirmation) + +┌──────────────────────────────────────────────────────────────────────────────┐ +│ Check your email │ +│ │ +│ If you entered a valid email address, we will send you an email with details │ +│ of how to reset your password. │ +│ │ +│ If you have entered an email address that is not connected with an account, │ +│ you will not receive an email. You will need to contact us to create an │ +│ account. │ +│ │ +│ Can''t see the email? │ +│ It can take a few minutes to arrive. Check your junk mail if you can''t see │ +│ it in your inbox. │ +│ If the email doesn''t arrive, you can request another password reset email. │ +└──────────────────────────────────────────────────────────────────────────────┘ + +  + +— + # + ## + ### D. Inactivity and Sign-Out Messages + +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ⚠️ You will soon be signed out due to inactivity. │ +└──────────────────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ⚠️ You have been signed out due to inactivity. │ +└──────────────────────────────────────────────────────────────────────────────┘ + +  + +— + # + ## Form Fields + +|Field|Type|Required|Validation|Behaviour| +|~~--~~~~--~~|~~--~~~~-|~~~~--~~~~--~~~~-|~~~~--~~~~--~~~~--~~|~~--~~~~--~~~~--~~-| +|Email address|Text|Yes|Must be valid email format|Highlighted red on invalid entry| +|Password|Password|Yes|Minimum 8 characters|Hidden characters by default| +|Radio buttons|Select|Yes|One option must be selected|Required before Continue| +|Forgot password email|Text|Yes|Must be valid email address|Sends reset request| + +— + # + ## Content + +{**}`**`EN:`**`{**}   + - {**}`**`Page title:`**`{**} “How do you want to sign in?”   + - {**}`**`Sign-in page labels:`**`{**} “Email address”, “Password”, “Sign in”   + - {**}`**`Error messages:`**`{**}   +  - “Incorrect email or password”   +  - “Please fix the following”   +  - “Check your email address”   +  - “Check your password”   + - {**}`**`Forgot password:`**`{**} “Forgot your password?”   + - {**}`**`Inactivity notices:`**`{**}   +  - “You will soon be signed out due to inactivity”   +  - “You have been signed out due to inactivity”   + - {**}`**`Dashboard tiles:`**`{**} “Court and tribunal hearings”, “Single Justice Procedure cases”, “Email subscriptions” + +{**}`**`CY:`**`{**}   + - {**}`**`Page title:`**`{**} “Sut ydych chi am fewngofnodi?”   + - {**}`**`Sign-in page labels:`**`{**} “cyfeiriad ebost”, “Cyfrinair”, “Mewngofnodi”   + - {**}`**`Error messages:`**`{**}   +  - “Ebost neu gyfrinair anghywir”   +  - “Trwsiwch y canlynol”   +  - “Gwiriwch eich cyfeiriad ebost”   +  - “Gwiriwch eich cyfrinair”   + - {**}`**`Forgot password:`**`{**} “Wedi anghofio eich cyfrinair?”   + - {**}`**`Inactivity notices:`**`{**}   +  - “Byddwch yn cael eich allgofnodi’n fuan, o ganlyniad i wneud dim”   +  - “Rydych wedi cael eich allgofnodi oherwydd anweithgarwch”   + - {**}`**`Dashboard tiles:`**`{**} “Gwrandawiadau llys a thribiwnlys”, “Achosion Gweithdrefn Un Ynad”, “Tanysgrifiadau e-bost” + +— + # + ## URL Structure + +|Page|URL| +|~~--~~~~-|~~---| +|Sign~~in options|`/sign~~in`| +|myHMCTS sign~~in|`/sign~~in/myhmcts`| +|Forgot password|`/sign~~in/forgot~~password`| +|Check your email|`/sign~~in/forgot~~password/check-email`| +|Dashboard|`/dashboard`| +|Session timeout notice|`/timeout-warning`| +|Session expired|`/session-expired`| + +— + # + ## Validation Rules + + - {**}`**`Email address`**`{**} must be a valid format (e.g., `user@example.com`).   + - {**}`**`Password`**`{**} must not be empty.   + - {**}`**`Radio button`**`{**} selection required before proceeding from the sign-in options page.   + - {**}`**`Inactivity warning`**`{**} triggers after predefined timeout period (e.g., 25 mins).   + - {**}`**`Session expiry`**`{**} triggers after extended inactivity (e.g., 30 mins).   + - All input errors must display inline and in an accessible error summary at the top of the page. + +— + # + ## Error Messages + +{**}`**`EN:`**`{**}   + - “Incorrect email or password.”   + - “Court or tribunal name must be 3 characters or more.”   + - “Please fix the following.”   + - “Check your email address.”   + - “Check your password.”   + +{**}`**`CY:`**`{**}   + - “Ebost neu gyfrinair anghywir.”   + - “Rhaid i enw’r llys neu’r tribiwnlys gynnwys 3 llythyren neu fwy.”   + - “Trwsiwch y canlynol.”   + - “Gwiriwch eich cyfeiriad ebost.”   + - “Gwiriwch eich cyfrinair.” + +— + # + ## Navigation + + - {**}`**`Sign in options → myHMCTS sign-in page`**`{**}   + - {**}`**`myHMCTS sign-in page → Dashboard (on success)`**`{**}   + - {**}`**`myHMCTS sign-in page → Error (on failure)`**`{**}   + - {**}`**`Forgot password → Check your email page`**`{**}   + - {**}`**`Dashboard → Sign out`**`{**} (link top-right)   + - {**}`**`Session timeout warning → Stay signed in or auto sign-out`**`{**}   + +— + # + ## Accessibility + + - Comply with {**}`**`WCAG 2.2 AA`**`{**} and {**}`**`GOV.UK Design System`**`{**}.   + - Use ARIA roles (`role="alert"`) for all error and timeout notifications.   + - Keyboard navigation must be supported for all inputs, links, and buttons.   + - Focus states must be visible and logical.   + - Error summaries must include anchor links to problematic fields.   + - Language toggle must update text dynamically without clearing input.   + - Timeout warnings must be screen-reader accessible and allow for user interaction to stay signed in. + +— + # + ## Test Scenarios + +|ID|Scenario|Steps|Expected Result| +|~~--~~|~~--~~~~--~~~~--|~~~~--~~~~--|~~~~--~~~~--~~~~--~~---| +|TS1|Load sign~~in options|Visit `/sign~~in`|Three radio buttons displayed| +|TS2|Select myHMCTS|Choose myHMCTS and click Continue|Redirect to myHMCTS sign-in page| +|TS3|Valid credentials|Enter correct email/password|Redirect to Dashboard| +|TS4|Invalid credentials|Enter incorrect credentials|Error box displayed with messages| +|TS5|Forgot password|Click Forgot password link|Redirect to `/sign~~in/forgot~~password`| +|TS6|Submit reset email|Enter email, click Submit|Redirect to Check your email page| +|TS7|Inactivity warning|Stay idle for threshold|Warning banner displayed| +|TS8|Auto sign-out|Stay idle after warning|Session expired page displayed| +|TS9|Resume session|Click Continue after warning|Session remains active| +|TS10|Sign out|Click Sign out|User returned to sign-in page| +|TS11|Welsh translation|Toggle to Welsh|Page updates to Welsh content| +|TS12|Accessibility test|Use screen reader|All messages read correctly| +|TS13|Error recovery|Fix invalid email, re-submit|Redirect to Dashboard on success| + +— + # + ## Assumptions / Open Questions + + - Confirm timeout and warning duration (e.g., 25 min warning, 30 min sign-out).   + - Confirm whether IDAM authentication occurs within CaTH or via external redirect.   + - Confirm if multi-factor authentication (MFA) is required for verified media users.   + - Confirm if error tracking should be logged for failed sign-in attempts.   + - Confirm if password reset emails use existing HMCTS templates or custom CaTH branding. + +—', 'functional', 'verified', 'medium', 'story', 228, 'https://github.com/hmcts/cath-service/issues/228', NULL, NULL, '2026-01-20T17:01:28Z', '2026-01-30T15:03:00Z', 'linusnorton', 'linusnorton'), + (7, 'REQ-0007', 'CaTH Sign In - B2C', '**PROBLEM STATEMENT** + +All CaTH users, including members of the public, have access to hearing lists published in CaTH. Public users are however restricted from accessing private/classified information and would need to be authorised and verified before access is granted to restricted information in CaTH and would be expected to sign into CaTH before accessing their account.  + +  + +**AS A** Verified User + +**I WANT** to sign into CaTH + +**SO THAT** I can have access to my account and to restricted hearing information published in CaTH + +  + ++**Technical Criteria**+ + # Utilise Non-Prod B2C instance for integration + # Language selection should pass through to Azure B2C user flows so that the user remains in their chosen language + +  + +**ACCEPTANCE CRITERIA** + * Only  verified users are allowed access to unrestricted published hearing information in CaTH + * When a verified user clicks on the sign in link, the user is directed to the ‘How do you want to sign in?’ page and is provided with various sign in account routes differentiated by individual radio buttons (HMCTS, Common Platform or CaTH account) and  an account selection is made by clicking a radio button beside the specific account and clicking the continue button upon which the user is taken to the dashboard + * Where the User inputs the correct log in information into the data log in fields and clicks the ‘sign in’ button to complete the sign in process, then the user is logged in and taken to the appropriate dashboard + * Where the User inputs an incorrect log in information into the HMCTS account data log in fields and clicks the ‘sign in’ button to complete the sign in process, then the user is notified of the incorrect email or password + * Where the User inputs an incorrect log in information into the CaTH or Common platform account data log in fields and clicks the ‘sign in’ button to complete the sign in process, then the user is notified of the invalid username or password + * Where the verified user has forgotten their log in password, then the user can click on the forgotten password link and is re-directed to a page where the user is expected to input their email address in the data field provided to receive a verification code which is sent to their email when the user clicks the ''Send code'' button. If the user inputs the correct verification code, then the user will be informed that the account has been verified and is given access to the verified account. If the user inputs an incorrect verification code then the user will be notified of the rejected log in and that the sign in failed + * Access CaTH is limited by a time~~boxed duration of inactivity upon which the user’s access is timed out. Where a User is logged into CaTH and the account remains inactive for the allocated timeframe, then a notice will be displayed stating ''You will soon be signed out, due to inactivity''. Where the notice has been displayed and the account still remains inactive for the additional allocated timeframe, then the user is signed out and a notice is displayed stating ''You have been signed out, due to inactivity''. In both scenarios, if the user re~~activates the account by clicking the continue button, then the user is not signed out and the inactivity time calculator resets. + +  + +  + +  + # VIBE~~142 Verified User Sign~~In Access Specification + +> Owner: **{*}VIBE-142{**}* · Updated: **{*}05 Nov 2025{**}* + +— + # + ## Problem Statement + +All CaTH users, including members of the public, can access hearing lists published in CaTH.   +However, **{*}public users are restricted{**}* from accessing private or classified information.   +Only **{*}verified users{**}* are authorised and verified to access restricted hearing information.   +These users must sign into CaTH using their verified credentials before gaining access to their accounts. + +— + # + ## User Story + +**{*}As a{**}* **Verified User**   +**{*}I want to{**}* **sign into CaTH**   +**{*}So that{**}* **I can access my account and view restricted hearing information published in CaTH** + +— + # + ## Acceptance Criteria + +1. **{*}Access Restriction{**}*   +   - Only verified users can access unrestricted or restricted hearing information.   +   - Public users cannot access restricted content. + +2. **{*}Sign-In Options Page{**}*   +   - When a verified user clicks **{*}“Sign in”{**}{**}, they are directed to the ***‘How do you want to sign in?’{*}* page.   +   - The page displays three radio button options for sign-in routes:   +     - **{*}HMCTS{**}* account   +     - **{*}Common Platform{**}* account   +     - **{*}CaTH account{**}*   +   - User selects a sign-in route and clicks **{*}Continue{**}* to proceed.   +   - The system redirects to the relevant authentication page for the selected account type. + +3. **{*}Successful Login{**}*   +   - If the user enters **{*}correct credentials{**}* and clicks **{*}Sign in{**}{**}, they are authenticated and redirected to their ***dashboard{*}*. + +4. **{*}Login Errors{**}*   +   - If the user enters **{*}incorrect credentials{**}*:   +     - For **{*}HMCTS{**}* route → display **{*}“Incorrect email or password”{**}*.   +     - For **{*}Common Platform{**}* or **{*}CaTH{**}* route → display **{*}“Invalid username or password”{**}*. + +5. **{*}Forgotten Password{**}*   +   - On any login page, a **{*}‘Forgot your password?’{**}* link is visible.   +   - Clicking the link redirects to a **{*}Reset Password page{**}*.   +   - User enters their registered email address and clicks **{*}‘Send code’{**}*.   +   - A verification code is sent to the user’s email.   +   - When the correct code is entered:   +     - Display message: **{*}“Your account has been verified. You can now sign in.”{**}*   +   - When an incorrect code is entered:   +     - Display message: **{*}“Verification failed. Please check the code and try again.”{**}* + +6. **{*}Session Inactivity Management{**}*   +   - If a user remains inactive for the configured timeout period:   +     - Display message: **{*}“You will soon be signed out, due to inactivity.”{**}*   +   - If the user continues to remain inactive after the warning period:   +     - Display message: **{*}“You have been signed out, due to inactivity.”{**}*   +   - If the user interacts (clicks **{*}Continue{**}*) after the first warning:   +     - The session remains active and the inactivity timer resets. + +7. **{*}Sign-Out{**}*   +   - Verified users can manually sign out using the **{*}‘Sign out’{**}* link displayed at the top-right corner of all pages.   + +8. **{*}All CaTH accessibility and design specifications are maintained.{**}* + +— + # + ## User Journey Flow + +1. Verified user navigates to the CaTH home page.   +2. Clicks **{*}‘Sign in’{**}* → directed to **{*}‘How do you want to sign in?’{**}* page.   +3. Selects one of the account types (HMCTS, Common Platform, or CaTH).   +4. Clicks **{*}Continue{**}* → redirected to respective login page.   +5. Enters login details → clicks **{*}Sign in{**}*.   +6. System validates credentials:   +   - Success → Redirect to Dashboard.   +   - Failure → Show appropriate error message.   +7. If user forgot password → clicks **{*}Forgot password{**}* → enters email → receives verification code → verifies code → can reset or re-access account.   +8. System tracks inactivity and automatically signs out inactive users after threshold. + +— + # + ## Wireframes + + # + ## + ### A. Sign-In Options Page + +┌──────────────────────────────────────────────────────────────────────────────┐ +│ GOV.UK │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ HMCTS – Courts and Tribunals Hearings (CaTH) │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ < Back │ +│ How do you want to sign in? │ +│ │ +│ ○ HMCTS account │ +│ ○ Common Platform account │ +│ ○ CaTH account │ +│ │ +│ (Green Button) │ +└──────────────────────────────────────────────────────────────────────────────┘ + +  + +— + # + ## + ### B. Sign-In Page (Generic Layout) + +┌──────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ Email / Username │ +│ <\{**}> │\{**} +**│ │** +**│ Password │** +{**}│ <\{**}> │ +│ │ +│ (Green Button) │ +│ Forgot your password? (Link) │ +│ │ +│ (Error example) │ +│ ┌────────────────────────────────────────────────────────────────────────┐ │ +│ │ 🔴 Incorrect email or password │ │ +│ │ Please check your credentials and try again. │ │ +│ └────────────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────────┘ + +  + +  + +— + # + ## + ### C. Forgot Password & Verification Page + +  + +┌──────────────────────────────────────────────────────────────────────────────┐ +│ Forgot your password │ +│ │ +│ Enter your email address │ +│ <*__**__**__**__**__**__**__*__\{**}> │\{**} +**│ │** +**│ (Green Button) │** +**│ │** +**│ Once received, enter your code: │** +{**}│ <\{**}> │ +│ (Green Button) │ +│ │ +│ Messages: │ +│ ✅ Your account has been verified. You can now sign in. │ +│ ❌ Verification failed. Please check the code and try again. │ +└──────────────────────────────────────────────────────────────────────────────┘ +  + +— + # + ## + ### D. Session Timeout Messages + +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ⚠️ You will soon be signed out, due to inactivity. │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ⚠️ You have been signed out, due to inactivity. │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ + +  + +  + +— + # + ## Form Fields + +|Field|Type|Required|Validation|Behaviour| +|~~--~~~~--~~|~~--~~~~-|~~~~--~~~~--~~~~-|~~~~--~~~~--~~~~--~~|~~--~~~~--~~~~--~~-| +|Sign~~in option|Radio|Yes|One option must be selected|Determines sign~~in route| +|Email / Username|Text|Yes|Valid email or username|Highlight red on error| +|Password|Password|Yes|Minimum 8 characters|Masked input| +|Verification code|Text|Yes|Numeric or alphanumeric|6–8 digits; must match system value| + +— + # + ## Content + +**{*}EN:{**}*   + - **{*}Title/H1:{**}* “How do you want to sign in?”   + - **{*}Labels:{**}* “Email address”, “Username”, “Password”   + - **{*}Buttons:{**}* “Sign in”, “Continue”, “Send code”, “Verify code”, “Sign out”   + - **{*}Links:{**}* “Forgot your password?”   + - **{*}Messages:{**}*   +  - “Incorrect email or password.”   +  - “Invalid username or password.”   +  - “You will soon be signed out, due to inactivity.”   +  - “You have been signed out, due to inactivity.”   +  - “Your account has been verified. You can now sign in.”   +  - “Verification failed. Please check the code and try again.” + +**{*}CY:{**}*   + - **{*}Title/H1:{**}* “Sut ydych chi am fewngofnodi?”   + - **{*}Labels:{**}* “Cyfeiriad e-bost”, “Enw defnyddiwr”, “Cyfrinair”   + - **{*}Buttons:{**}* “Mewngofnodi”, “Parhau”, “Anfon cod”, “Gwirio cod”, “Allgofnodi”   + - **{*}Links:{**}* “Wedi anghofio eich cyfrinair?”   + - **{*}Messages:{**}*   +  - “Ebost neu gyfrinair anghywir.”   +  - “Enw defnyddiwr neu gyfrinair annilys.”   +  - “Byddwch yn cael eich allgofnodi’n fuan, o ganlyniad i wneud dim.”   +  - “Rydych wedi cael eich allgofnodi oherwydd anweithgarwch.”   +  - “Mae eich cyfrif wedi cael ei wirio. Gallwch fewngofnodi nawr.”   +  - “Methwyd y dilysiad. Gwiriwch y cod a cheisiwch eto.” + +— + # + ## URL Structure + +|Page|URL| +|~~--~~~~-|~~---| +|Sign~~in options|`/sign~~in`| +|HMCTS sign~~in|`/sign~~in/hmcts`| +|Common Platform sign~~in|`/sign~~in/common-platform`| +|CaTH sign~~in|`/sign~~in/cath`| +|Forgot password|`/sign~~in/forgot~~password`| +|Verification code|`/sign~~in/verify~~code`| +|Dashboard|`/dashboard`| +|Timeout warning|`/session/timeout-warning`| +|Session expired|`/session/expired`| + +— + # + ## Validation Rules + + - Radio button selection required before clicking **{*}Continue{**}*.   + - All input fields must be populated before **{*}Sign in{**}*.   + - Incorrect credentials → Show error message inline and in summary.   + - Verification code input must match generated code before granting access.   + - Session timeout warning displayed before forced logout.   + - Clicking **{*}Continue{**}* on timeout warning resets inactivity timer.   + +— + # + ## Error Messages + +**{*}EN:{**}*   + - “Incorrect email or password.”   + - “Invalid username or password.”   + - “Verification failed. Please check the code and try again.”   + - “Enter a valid email address.”   + - “Select how you want to sign in.” + +**{*}CY:{**}*   + - “Ebost neu gyfrinair anghywir.”   + - “Enw defnyddiwr neu gyfrinair annilys.”   + - “Methwyd y dilysiad. Gwiriwch y cod a cheisiwch eto.”   + - “Rhowch gyfeiriad e-bost dilys.”   + - “Dewiswch sut rydych am fewngofnodi.” + +— + # + ## Navigation + + - **{*}Sign in options → Login page → Dashboard{**}*   + - **{*}Forgot password → Send code → Verify code → Login{**}*   + - **{*}Dashboard → Sign out{**}*   + - **{*}Inactivity → Timeout warning → Auto sign-out{**}*   + +— + # + ## Accessibility + + - Must comply with **{*}WCAG 2.2 AA{**}* and **{*}GOV.UK Design System{**}* standards.   + - Screen readers must announce:   +  - “How do you want to sign in?”   +  - Selected radio options and error messages.   + - Error banners use `role="alert"`.   + - Buttons and links must be reachable by keyboard and have visible focus states.   + - Timeout warnings must be readable by assistive technology.   + - Input fields must include proper `aria-labels` and language toggle support. + +— + # + ## Test Scenarios + +|ID|Scenario|Steps|Expected Result| +|~~--~~|~~--~~~~--~~~~--|~~~~--~~~~--|~~~~--~~~~--~~~~--~~---| +|TS1|Sign~~in options visible|Visit `/sign~~in`|Page shows three radio options| +|TS2|No option selected|Click Continue without choosing|Error “Select how you want to sign in” displayed| +|TS3|Valid HMCTS credentials|Select HMCTS → Sign in|Redirect to dashboard| +|TS4|Invalid HMCTS credentials|Enter incorrect password|Error “Incorrect email or password”| +|TS5|Invalid CaTH credentials|Enter invalid password|Error “Invalid username or password”| +|TS6|Forgot password|Click “Forgot your password?”|Redirect to `/sign~~in/forgot~~password`| +|TS7|Valid verification code|Enter correct code|Message “Your account has been verified”| +|TS8|Invalid verification code|Enter incorrect code|Message “Verification failed”| +|TS9|Inactivity warning|Stay idle until timeout threshold|“You will soon be signed out…” displayed| +|TS10|Auto sign-out|Stay idle after warning|“You have been signed out…” displayed| +|TS11|Resume activity|Click Continue on warning|Session remains active| +|TS12|Sign out manually|Click “Sign out”|Redirect to `/sign-in`| +|TS13|Welsh translation|Toggle to Welsh|All content updates correctly| + +— + # + ## Assumptions / Open Questions + + - Confirm if all three routes (HMCTS, Common Platform, CaTH) authenticate through CFT IDAM.   + - Confirm timeout threshold (e.g., 25 min warning, 30 min logout).   + - Confirm if verification code is single~~use or time~~limited (e.g., 15 minutes).   + - Confirm whether all sign-in error messages are logged for audit.   + - Confirm if “Forgot password” applies to all account types or only CaTH accounts. + +—', 'functional', 'verified', 'medium', 'story', 229, 'https://github.com/hmcts/cath-service/issues/229', 'c3acd7bdbe390bfc302d511da3f1cca379a21b92', '["apps/web/src/app.test.ts","apps/web/src/app.ts","apps/web/src/assets/css/index.scss","apps/web/src/assets/js/index.ts","e2e-tests/playwright.config.ts","e2e-tests/tests/session-expired.spec.ts","e2e-tests/tests/sign-in.spec.ts","e2e-tests/tests/sign-out.spec.ts","libs/account/src/repository/model.ts","libs/auth/package.json","libs/auth/src/assets/css/session-timeout.scss","libs/auth/src/assets/js/session-timeout.test.ts","libs/auth/src/assets/js/session-timeout.ts","libs/auth/src/config.ts","libs/auth/src/config/b2c-config.test.ts","libs/auth/src/config/b2c-config.ts","libs/auth/src/index.ts","libs/auth/src/middleware/session-timeout.test.ts","libs/auth/src/middleware/session-timeout.ts","libs/auth/src/pages/b2c-callback/index.test.ts","libs/auth/src/pages/b2c-callback/index.ts","libs/auth/src/pages/b2c-forgot-password/index.test.ts","libs/auth/src/pages/b2c-forgot-password/index.ts","libs/auth/src/pages/b2c-login/index.test.ts","libs/auth/src/pages/b2c-login/index.ts","libs/auth/src/pages/logout/index.test.ts","libs/auth/src/pages/logout/index.ts","libs/auth/src/pages/password-reset-success/cy.ts","libs/auth/src/pages/password-reset-success/en.ts","libs/auth/src/pages/password-reset-success/index.njk","libs/auth/src/pages/password-reset-success/index.test.ts","libs/auth/src/pages/password-reset-success/index.ts","libs/auth/src/pages/session-expired/cy.ts","libs/auth/src/pages/session-expired/en.ts","libs/auth/src/pages/session-expired/index.njk","libs/auth/src/pages/session-expired/index.test.ts","libs/auth/src/pages/session-expired/index.ts","libs/auth/src/pages/session-logged-out/index.njk","libs/auth/src/session/timeout-tracker.test.ts","libs/auth/src/session/timeout-tracker.ts","libs/auth/src/user-profile.ts","libs/auth/tsconfig.json","libs/public-pages/src/pages/sign-in/index.test.ts","libs/public-pages/src/pages/sign-in/index.ts","libs/web-core/src/middleware/helmet/helmet-middleware.ts"]', '2026-01-20T17:01:44Z', '2026-04-15T10:34:17Z', 'linusnorton', 'linusnorton'), + (8, 'REQ-0008', 'User table creation in database', '**PROBLEM STATEMENT** + +The details of users who sign into CaTH needs to be stored in the database. This ticket is raised to create a user table to be used to store user details. + +  + +**AS A** Service + +**I WANT** to create a user table in the database + +**SO THAT** I can store the details of users who access CaTH + +  + +**ACCEPTANCE CRITERIA** + * A User table is created at the back end in the database to capture and store the details of all users in CaTH including users who sign in through the SSO, B2C (Media), CFT IDAM and Crime IDAM routes + +  + +**Technical Acceptance Criteria:** + # Update SSO integration + ## When a user signs in and a user record does not exist based on the provenance ID, a record is created in the table below + ## When a user signs in and the record does exist, a check is performed if the role matches. If it does, the user continues to sign in. If not, the role is updated in the table below + # Update CFT Integration + ## When a user signs in and a user record does not exist based on the provenance ID, a record is created in the table below + ## The role is always ''VERIFIED'' + # last*signed*in_date is updated for all users when they sign in + # created_date is set when the user is first created + +  +|Column Name|Type|Required|Description| +|~~--~~~~--~~~~--~~~~-|~~~~--~~~~|-~~~~--~~~~--~~~~|-~~~~--~~~~--~~~~--~~| +|user_id|UUID|Yes|Unique primary key for user| +|email|VARCHAR(255)|Yes|User email address (unique constraint)| +|first*name|VARCHAR(255)|No|Only stored for CFT*IDAM and CRIME_IDAM| +|surname|VARCHAR(255)|No|Only stored for CFT*IDAM and CRIME*IDAM| +|user*provenance|VARCHAR(20)|Yes|SSO, CFT*IDAM, CRIME*IDAM, B2C*IDAM| +|user*provenance*id|UUID|Yes| | +|role|VARCHAR(20)|Yes|VERIFIED, LOCAL*ADMIN, CTSC*ADMIN, SYSTEM_ADMIN| +|created_date|TIMESTAMP|Yes|Date/Time (to seconds)| +|last*signed*in_date|TIMESTAMP|No|Date/Time (to seconds). Can be blank when user is first created and has not signed in yet| + +  + +  + +# VIBE-143 — Create User Table for CaTH Database (Specification) + +> Owner: VIBE-143   +> Updated: 15 Nov 2025   + +--- + +## Problem Statement +The details of all users who sign into CaTH must be stored securely in the ***CaTH database***.   +This table will serve as the central data source for user authentication, authorization, and audit tracking across all sign-in routes — ***SSO****, ***B2C (Media)***, ***CFT IDAM***, and ***Crime IDAM**. + +--- + +## User Story +***As a*** Service   +***I want to*** create a user table in the database   +***So that*** I can store the details of all users who access CaTH through any sign-in route + +--- + +## Acceptance Criteria +1. A ***User table*** is created at the CaTH back end to capture and store details of all users across multiple authentication routes.   +2. Supported authentication providers include: +   - ***SSO*** (Single Sign-On) +   - ***B2C (Media)*** (Azure AD B2C for verified media users) +   - ***CFT IDAM*** (Civil, Family, Tribunals) +   - ***Crime IDAM*** +3. The table records and updates user identity, provenance, and role information as users log in.   +4. Each user record is uniquely identifiable by `user_id` (UUID).   +5. When a user signs in: +   - If no record exists for their provenance ID, a ***new record is created***.   +   - If a record exists: +     - The ***role*** is verified — if changed, it is updated.   +     - The `last*signed*in_date` field is updated. +6. `created_date` is set when the record is created and never changes.   +7. Integration logic must be updated for both ***SSO*** and ***CFT*** login routes to handle user creation and updates.   +8. Data storage and updates must comply with ***HMCTS data security*** and ***GDPR standards***.   + +--- + +## Technical Acceptance Criteria + +### 1. ***SSO Integration*** +- When a user signs in via SSO: +  - The system checks if a record exists by ***`user*provenance*id`***. +  - If ***no record exists***, create a new entry with: +    - `user_provenance = SSO` +    - `created_date = current timestamp` +    - `last*signed*in_date = current timestamp` +  - If a record ***does exist***: +    - Validate that the stored `role` matches the SSO role. +    - If different, update the record with the new role. +    - Update `last*signed*in_date` to the current timestamp. + +### 2. ***CFT Integration*** +- When a user signs in via CFT IDAM: +  - If no record exists by `user*provenance*id`, create a new record with: +    - `user*provenance = CFT*IDAM` +    - `role = VERIFIED` +    - `created_date = current timestamp` +  - When an existing user logs in, update: +    - `last*signed*in_date = current timestamp` +  - `first_name` and `surname` fields are populated for CFT users. + +### 3. ***Crime IDAM Integration*** +- Same logic as CFT integration applies.   +  - Populate `first_name`, `surname`, `role = VERIFIED`. + +### 4. ***B2C (Media) Integration*** +- For verified media users authenticated via Azure AD B2C: +  - If no existing record: +    - Create user record with `user*provenance = B2C*IDAM`. +  - Role assignment based on verification workflow (`VERIFIED` or `PENDING`). +  - `last*signed*in_date` updated each successful sign-in. + +--- + +## Table Definition + +| Column Name | Type | Required | Description | +|~~--~~~~--~~~~--~~~~-|~~~~--~~~~|-~~~~--~~~~--~~~~|-~~~~--~~~~--~~~~--~~| +| ***user_id*** | UUID | Yes | Unique primary key for each user record | +| ***email*** | VARCHAR(255) | Yes | User email address; must be unique | +| ***first*name*** | VARCHAR(255) | No | First name (only populated for CFT*IDAM and CRIME_IDAM) | +| ***surname*** | VARCHAR(255) | No | Surname (only populated for CFT*IDAM and CRIME*IDAM) | +| ***user*provenance*** | VARCHAR(20) | Yes | Authentication source: SSO, CFT*IDAM, CRIME*IDAM, B2C*IDAM | +| ***user*provenance*id*** | UUID | Yes | Unique ID from authentication provider | +| ***role*** | VARCHAR(20) | Yes | User role: VERIFIED, LOCAL*ADMIN, CTSC*ADMIN, SYSTEM_ADMIN | +| ***created_date*** | TIMESTAMP | Yes | Timestamp of record creation (to seconds) | +| ***last*signed*in_date*** | TIMESTAMP | No | Timestamp of last login; can be null until first sign-in | + +### Constraints +- ***Primary Key:*** `user_id` +- ***Unique Constraint:*** `email` and `user*provenance*id` +- ***Default Values:*** +  - `role = VERIFIED` for CFT/Crime users +  - `created*date = CURRENT*TIMESTAMP` +- ***Timestamps*** recorded in UTC. + +--- + +## Business Logic Flow + +### User Sign-in (General) +1. User authenticates via one of the supported identity providers (SSO, CFT IDAM, Crime IDAM, B2C). +2. System retrieves the user’s ***provenance ID*** from the provider. +3. System checks if a record exists in the ***User table***: +   - ***No record found:*** +     - Create a new record with all available data. +     - Assign default role (based on provider type). +     - Set `created*date` and `last*signed*in*date`. +   - ***Record found:*** +     - Validate and update `role` (if changed). +     - Update `last*signed*in_date`. +4. Record creation/update is logged in the audit trail. + +--- + +## Audit Logging +Each operation on the User table must be logged in the ***Audit Log*** for traceability. + +| Field | Description | +|~~--~~~~--~~|~~--~~~~--~~~~--~~-| +| ***audit_id*** | Unique ID for each audit entry | +| ***user_id*** | User associated with the change | +| ***operation*** | INSERT / UPDATE | +| ***timestamp_utc*** | UTC timestamp of operation | +| ***performed_by*** | System user or process name | +| ***changes*** | JSON object detailing field changes | + +--- + +## Data Retention & Security +- Data stored in accordance with ***HMCTS data management policies***. +- All PII data encrypted at rest and transmitted over secure TLS 1.2+. +- User data retained for 7 years for audit purposes. +- Anonymization of inactive user data after 12 months of inactivity. + +--- + +## API Endpoints (Internal) +| Method | Endpoint | Description | +|~~--~~~~--~~~~|-~~~~--~~~~--~~~~|-~~~~--~~~~--~~---| +| ***POST*** | `/api/users` | Create new user record | +| ***PATCH*** | `/api/users/\{user_id}` | Update existing record (role or timestamps) | +| ***GET*** | `/api/users/\{user_id}` | Retrieve specific user data | +| ***GET*** | `/api/users` | Retrieve paginated list of users | +| ***DELETE*** | `/api/users/\{user_id}` | Soft-delete user (if required by policy) | + +--- + +## Validation Rules +- `email` must follow standard RFC 5322 format. +- `role` must be one of the following:   +  `VERIFIED`, `LOCAL*ADMIN`, `CTSC*ADMIN`, `SYSTEM_ADMIN`. +- `user_provenance` must be one of:   +  `SSO`, `CFT*IDAM`, `CRIME*IDAM`, `B2C_IDAM`. +- `created_date` must be immutable. +- `last*signed*in_date` must be updated on each login. +- Duplicate records (same provenance ID) must not be created. + +--- + +## Example Records + +### SSO User +| user*id | email | first*name | surname | user*provenance | user*provenance*id | role | created*date | last*signed*in_date | +|~~--~~~~--~~~~-|~~~~--~~~~--|~~~~--~~~~--~~~~--~~|~~--~~~~--~~~~|-~~~~--~~~~--~~~~--~~~~--~~|~~--~~~~--~~~~--~~~~--~~~~--~~|~~--~~~~-|~~~~--~~~~--~~~~--~~~~-|~~~~--~~~~--~~~~--~~~~--~~~~--~~| +| 1b12f... | jane.doe@gov.uk | NULL | NULL | SSO | 6b2e3... | LOCAL_ADMIN | 2025~~11~~15T10:30:00Z | 2025~~11~~15T11:00:00Z | + +### CFT IDAM User +| user*id | email | first*name | surname | user*provenance | user*provenance*id | role | created*date | last*signed*in_date | +|~~--~~~~--~~~~-|~~~~--~~~~--|~~~~--~~~~--~~~~--~~|~~--~~~~--~~~~|-~~~~--~~~~--~~~~--~~~~--~~|~~--~~~~--~~~~--~~~~--~~~~--~~|~~--~~~~-|~~~~--~~~~--~~~~--~~~~-|~~~~--~~~~--~~~~--~~~~--~~~~--~~| +| 8c45a... | tom.smith@bbc.co.uk | Tom | Smith | CFT_IDAM | a1b2c... | VERIFIED | 2025~~11~~14T09:00:00Z | 2025~~11~~14T09:05:00Z | + +--- + +## Test Scenarios +| ID | Scenario | Steps | Expected Result | +|~~--~~|~~--~~~~--~~~~--|~~~~--~~~~--|~~~~--~~~~--~~~~--~~---| +| TS1 | Create user (SSO) | Sign in via SSO for first time | Record created with correct fields populated | +| TS2 | Create user (CFT IDAM) | Sign in via CFT for first time | Record created; role set to VERIFIED | +| TS3 | Create user (Crime IDAM) | Sign in via Crime IDAM | Record created with first/last name | +| TS4 | Create user (B2C Media) | Media user signs in | Record created with provenance B2C_IDAM | +| TS5 | Update existing user | Existing user signs in again | last*signed*in_date updated | +| TS6 | Update role change | Role changed at provider side | Role updated in database | +| TS7 | No duplicate records | Same provenance ID signs in twice | Only one record exists | +| TS8 | Invalid email | Email missing or malformed | Validation fails; record not created | +| TS9 | Audit log | User signs in | Corresponding audit entry created | +| TS10 | Field persistence | created*date immutable | created*date unchanged on updates | + +--- + +## Risks & Clarifications +- Confirm which service owns role synchronisation logic (SSO or CaTH API).   +- Confirm retention and anonymization timelines are aligned with GDPR.   +- Confirm whether users can exist under multiple provenance sources (e.g., dual accounts).   +- Confirm if local admin roles are created manually or dynamically via provider data.   +- Confirm database: PostgreSQL or equivalent relational DB.', 'functional', 'verified', 'high', 'story', 230, 'https://github.com/hmcts/cath-service/issues/230', NULL, NULL, '2026-01-20T17:01:58Z', '2026-01-30T15:03:03Z', 'linusnorton', 'linusnorton'), + (9, 'REQ-0009', 'Forgotten password', '**PROBLEM STATEMENT** + +Verified users are required to sign into CaTH before accessing restricted information. Sometimes users forget the password required to access their verified account. + +  + +**AS A** Verified User + +**I WANT** to sign into CaTH + +**SO THAT** I can have access to my account and to restricted hearing information published in CaTH + +  + +**ACCEPTANCE CRITERIA** + * Where a User attempts to sign into their verified account and has forgotten their log in password, then the user can click the forgotten password link + * When the user clicks the forgotten password link, then the user is re-directed to a page where the user inputs their email address in the data field provided + * When the user inputs their email address and clicks the send code button, then the user receives a verification code sent to the inputted email address + * Where the user inputs the correct verification code within 10minutes, then the user can continue the password recovery process and is informed upon completing the process that the password was changed successfully + * Where the user does not input the code withing 10 minutes, then the user is informed that the verification code has expired and is prompted to request a new code by clicking the send new code’ link + * The expired code can no longer be used in resetting the account password + * Where the user no longer wants to continue with the password recovery process and clicks the cancel button, then the process is terminated, and the user is informed that the password is unchanged and can still sign-in using the button below and your existing credentials.', 'functional', 'verified', 'medium', 'story', 231, 'https://github.com/hmcts/cath-service/issues/231', NULL, NULL, '2026-01-20T17:02:16Z', '2026-01-30T15:03:05Z', 'linusnorton', 'linusnorton'), + (10, 'REQ-0010', 'Landing Page - Header & Footer', '**PROBLEM STATEMENT** + +All CaTH users, including members of the public, have access to hearing lists published in CaTH. This would require users to undergo a few steps to navigate through the different pages in CaTH. + +  + +**AS A** CaTH User + +**I WANT** to view published court and tribunal hearing lists + +**SO THAT** I can get information about upcoming hearings + +  + +**ACCEPTANCE CRITERIA** + * All CaTH users have access to unrestricted published hearing information in CaTH + * All users begin the journey to accessing this information from the landing page + * All CaTH pages specifications are maintained + * Users can see a summary of the information provided by the service with bullet points highlighting hearings from most civil and family courts in the South East and South West regions, hearings in First Tier and Upper Tribunals (excluding Employment Tribunals), hearings in the Royal Courts of Justice and the Rolls Building and single justice procedure cases, including TV licensing and minor traffic offences such as speeding + * Users are also informed that More courts and tribunals will become available over time. + * Legal and media professionals can see a sign in link just after the highlighted service information + * Users are informed that the service is also available in Welsh language and provided a link to switch to Welsh + * Users can see a ‘continue’ button to continues the process to viewing the hearing lists', 'functional', 'verified', 'high', 'story', 232, 'https://github.com/hmcts/cath-service/issues/232', NULL, NULL, '2026-01-20T17:02:27Z', '2026-01-30T15:03:07Z', 'linusnorton', 'linusnorton'), + (11, 'REQ-0011', 'Public user – Restricted access', '**PROBLEM STATEMENT** + +All CaTH users, including members of the public, have access to hearing lists published in CaTH. Public users are however restricted from accessing private/classified information and would need to be authorised and verified before access is granted to restricted information in CaTH. As such, public users are not required to sign in to access general information in CaTH. + +  + +**AS A** CaTH User + +**I WANT** to view restricted published court and tribunal hearing lists + +**SO THAT** I can get information about upcoming hearings + +  + +**ACCEPTANCE CRITERIA** + * All CaTH users have access to unrestricted published hearing information in CaTH + + * Public users can see the links to Gov.UK and Court and tribunal hearings at the top left of the page + * Public users are informed that this is a new service in the sentence ‘This is a new service – your feedback will help us to improve it.’ + * Link to a feedback form is provided in the text ‘feedback’ + * Public users can see the link to sign in to verified accounts in CaTH at the top right of the page + * When a public user clicks on the sign in link, the public user is directed to the ‘How do you want to sign in?’ page and is provided with various sign in account routes differentiated by individual radio buttons (HMCTS, Common Platform or CaTH account) + * The public user can make an account selection by clicking a radio button beside the specific account and clicking the continue button + * The public user is informed that a CaTH account, needed to sign in through the CaTH account route, can be created and is provided a link to create a CaTH account + + * Where the public user inputs an incorrect log in information into the HMCTS account data log in fields and clicks the ‘sign in’ button to complete the sign in process, then the user is notified of the incorrect email or password + * Where the public user inputs an incorrect log in information into the CaTH or Common platform account data log in fields and clicks the ‘sign in’ button to complete the sign in process, then the user is notified of the invalid username or password + * Where a verification code is required during the sign in process, then the user is sent a verification code + * Where the required verification code has been sent to the user and the user inputs the correct verification code, then the user will be informed that the account has been verified and is given access to the verified account + * Where a User attempts to sign into their verified account and has forgotten their log in password, then the user can click the forgotten password link + * Where the public user clicks the forgotten password link, then the user is re-directed to a page where the user is expected to input their email address in the data field provided to receive a verification code. + * When the public user inputs their email address and clicks the send code button, since the public user does not have a verified account, then the public user does not receive a verification code in the inputted email address + * Where the public user inputs an incorrect verification code then the user will be notified of the rejected log in and that the sign in failed + * Where the public user no longer wants to continue with the password recovery process and clicks the cancel button, then the process is terminated', 'functional', 'verified', 'high', 'story', 233, 'https://github.com/hmcts/cath-service/issues/233', NULL, NULL, '2026-01-20T17:02:44Z', '2026-01-30T15:03:10Z', 'linusnorton', 'linusnorton'), + (12, 'REQ-0012', 'Requirements for content displayed on all pages in CaTH', '**PROBLEM STATEMENT** + +All CaTH pages are expected to have specific content displayed at the top and bottom of each page. + +  + +**AS A** System + +**I WANT** to display specific content on each page in CaTH + +**SO THAT** all pages in CaTH display the required general user information + +  + +**ACCEPTANCE CRITERIA** + * Each page should have the links to Gov.UK at the top banner in the approved blue colour + * A link to ‘Court and tribunal hearings’ is provided just below, at the top left of the page, in a different banner, in the approved lighter shade of blue colour + * A link to sign in to verified accounts in CaTH at the top right of the page, on the same level as the ‘Court and tribunal hearings’ link + * A beta notification is displayed under the above acceptance criteria, informing users that this is a new service in the sentence ‘This is a new service – your feedback will help us to improve it.’ + * Link to a feedback from is provided in the text ‘feedback’ + * A link to switch to the Welsh translated page at the top right of the landing page, underneath the sign in link and on the same level as the beta notification + * A separate section at the bottom, demarcated by a dark blue narrow banner similar to the above criteria for the ‘Gov.UK’, displays the Crown logo followed by various appropriate links provided beneath the Crown, at the bottom of the page and embedded in the following texts; Help, privacy, cookies, accessibility statement, contact, terms and conditions, Welsh, government digital service and open government licence) + * This section is displayed in a light blue colour similar to the above criteria for the ‘sign in’ and ‘court and tribunal hearings’. + + * A link to the Crown copy right is embedded in the royal coat of arms logo at the bottom right of the page, with the crown copyright text written beneath it.', 'functional', 'verified', 'high', 'story', 234, 'https://github.com/hmcts/cath-service/issues/234', NULL, NULL, '2026-01-20T17:02:54Z', '2026-01-30T15:03:13Z', 'linusnorton', 'linusnorton'), + (13, 'REQ-0013', '‘What do you want to do?’ Page', '**PROBLEM STATEMENT** + +All CaTH users, including members of the public, have access to hearing lists published in CaTH. This would require users to undergo a few steps to navigate through the different pages in CaTH including selection what they want to view. + +  + +**AS A** CaTH User + +**I WANT** to select a court/tribunal + +**SO THAT** I can view specific hearing lists + +  + +**ACCEPTANCE CRITERIA** + * All CaTH users have access to unrestricted published hearing information in CaTH + * All users access the ‘What do you want to do?’ Page by clicking the ‘continue’ button on the landing page + * All users can see 2 radio buttons to select either to ‘find a court or tribunal’ or ‘find a single justice procedure case’ + * Under the ‘find a court or tribunal’, the descriptive text in the bracket is provided (View time, location, type of hearings and more) + * Under the ‘find a single justice procedure case’ option, the descriptive text in the bracket is provided (TV licensing, minor traffic offences such as speeding and more) + * Users can continue the process by clicking the ‘continue’ button + * All CaTH pages specifications are maintained', 'functional', 'verified', 'high', 'story', 235, 'https://github.com/hmcts/cath-service/issues/235', NULL, NULL, '2026-01-20T17:03:12Z', '2026-01-30T15:03:15Z', 'linusnorton', 'linusnorton'), + (14, 'REQ-0014', 'Find a single justice procedure case', '**PROBLEM STATEMENT** + +All CaTH users, including members of the public, have access to hearing lists published in CaTH including single justice procedure (SJP) cases. + +  + +**AS A** CaTH User + +**I WANT** to access a SJP hearing list + +**SO THAT** I can view specific SJP hearing information + +**Technical Specification:** + * Schema for Single Justice Procedure – Public List: + * Schema for Single Justice Procedure – Press List: + +  + +**ACCEPTANCE CRITERIA** + * There are 2 types of SJP lists; the public list and the press list + * The system should be able to handle up to 30,000 SJP case load  + * validation schema: (https://tools.hmcts.net/confluence/spaces/PUBH/pages/1558261966/SJP+Press+List)  (https://tools.hmcts.net/confluence/spaces/PUBH/pages/1558261961/SJP+Public+List)  + * style guide: attached document  + +**Single Justice Procedure – Public List** + * All CaTH users have access to unrestricted published hearing information in CaTH + * On the ‘What do you want to view from single justice procedure?’ page, users can see links to published SJP lists + * Users can click on any of the SJP list links to view the hearing details of all SJP cases that are published within each SJP list + * Each list displays the list title followed by the following text that states the number of cases in the list and the date and time the list was generated; ''List containing 10220 case(s) generated on 28 November 2025 at 9am''. underneath this is a green button with the text ''Download a copy'' that allows the user download the list  + * The SJP cases are published in a table with the following data fields; Name, Postcode, Offence and Prosecutor + * Users can click on the pages numbers to view the SJP cases published across different pages + * Users can click on the ‘show filters’ button to access the SJP filter + * Users can search for specific case details using the search bar + * Users can use the filter options to search for specific cases using the postcode or prosecutor + * Users can close each filter option by clicking on the collapsible accordion + * Users can clear the selected filter options by clicking the ‘clear filter’ link provided at the top of the filter + * Users can go back to the top of the page by clicking the ‘back to top’ arrow/text provided at the bottom of the page + * All CaTH pages specifications are maintained + +  + +**Single Justice Procedure – Press List** + * Only verified CaTH users have access to the SJP Press List + * under the list title is an accordion titled ''What are Single Justice Procedure Cases?'' which is open by default with the following text displayed ''Cases ready to be decided by a magistrate without a hearing. Includes TV licensing and minor traffic offences such as speeding.'' + * This is followed by the publication date and the date the list was published, displayed in the following format; List for 28 November 2025 + +Published 28 November 2025 at 9:05am + * A second accordion titled ''Important Information'' follows with the following text displayed ''In accordance with the media protocol, additional documents from these cases are available to the members of the media on request. The link below takes you to the full protocol and further information in relation to what documentation can be obtained (https://www.gov.uk/government/publications/guidance~~to~~staff~~on~~supporting~~media~~access~~to~~courts~~and~~tribunals/protocol~~on~~sharing~~court~~lists~~registers~~and~~documents~~with~~the~~media~~accessible~~version) '' (the linked masked in this text is (https://www.gov.uk/government/publications/guidance~~to~~staff~~on~~supporting~~media~~access~~to~~courts~~and~~tribunals/protocol~~on~~sharing~~court~~lists~~registers~~and~~documents~~with~~the~~media~~accessible~~version)) + * underneath this is a green button with the text ''Download a copy'' that allows the user download the list  + * Users can click on the ‘show filters’ button to access the SJP filter + * Users can click on the ‘show filters’ button to access the SJP filter + * Users can search for specific case details using the search bar + * Users can use the filter options to search for specific cases using the postcode or prosecutor + * Users can close each filter option by clicking on the collapsible accordion + * Users can clear the selected filter options by clicking the ‘clear filter’ link provided at the top of the filter + * Users can click on the pages numbers to view the SJP cases published across different pages + * The cases are displayed in sections that contain a table with the following titles in rows under column 1; Name, Date of Birth, Reference, Address, Prosecutor. This is followed by the ''Reporting Restriction'' which can be either true or false + * Users can go back to the top of the page by clicking the ‘back to top’ arrow/text provided at the bottom of the page + * All CaTH pages specifications are maintained + +  + +  + +**VIBE-151 specification** + +This specification includes {**}three pages{**}: + # *What do you want to view from Single Justice Procedure?* + + # *SJP Public List page* + + # *SJP Press List page* + +  + +VIBE-151 – Single Justice Procedure (SJP) Hearing Lists +# **User Story** + +**As a** CaTH User +**I want to** access a Single Justice Procedure (SJP) hearing list +**So that** I can view specific SJP hearing information + +  +# **PAGE 1 — What do you want to view from Single Justice Procedure?** +## **Form fields** +|Field|Input type|Required|Validation| +|None|N/A|N/A|Page is navigational only| + +  +## **Content** +### **EN:** + * Title/H1: “What do you want to view from Single Justice Procedure?” + + * SJP list links — “SJP Public List – ”, “SJP Press List – ” + + * Button/link: “Back” + +### **CY:** + * Title/H1: “Welsh placeholder” + + * SJP list links — “Welsh placeholder” + + * Back — “Welsh placeholder” + +  +## **Errors** + +No errors on this page. + +  +## **Back navigation** + * Back returns user to previous page (likely main hearings selection page). + +  + +  + +  + +  + +**PAGE 2 — SJP PUBLIC LIST PAGE** + +  +## **Form fields** +|Field name|Input type|Required|Validation| +|Search SJP cases|Text|No|Max 200 chars| +|Postcode filter|Text|No|Must match UK postcode regex format| +|Prosecutor filter|Dropdown|No|Options supplied by SJP list metadata| +|Filter accordions|Toggle|No|GOV.UK accordion pattern| +|Clear filters|Link|No|Resets all applied filters| +|Pagination|Number link|No|Must be a valid page index| + +  +## **Content** +### **EN:** + * Title/H1: “Single Justice Procedure – Public List” + + * Text under title: + + * + *** “List containing *** case(s) generated on **** at {**}