diff --git a/apps/postgres/prisma/migrations/20260119150000_add_list_search_config/migration.sql b/apps/postgres/prisma/migrations/20260119150000_add_list_search_config/migration.sql new file mode 100644 index 000000000..2b28ee0c8 --- /dev/null +++ b/apps/postgres/prisma/migrations/20260119150000_add_list_search_config/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "list_search_config" ( + "id" TEXT NOT NULL, + "list_type_id" INTEGER NOT NULL, + "case_number_field_name" VARCHAR(100) NOT NULL, + "case_name_field_name" VARCHAR(100) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "list_search_config_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "list_search_config_list_type_id_key" ON "list_search_config"("list_type_id"); diff --git a/apps/postgres/prisma/migrations/20260119151000_refactor_subscription_and_add_artefact_search/migration.sql b/apps/postgres/prisma/migrations/20260119151000_refactor_subscription_and_add_artefact_search/migration.sql new file mode 100644 index 000000000..8ade538e4 --- /dev/null +++ b/apps/postgres/prisma/migrations/20260119151000_refactor_subscription_and_add_artefact_search/migration.sql @@ -0,0 +1,37 @@ +-- DropForeignKey +ALTER TABLE "subscription" DROP CONSTRAINT IF EXISTS "subscription_location_id_fkey"; + +-- DropIndex +DROP INDEX IF EXISTS "idx_subscription_location"; + +-- DropIndex +DROP INDEX IF EXISTS "unique_user_location"; + +-- AlterTable +ALTER TABLE "subscription" +ADD COLUMN "search_type" VARCHAR(50) NOT NULL, +ADD COLUMN "search_value" TEXT NOT NULL, +DROP COLUMN IF EXISTS "location_id"; + +-- CreateTable +CREATE TABLE "artefact_search" ( + "id" TEXT NOT NULL, + "artefact_id" UUID NOT NULL, + "case_number" TEXT, + "case_name" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "artefact_search_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "artefact_search_artefact_id_idx" ON "artefact_search"("artefact_id"); + +-- CreateIndex +CREATE INDEX "idx_subscription_search" ON "subscription"("search_type", "search_value"); + +-- CreateIndex +CREATE UNIQUE INDEX "subscription_user_id_search_type_search_value_key" ON "subscription"("user_id", "search_type", "search_value"); + +-- AddForeignKey +ALTER TABLE "artefact_search" ADD CONSTRAINT "artefact_search_artefact_id_fkey" FOREIGN KEY ("artefact_id") REFERENCES "artefact"("artefact_id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/postgres/prisma/migrations/20260122150011_add_case_name_and_case_number_to_subscription/migration.sql b/apps/postgres/prisma/migrations/20260122150011_add_case_name_and_case_number_to_subscription/migration.sql new file mode 100644 index 000000000..20c2ddc56 --- /dev/null +++ b/apps/postgres/prisma/migrations/20260122150011_add_case_name_and_case_number_to_subscription/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "subscription" ADD COLUMN "case_name" TEXT, +ADD COLUMN "case_number" TEXT; + +-- CreateIndex +CREATE INDEX "idx_subscription_case_number" ON "subscription"("case_number"); diff --git a/apps/postgres/prisma/migrations/20260123151728_add_cascade_delete_to_notification_audit_log/migration.sql b/apps/postgres/prisma/migrations/20260123151728_add_cascade_delete_to_notification_audit_log/migration.sql new file mode 100644 index 000000000..fd2f1c8a6 --- /dev/null +++ b/apps/postgres/prisma/migrations/20260123151728_add_cascade_delete_to_notification_audit_log/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "notification_audit_log" DROP CONSTRAINT "notification_audit_log_subscription_id_fkey"; + +-- AddForeignKey +ALTER TABLE "notification_audit_log" ADD CONSTRAINT "notification_audit_log_subscription_id_fkey" FOREIGN KEY ("subscription_id") REFERENCES "subscription"("subscription_id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/postgres/prisma/schema.prisma b/apps/postgres/prisma/schema.prisma index fc792bbdf..a70ec08bf 100644 --- a/apps/postgres/prisma/schema.prisma +++ b/apps/postgres/prisma/schema.prisma @@ -9,24 +9,38 @@ datasource db { } model Artefact { - artefactId String @id @default(uuid()) @map("artefact_id") @db.Uuid - locationId String @map("location_id") - listTypeId Int @map("list_type_id") - contentDate DateTime @map("content_date") @db.Date + artefactId String @id @default(uuid()) @map("artefact_id") @db.Uuid + locationId String @map("location_id") + listTypeId Int @map("list_type_id") + contentDate DateTime @map("content_date") @db.Date sensitivity String language String - displayFrom DateTime @map("display_from") - displayTo DateTime @map("display_to") - lastReceivedDate DateTime @default(now()) @map("last_received_date") - isFlatFile Boolean @map("is_flat_file") + displayFrom DateTime @map("display_from") + displayTo DateTime @map("display_to") + lastReceivedDate DateTime @default(now()) @map("last_received_date") + isFlatFile Boolean @map("is_flat_file") provenance String - supersededCount Int @default(0) @map("superseded_count") - noMatch Boolean @default(false) @map("no_match") + supersededCount Int @default(0) @map("superseded_count") + noMatch Boolean @default(false) @map("no_match") ingestionLogs IngestionLog[] + artefactSearches ArtefactSearch[] @@map("artefact") } +model ArtefactSearch { + id String @id @default(uuid()) + artefactId String @map("artefact_id") @db.Uuid + caseNumber String? @map("case_number") + caseName String? @map("case_name") + createdAt DateTime @default(now()) @map("created_at") + + artefact Artefact @relation(fields: [artefactId], references: [artefactId], onDelete: Cascade) + + @@index([artefactId]) + @@map("artefact_search") +} + model User { userId String @id @default(uuid()) @map("user_id") @db.Uuid email String @db.VarChar(255) diff --git a/apps/postgres/scripts/migrate-subscriptions.ts b/apps/postgres/scripts/migrate-subscriptions.ts new file mode 100644 index 000000000..86423a384 --- /dev/null +++ b/apps/postgres/scripts/migrate-subscriptions.ts @@ -0,0 +1,57 @@ +import { prisma } from "@hmcts/postgres"; + +async function migrateSubscriptions() { + console.log("Starting subscription data migration..."); + + const subscriptions = await prisma.subscription.findMany({ + where: { + searchType: null + } + }); + + console.log(`Found ${subscriptions.length} subscriptions to migrate`); + + let migratedCount = 0; + let errorCount = 0; + + for (const subscription of subscriptions) { + try { + if (subscription.locationId) { + await prisma.subscription.update({ + where: { + subscriptionId: subscription.subscriptionId + }, + data: { + searchType: "LOCATION_ID", + searchValue: subscription.locationId.toString() + } + }); + migratedCount++; + } + } catch (error) { + console.error(`Failed to migrate subscription ${subscription.subscriptionId}:`, error); + errorCount++; + } + } + + console.log(`Migration completed:`); + console.log(` - Successfully migrated: ${migratedCount}`); + console.log(` - Errors: ${errorCount}`); + + const verifyCount = await prisma.subscription.count({ + where: { + searchType: "LOCATION_ID" + } + }); + + console.log(`Verification: ${verifyCount} subscriptions now have searchType='LOCATION_ID'`); +} + +migrateSubscriptions() + .catch((error) => { + console.error("Migration failed:", error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/postgres/src/schema-discovery.test.ts b/apps/postgres/src/schema-discovery.test.ts index 186bc4518..06c62dea2 100644 --- a/apps/postgres/src/schema-discovery.test.ts +++ b/apps/postgres/src/schema-discovery.test.ts @@ -12,12 +12,13 @@ describe("Schema Discovery", () => { expect(Array.isArray(result)).toBe(true); }); - it("should return array with subscriptions, location, and notifications schemas", () => { + it("should return array with subscription, location, notifications, and list-search-config schemas", () => { const result = getPrismaSchemas(); - expect(result.length).toBe(3); - expect(result.some((path) => path.includes("subscriptions"))).toBe(true); + expect(result.length).toBe(4); + expect(result.some((path) => path.includes("subscription"))).toBe(true); expect(result.some((path) => path.includes("location"))).toBe(true); expect(result.some((path) => path.includes("notifications"))).toBe(true); + expect(result.some((path) => path.includes("list-search-config"))).toBe(true); }); it("should return a new array on each call", () => { diff --git a/apps/postgres/src/schema-discovery.ts b/apps/postgres/src/schema-discovery.ts index 9c68e6c11..997b93365 100644 --- a/apps/postgres/src/schema-discovery.ts +++ b/apps/postgres/src/schema-discovery.ts @@ -1,8 +1,9 @@ // Schema discovery functionality for module integration +import { prismaSchemas as listSearchConfigSchemas } from "@hmcts/list-search-config/config"; 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 subscriptionSchemas } from "@hmcts/subscription/config"; export function getPrismaSchemas(): string[] { - return [subscriptionsSchemas, locationSchemas, notificationsSchemas]; + return [subscriptionSchemas, locationSchemas, notificationsSchemas, listSearchConfigSchemas]; } diff --git a/docs/tickets/VIBE-300/e2e-tests-summary.md b/docs/tickets/VIBE-300/e2e-tests-summary.md new file mode 100644 index 000000000..41d947089 --- /dev/null +++ b/docs/tickets/VIBE-300/e2e-tests-summary.md @@ -0,0 +1,200 @@ +# VIBE-300: E2E Tests Summary + +## Overview + +E2E tests created for case subscription functionality following the CLAUDE.md guidelines: +- Minimum number of tests (one per complete user journey) +- Validation, Welsh translation, and accessibility checks inline with each journey +- All tests tagged with @nightly for nightly-only execution + +## Test File Location + +`/Users/kian.kwa/IdeaProjects/cath-service/e2e-tests/tests/case-subscriptions.spec.ts` + +## Test Structure + +### 1. Subscribe by Case Name Journey +**Test:** `should complete subscription by case name with validation, Welsh, and accessibility @nightly` + +**Coverage:** +- Navigate from account home to subscription management +- Add email subscription flow +- Subscription method selection page with validation +- Case name search page with: + - Form validation (empty field) + - Keyboard navigation testing + - Welsh translation checks + - Accessibility testing +- Search results page with: + - Selection validation (at least one case required) + - Welsh translation + - Accessibility testing +- Pending subscriptions confirmation +- Subscription confirmed page +- Verify subscription appears in management list + +### 2. Subscribe by Case Reference Journey +**Test:** `should complete subscription by case reference with validation, Welsh, and accessibility @nightly` + +**Coverage:** +- Navigate to subscription method selection +- Select case reference option +- Case reference search page with: + - Form validation + - Welsh translation + - Accessibility testing +- Search results and selection +- Pending subscriptions and confirmation + +### 3. Multiple Search Results Journey +**Test:** `should handle multiple search results selection @nightly` + +**Coverage:** +- Search returns multiple results +- Select multiple cases with checkboxes +- Verify multiple cases in pending subscriptions +- Remove one subscription from pending list +- Confirm remaining subscriptions +- Accessibility testing + +### 4. No Results Found Journey +**Test:** `should handle no search results with validation and accessibility @nightly` + +**Coverage:** +- Search for non-existent case name +- Verify "no results found" error message +- Welsh translation of error message +- Accessibility testing with error state +- Test same flow for case reference search +- Verify error handling on both search types + +### 5. View Subscriptions by Case Journey +**Test:** `should view and manage case subscriptions with tabs and accessibility @nightly` + +**Coverage:** +- Create a case subscription +- Navigate to subscription management +- Verify tabs (All, By case, By court/tribunal) +- Welsh translation of tabs +- Accessibility testing with tabs +- Click "By case" tab and verify URL parameter +- View case subscriptions table +- Remove case subscription flow +- Verify subscription is removed from list + +### 6. Authentication Protection +**Test:** `should require authentication for case subscription pages @nightly` + +**Coverage:** +- Test all protected pages redirect to sign-in when not authenticated: + - /subscription-add + - /case-name-search + - /case-name-search-results + - /case-number-search + - /case-number-search-results + +## Test Data Management + +### Setup (beforeEach) +- Creates unique test case data using timestamp and random ID +- Creates test artefact in artefact_search table +- Stores test data in Map keyed by testInfo.testId for parallel execution safety +- Authenticates user with CFT IDAM + +### Teardown (afterEach) +- Deletes associated subscriptions +- Deletes test artefact data +- Removes test data from Map + +### Parallel Test Safety +- Uses Map with testInfo.testId as key to avoid conflicts +- Generates unique IDs for each test run +- Proper cleanup ensures no data leakage between tests + +## Key Testing Patterns + +### Inline Validation Testing +All journeys include validation checks within the user flow: +- Empty form submission errors +- Required field validations +- Selection requirement checks + +### Inline Welsh Translation Testing +Welsh translation checked at key points in each journey: +- Page headings +- Error messages +- Tab labels +- Form labels + +### Inline Accessibility Testing +Accessibility scans performed inline using AxeBuilder: +- Disables "region" rule (project standard) +- Tests at each major page in the journey +- Verifies no violations + +### Keyboard Navigation Testing +Tests keyboard interaction where relevant: +- Tab key navigation +- Enter key form submission + +## Dependencies + +### External Dependencies +- @playwright/test +- @axe-core/playwright +- @hmcts/postgres (for test data management) + +### Helper Functions +- loginWithCftIdam from e2e-tests/utils/cft-idam-helpers.js + +### Environment Variables +- CFT_VALID_TEST_ACCOUNT +- CFT_VALID_TEST_ACCOUNT_PASSWORD + +## Assumptions + +### Database Schema +Tests assume VIBE-316 is complete with: +- artefact_search table exists +- Subscription table has caseName and caseNumber columns +- locationId column in artefact_search table + +### Pages Exist +Tests assume the following pages are implemented: +- /subscription-management +- /subscription-add +- /case-name-search +- /case-name-search-results +- /case-number-search +- /case-number-search-results +- /pending-subscriptions +- /subscription-confirmed +- /delete-subscription +- /unsubscribe-confirmation + +## Running the Tests + +```bash +# Run all E2E tests including @nightly tagged tests +yarn test:e2e:all + +# Run E2E tests excluding @nightly tests +yarn test:e2e + +# Run case subscriptions tests specifically +npx playwright test case-subscriptions.spec.ts + +# Run with UI +npx playwright test case-subscriptions.spec.ts --ui + +# Run in headed mode for debugging +npx playwright test case-subscriptions.spec.ts --headed +``` + +## Notes + +- All tests are tagged with @nightly as they represent complete user journeys +- Tests follow the principle: one test per complete user journey +- No separate tests for individual validations, Welsh, or accessibility - all inline +- Tests use proper Playwright selectors (getByRole, getByLabel) for accessibility +- Test data cleanup is robust with try-catch to handle missing data gracefully diff --git a/docs/tickets/VIBE-300/plan.md b/docs/tickets/VIBE-300/plan.md new file mode 100644 index 000000000..d6db9dab6 --- /dev/null +++ b/docs/tickets/VIBE-300/plan.md @@ -0,0 +1,400 @@ +# VIBE-300: Implementation Plan + +## Overview + +This ticket implements case-based subscription functionality for verified media users. Users can subscribe to hearing lists by case name, case reference number, case ID, or URN. The implementation builds on VIBE-316's artefact_search infrastructure. + +## Critical Files + +### New Files to Create + +1. **Verified Pages Module** (existing - `libs/verified-pages/`) + + **Note:** Each page follows folder structure: `folder/index.ts`, `folder/index.njk`, `folder/en.ts`, `folder/cy.ts`, `folder/index.test.ts` + + - `libs/verified-pages/src/pages/subscription-management/` - Main subscriptions page (EXISTING - update to support case subscriptions) + - `libs/verified-pages/src/pages/subscription-add/` - Method selection page folder + - `libs/verified-pages/src/pages/case-name-search/` - Case name search folder + - `libs/verified-pages/src/pages/case-number-search/` - Reference number search folder + - `libs/verified-pages/src/pages/case-name-search-results/` - Case name search results folder + - `libs/verified-pages/src/pages/case-number-search-results/` - Case number search results folder + - `libs/verified-pages/src/pages/pending-subscriptions/` - Pending subscriptions page (EXISTING - update to support case subscriptions) + - `libs/verified-pages/src/pages/subscription-confirmed/` - Confirmation success page (EXISTING - verify) + - `libs/verified-pages/src/assets/css/` - Styles (update existing or add new) + +2. **Subscription Module** (existing - `libs/subscription/`) + - `libs/subscription/src/repository/queries.ts` - Subscription repository queries (case subscription data access) + - `libs/subscription/src/repository/service.ts` - Subscription service layer (subscription management) + - `libs/subscription/src/case-search-service.ts` - Case search logic + +3. **Publication Module** (existing - `libs/publication/`) + - `libs/publication/src/repository/artefact-search-queries.ts` - Case search queries (if not exists from VIBE-316) + - Move artefact search code from `libs/publication/src/repository/queries.ts` to new file + +### Files to Modify + +1. **Database Schema** + - `libs/postgres/prisma/schema.prisma` - Add `case_name` and `case_number` columns to Subscription model + +2. **Subscription Module** + - `libs/subscription/src/repository/queries.ts` - Add case subscription repository methods + - `libs/subscription/src/repository/service.ts` - Add case subscription service methods + +3. **Notifications Module** + - `libs/notifications/src/notification/subscription-queries.ts` - Add case subscription queries for fulfilment + - `libs/notifications/src/notification/notification-service.ts` - Update to handle case subscription fulfilment + +4. **Subscription Management Page (Existing)** + - `libs/verified-pages/src/pages/subscription-management/` - Update to support case subscriptions + +**Note:** No app registration or dashboard navigation changes needed - both already exist. + +## Implementation Steps + +### Phase 0: Module Refactoring (Priority: Critical - Do First) + +**Rename subscriptions module to singular:** +1. Rename directory: `libs/subscriptions` → `libs/subscription` +2. Update package name in `libs/subscription/package.json`: `@hmcts/subscriptions` → `@hmcts/subscription` +3. Update all imports across the codebase from `@hmcts/subscriptions` to `@hmcts/subscription` (~28 occurrences in 19 files) + - Root `tsconfig.json` + - `apps/postgres/src/schema-discovery.ts` + - `libs/verified-pages/package.json` and various page controllers + - Test files +4. Run `yarn install` to update workspace dependencies +5. Verify all tests pass: `yarn test` + +**Why:** Ensures consistent singular naming convention across all modules (e.g., `@hmcts/auth`, `@hmcts/publication`, `@hmcts/subscription`). + +### Phase 1: Database Schema (Priority: Critical) + +1. **Update Prisma model** in `libs/postgres/prisma/schema.prisma` + ```prisma + model Subscription { + id String @id @default(cuid()) + userId String @map("user_id") + searchType String @map("search_type") @db.VarChar(50) + searchValue String @map("search_value") + caseName String? @map("case_name") // NEW COLUMN - display name + caseNumber String? @map("case_number") // NEW COLUMN - normalized number + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("subscription") + @@index([userId]) + @@index([searchType, searchValue]) + @@index([caseNumber]) + } + ``` + +2. **Generate migration** + ```bash + yarn db:migrate:dev + ``` + +### Phase 2: Case Search Service (Priority: High) + +1. **Create case search service** `libs/subscription/src/case-search-service.ts` + - `searchByCaseName(caseName: string)` - Search artefact_search table + - `searchByCaseReference(reference: string)` - Exact match search + - Return array of matching cases with: case_number, case_name, party_name (if available) + +2. **Create artefact search repository** (if not exists from VIBE-316) + - `libs/publication/src/repository/artefact-search-queries.ts` + - Move any artefact search code from `libs/publication/src/repository/queries.ts` + - `findByCaseName(caseName: string)` + - `findByCaseNumber(caseNumber: string)` + +3. **Add unit tests** for search service + +### Phase 3: Subscription Management Service (Priority: High) + +1. **Create or update repository queries** `libs/subscription/src/repository/queries.ts` + - `findByUserId(userId: string)` - Get all subscriptions for user + - `findByUserIdAndType(userId: string, searchType: string)` - Get subscriptions by type + - `createSubscription(userId, searchType, searchValue, caseName, caseNumber)` - Create subscription + - `deleteSubscription(id: string)` - Delete subscription + +2. **Create or update service layer** `libs/subscription/src/repository/service.ts` + - `getUserSubscriptions(userId: string)` - Get all subscriptions + - `getSubscriptionsByCase(userId: string)` - Filter case subscriptions + - `getSubscriptionsByLocation(userId: string)` - Filter location subscriptions + - `createCaseSubscription(userId, caseNumber, caseName)` - Create new case subscription + +3. **Add unit tests** for repository queries and service layer + +### Phase 4: Subscription Management Pages (Priority: High) + +**Note:** Each page follows folder structure: `folder/index.ts`, `folder/index.njk`, `folder/en.ts`, `folder/cy.ts`, `folder/index.test.ts` + +#### Page 1: Subscription Management (EXISTING - Update) +**Path:** `/subscription-management` at `libs/verified-pages/src/pages/subscription-management/` + +1. **Update controller** `index.ts` + - Enhance GET handler to support case subscriptions + - Add query param handling: `view` (all, case, location) +2. **Update English content** `en.ts` - Add case-related text, labels, headings +3. **Update Welsh content** `cy.ts` - Add Welsh translations for case content +4. **Update template** `index.njk` + - Add tabs with counts (All, By case, By court/tribunal) + - Add table for case subscriptions (Case name, Reference number, Date added, Checkbox) + - Keep existing location subscriptions table + - Update empty state message +5. **Update tests** `index.test.ts` - Add tests for new case subscription functionality + +#### Page 2: Add Subscription Method +1. **Create folder** `libs/verified-pages/src/pages/subscription-add/` +2. **Create controller** `index.ts` + - GET handler: Render form + - POST handler: Validate selection, redirect based on method +3. **Create English content** `en.ts` - All page text, labels, options +4. **Create Welsh content** `cy.ts` - All page text, labels, options +5. **Create template** `index.njk` + - Radio buttons for three methods + - Body text about published information only + - Error summary +6. **Create tests** `index.test.ts` + +#### Page 3: Subscribe by Case Name +1. **Create folder** `libs/verified-pages/src/pages/case-name-search/` +2. **Create controller** `index.ts` + - GET handler: Render form + - POST handler: Search by case name + - If no results: Show error message on same page + - If results found: Store in session, redirect to `/case-name-search-results` +3. **Create English content** `en.ts` - All page text, labels, errors +4. **Create Welsh content** `cy.ts` - All page text, labels, errors +5. **Create template** `index.njk` + - Input field for case name + - Validation errors + - Error summary +6. **Create tests** `index.test.ts` + +#### Page 4: Subscribe by Reference +1. **Create folder** `libs/verified-pages/src/pages/case-number-search/` +2. **Create controller** `index.ts` + - GET handler: Render form + - POST handler: Search by reference number + - If no results: Show error message on same page + - If results found: Store in session, redirect to `/case-number-search-results` +3. **Create English content** `en.ts` - All page text, labels, errors +4. **Create Welsh content** `cy.ts` - All page text, labels, errors +5. **Create template** `index.njk` + - Input field for reference number + - Validation errors + - Error summary +6. **Create tests** `index.test.ts` + +#### Page 5: Case Name Search Results +1. **Create folder** `libs/verified-pages/src/pages/case-name-search-results/` +2. **Create controller** `index.ts` + - GET handler: Load search results from session + - POST handler: Validate selections (at least one), store in session, redirect to `/pending-subscriptions` +3. **Create English content** `en.ts` - Title: "Subscription case search results", table headers, button text +4. **Create Welsh content** `cy.ts` - All page text, table headers +5. **Create template** `index.njk` + - Table with checkboxes (allow multiple selections) + - 3 columns: "Select a result" (checkbox), "Case name", "Reference number" + - "Continue" button at bottom + - Validation error if no selection made +6. **Create tests** `index.test.ts` + +#### Page 6: Case Number Search Results +1. **Create folder** `libs/verified-pages/src/pages/case-number-search-results/` +2. **Create controller** `index.ts` + - GET handler: Load search results from session + - POST handler: Validate selections (at least one), store in session, redirect to `/pending-subscriptions` +3. **Create English content** `en.ts` - Title: "Subscription case search results", table headers, button text +4. **Create Welsh content** `cy.ts` - All page text, table headers +5. **Create template** `index.njk` + - Table with checkboxes (allow multiple selections) + - 3 columns: "Select a result" (checkbox), "Case name", "Reference number" + - "Continue" button at bottom + - Validation error if no selection made +6. **Create tests** `index.test.ts` + +#### Page 7: Pending Subscriptions (EXISTING - Update) +**Path:** `/pending-subscriptions` at `libs/verified-pages/src/pages/pending-subscriptions/` + +1. **Update controller** `index.ts` + - GET handler: Load pending subscriptions from session (both case and location) + - POST handler: Save all pending subscriptions, redirect to `/subscription-confirmed` + - Add remove functionality for case subscriptions +2. **Update English content** `en.ts` - Add case subscription table headers and content +3. **Update Welsh content** `cy.ts` - Add Welsh translations for case content +4. **Update template** `index.njk` + - Add new table for case subscriptions + - 3 columns: "Case Name", "Reference number", "Actions" + - "Remove" link in Actions column for each row + - Keep existing court/tribunal subscription table + - Keep existing "Confirm subscription" button +5. **Update tests** `index.test.ts` - Add tests for case subscription functionality + +#### Page 8: Subscription Confirmed (EXISTING - Verify) +**Path:** `/subscription-confirmed` at `libs/verified-pages/src/pages/subscription-confirmed/` + +1. **Verify existing page** - No changes needed unless confirmation message needs updating for case subscriptions + +### Phase 5: Translations (Priority: High) + +**Note:** Translations are handled in separate `en.ts` and `cy.ts` files within each page folder (already covered in Phase 4). +- Each page folder contains `en.ts` (English content) and `cy.ts` (Welsh content) +- Use translations from ticket description +- All page titles, form labels, button text, error messages, and table headers included in respective files + +### Phase 6: Subscription Fulfilment (Priority: High) + +1. **Update subscription queries** `libs/notifications/src/notification/subscription-queries.ts` + - Add `findActiveSubscriptionsByCaseNumbers(caseNumbers: string[])` + - Query subscriptions where `searchType = 'CASE_NUMBER'` and `searchValue` in caseNumbers + - Return `SubscriptionWithUser[]` matching existing interface + - Include user email, firstName, surname + - Add unit tests for new query function + +2. **Update notification service** `libs/notifications/src/notification/notification-service.ts` + - Update `sendPublicationNotifications()` function: + ```typescript + // 1. Get existing location subscriptions (already implemented) + const locationSubscriptions = await findActiveSubscriptionsByLocation(locationId); + + // 2. Get case numbers from artefact search table + const artefactCases = await findArtefactSearchByArtefactId(artefactId); + const caseNumbers = artefactCases.map(ac => ac.caseNumber).filter(Boolean); + + // 3. Get case subscriptions + const caseSubscriptions = caseNumbers.length > 0 + ? await findActiveSubscriptionsByCaseNumbers(caseNumbers) + : []; + + // 4. Combine and deduplicate subscriptions + const allSubscriptions = [...locationSubscriptions, ...caseSubscriptions]; + + // 5. Process all with existing processUserNotification() + ``` + - Ensure no duplicate notifications to same user + - Add unit tests for case subscription fulfilment + - Add integration tests for combined fulfilment flow + +### Phase 7: Access Control (Priority: High) + +1. **Add middleware** for verified user check + - Verify user has VERIFIED_USER role + - Redirect to login if not authenticated + - Display error if not verified + +2. **Apply middleware** to all subscription management routes + +### Phase 8: Integration & Registration (Priority: Medium) + +**Note:** The `verified-pages` module is already registered. No additional registration needed. +- Pages will be auto-discovered through existing `verified-pages` module registration +- Assets already configured in `apps/web/vite.config.ts` +- Verify that new services exported from `@hmcts/subscription` are accessible + +### Phase 9: Testing (Priority: High) + +1. **Unit tests** + - Case search service + - Subscription management service + - Repository methods + - Form validation logic + +2. **Integration tests** + - Case search by name + - Case search by reference + - Subscription creation + - Subscription fulfilment + - Permission checks + +3. **E2E tests** + - Complete subscription by case name journey + - Complete subscription by reference journey + - Multiple search results journey + - No results found journey + - View subscriptions journey + - Welsh translation journey + - Accessibility checks with Axe + +## Technical Considerations + +### Session Management + +- Store search results in session between pages +- Store selected case in session for confirmation +- Clear session data after subscription created + +### Search Logic + +**Case Name Search:** +- Use case-insensitive ILIKE search +- Pattern: `%{search_term}%` +- Return all matches + +**Reference Search:** +- Exact match only +- Case-sensitive or case-insensitive depending on data format + +### Performance + +- Index `artefact_search(case_number)` for reference lookups +- Index `artefact_search(case_name)` for name searches (consider GIN for text search) +- Limit search results (e.g., max 50 results) + +### User Experience + +- If single search result, skip results page and go directly to confirmation +- Display party name in results if available +- Clear error messages for no results +- Preserve search term on validation error + +### Data Consistency + +- Validate case still exists before creating subscription +- Handle race conditions (case deleted between search and confirmation) + +## Testing Strategy + +### Unit Tests +- Search service logic +- Validation rules +- Repository queries +- Subscription creation + +### Integration Tests +- Database operations +- Search queries +- Fulfilment matching +- Session handling + +### E2E Tests +- Full subscription journeys (by name, by reference) +- Edge cases (no results, multiple results) +- Permission checks +- Welsh language support +- Accessibility with Axe + +## Risks & Mitigations + +| Risk | Impact | Mitigation | +|------|--------|-----------| +| VIBE-316 not completed | High | Verify artefact_search table exists before starting | +| Large search results slow page | Medium | Limit results, add pagination if needed | +| Case data changes after search | Low | Validate case exists before creating subscription | +| Session data loss | Medium | Store minimal data in session, re-query if needed | + +## Success Criteria + +1. ✅ Database schema updated with case_name and case_number columns +2. ✅ Users can search by case name and reference number +3. ✅ Search results display correctly +4. ✅ Subscriptions created with search_type = CASE_NUMBER +5. ✅ Case name and case number stored in subscription record +6. ✅ Subscriptions appear in "Subscriptions by case" table +7. ✅ Notifications sent when matching artefact published +8. ✅ All pages support Welsh language +9. ✅ Accessibility standards met (WCAG 2.2 AA) +10. ✅ All tests passing + +## Estimated Complexity: High + +This ticket involves creating a new multi-page user journey, case search functionality, subscription management UI, and integration with existing fulfilment logic. The complexity is high due to the number of pages, search logic, and dependency on VIBE-316. diff --git a/docs/tickets/VIBE-300/specification.md b/docs/tickets/VIBE-300/specification.md new file mode 100644 index 000000000..4ca45fdea --- /dev/null +++ b/docs/tickets/VIBE-300/specification.md @@ -0,0 +1,243 @@ +# VIBE-300: Subscribe by case name, case reference number, case ID or unique reference number (URN) + +## Problem Statement + +Verified media users need the ability to subscribe to hearing lists by case-specific identifiers (case name, case reference number, case ID, or URN), not just by location. This enables users to receive email notifications for specific cases they are monitoring. + +## User Story + +**As a** verified media user +**I want** to subscribe to hearing lists in CaTH +**So that** I can receive email notifications whenever a list I subscribed to is published. + +## Pre-conditions + +- User has valid credentials and is approved as a verified media user +- Only published information is available for searching +- Email notifications are implemented in Gov Notify +- `artefact_search` table exists (from VIBE-316) with case data +- Subscription table has `search_type` and `search_value` columns (from VIBE-316) + +## Technical Requirements + +### Database Changes + +**Subscription Table Updates:** +- Add column: `case_name` (text, nullable) - display name of the case +- Add column: `case_number` (text, nullable) - normalized case number for matching +- Existing columns from VIBE-316: + - `search_type` (varchar 50) + - `search_value` (text) + +**Data Storage Rules:** +- When subscribing by case name: + - `search_type` = `'CASE_NUMBER'` + - `search_value` = resolved case number + - `case_number` = resolved case number (duplicate for clarity) + - `case_name` = entered/displayed case name +- When subscribing by case reference/ID/URN: + - `search_type` = `'CASE_NUMBER'` + - `search_value` = case number + - `case_number` = case number + - `case_name` = case name (if available from search results) + +### User Journey Pages + +#### 1. Verified User Dashboard +- **Entry point** after sign-in +- Navigation links: + - "Court and tribunal hearings" + - "Dashboard" + - "Email subscriptions" + +#### 2. Your Email Subscriptions +- **URL**: `/subscription-management` or `/email-subscriptions` +- **Access**: Verified users only +- **Content**: + - H1: "Your email subscriptions" + - Button: "Add email subscription" (green) + - Display tabs: + - "All subscriptions" (shows count) + - "Subscriptions by case" (shows count) + - "Subscription by court or tribunal" (shows count) + - Empty state: "You do not have any active subscriptions" + +**Subscriptions by Case Table:** +- Columns: Case name, Reference number, Date added, Checkbox +- Displays all case-based subscriptions + +**Subscriptions by Court or Tribunal Table:** +- Columns: Court or tribunal name, Date added, Checkbox +- Displays all location-based subscriptions + +**All Subscriptions:** +- Shows both tables if both types exist + +#### 3. How Do You Want to Add an Email Subscription? +- **Form field**: Subscription method (radio buttons, required) +- **Options**: + - By court or tribunal name + - By case name + - By case reference number, case ID or unique reference number (URN) +- **Body text**: "You can only search for information that is currently published." +- **Validation**: Must select one option + +#### 4. Enter Case Name (if "By case name" selected) +- **Form field**: Case name (text, required, max 255 characters) +- **Validation**: + - Must not be empty + - Search `artefact_search` table for matching case names + - Display error if no results found: "No results found" + +#### 5. Enter Case Reference Number / URN (if "By case reference..." selected) +- **Form field**: Reference number (text, required, alphanumeric and symbols) +- **Validation**: + - Must not be empty + - Search `artefact_search` table for matching case numbers + - Display error if no match: "No matching case found" + +#### 6. Case Search Results +- **Form field**: Case selection (radio buttons, required) +- **Table columns**: Case name, Party name, Reference number +- **Validation**: Must select one case + +#### 7. Confirm Email Subscription +- **Content**: Display selected case details +- **Button**: "Confirm" +- **Action**: Create subscription record + +#### 8. Subscription Added +- **Content**: "Your email subscription has been added." +- **Link**: "Email subscriptions" (returns to subscription management page) + +### Search Logic + +**By Case Name:** +1. Query `artefact_search` table: `WHERE case_name ILIKE '%{search_term}%'` +2. Return matching cases with case_number and case_name +3. If multiple results, show search results page +4. If single result, proceed directly to confirmation + +**By Case Reference/ID/URN:** +1. Query `artefact_search` table: `WHERE case_number = '{reference}'` +2. Return exact match +3. If found, show search results (or proceed directly to confirmation) +4. If not found, display "No matching case found" + +### Subscription Creation + +When user confirms subscription: +1. Create subscription record: + - `user_id` = current user ID + - `search_type` = `'CASE_NUMBER'` + - `search_value` = case number from search results + - `case_name` = case name (if available) + - `created_at` = current timestamp +2. Subscription appears immediately in user's subscription table +3. Redirect to confirmation page + +### Subscription Fulfilment + +When an artefact is published: +1. Extract case information into `artefact_search` table (VIBE-316 functionality) +2. Query subscriptions: + ```sql + WHERE search_type = 'CASE_NUMBER' + AND search_value IN (SELECT case_number FROM artefact_search WHERE artefact_id = :artefact_id) + ``` +3. Send notification emails to matched subscriptions via Gov Notify + +### Content Requirements + +All content must be provided in both English and Welsh. + +**Dashboard:** +- EN: Title "Dashboard" +- EN: Navigation "Court and tribunal hearings", "Dashboard", "Email subscriptions" +- CY: Translations provided in ticket description + +**Your Email Subscriptions:** +- EN: Title "Your email subscriptions" +- EN: Button "Add email subscription" +- EN: Tabs "All subscriptions", "Subscriptions by case", "Subscription by court or tribunal" +- EN: Empty state "You do not have any active subscriptions" +- CY: Translations provided + +**Subscription Method Selection:** +- EN: Title "How do you want to add an email subscription?" +- EN: Body "You can only search for information that is currently published." +- EN: Options as listed above +- EN: Error "Select how you want to add an email subscription." +- CY: Translations provided + +**Enter Case Name:** +- EN: Title "By case name" +- EN: Label "Case name" +- EN: Error "Enter a case name" +- EN: Error "No results found" +- CY: Translations needed + +**Enter Reference Number:** +- EN: Title "By case reference number, case ID or unique reference number (URN)" +- EN: Label "Reference number" +- EN: Error "Enter reference number" +- EN: Error "No matching case found" +- CY: Translations provided + +**Search Results:** +- EN: Title "Select a case" +- EN: Table headers "Case name", "Party name", "Reference number" +- EN: Error "Select a case to continue" +- CY: Translations needed + +**Confirmation:** +- EN: Title "Confirm email subscription" +- EN: Button "Confirm" +- CY: Translations provided + +**Success:** +- EN: Title "Subscription added" +- EN: Body "Your email subscription has been added." +- EN: Link "Email subscriptions" +- CY: Translations provided + +## Accessibility Requirements + +- WCAG 2.2 AA standards compliance +- Radio buttons and tables fully keyboard accessible +- Error messages announced to assistive technologies +- Unique and descriptive page headings +- Proper form labels and error associations + +## Test Scenarios + +1. Verified media user can access Email Subscriptions from dashboard +2. User with no subscriptions sees empty state message +3. Validation errors appear when required inputs are missing +4. Subscribing without selecting a method displays validation error +5. Case search by name returns results based on published information only +6. Case search by reference number finds exact matches +7. No results found message appears when search returns no matches +8. Multiple search results display in table with radio buttons +9. Selecting and confirming a case adds subscription immediately +10. Subscription appears in "Subscriptions by case" table +11. Subscription stored with `search_type = 'CASE_NUMBER'` +12. Case name stored in `case_name` column when subscribing by name +13. Subscription confirmation page displays success message +14. Notification email sent when matching artefact is published +15. Welsh translations display correctly +16. All pages meet accessibility standards + +## Dependencies + +- VIBE-316: Requires `artefact_search` table and `search_type`/`search_value` columns in subscription table +- Existing location-based subscription functionality +- Gov Notify integration for email notifications + +## Out of Scope + +- Editing existing subscriptions +- Bulk subscription management +- Subscription expiry/renewal +- Advanced search filters (date range, court type, etc.) +- Email notification content customization diff --git a/docs/tickets/VIBE-300/tasks.md b/docs/tickets/VIBE-300/tasks.md new file mode 100644 index 000000000..2558a41df --- /dev/null +++ b/docs/tickets/VIBE-300/tasks.md @@ -0,0 +1,249 @@ +# VIBE-300: Implementation Tasks + +## Phase 0: Module Refactoring ⚠️ DO FIRST + +- [x] Rename `libs/subscriptions` to `libs/subscription` (singular) +- [x] Update all imports from `@hmcts/subscriptions` to `@hmcts/subscription` +- [x] Update package name in `libs/subscription/package.json` +- [x] Update root `tsconfig.json` path mapping +- [x] Run `yarn install` to update workspace dependencies +- [x] Verify all tests pass after rename + +## Phase 1: Database Schema ⚠️ CRITICAL + +- [x] Verify VIBE-316 is complete and `artefact_search` table exists +- [x] Update `libs/subscription/prisma/schema.prisma` to add `case_name` and `case_number` columns to Subscription model +- [x] Generate Prisma migration: `yarn db:migrate:dev` +- [x] Verify migration applied successfully + +## Phase 2: Case Search Service + +### Repository Layer (if not exists from VIBE-316) +- [x] Create `libs/publication/src/repository/artefact-search-queries.ts` +- [x] Move any artefact search code from `libs/publication/src/repository/queries.ts` to new file +- [x] Implement `findByCaseName(caseName: string)` +- [x] Implement `findByCaseNumber(caseNumber: string)` +- [x] Add unit tests for repository + +### Search Service +- [x] Create `libs/subscription/src/case-search-service.ts` +- [x] Implement `searchByCaseName(caseName: string)` +- [x] Implement `searchByCaseReference(reference: string)` +- [x] Add unit tests for search service + +## Phase 3: Subscription Management Service + +### Repository Layer +- [x] Create or update `libs/subscription/src/repository/queries.ts` +- [x] Implement `findByUserId(userId: string)` +- [x] Implement `findByUserIdAndType(userId: string, searchType: string)` +- [x] Implement `createSubscription(userId, searchType, searchValue, caseName, caseNumber)` +- [x] Implement `deleteSubscription(id: string)` +- [x] Add unit tests for repository queries + +### Service Layer +- [x] Create or update `libs/subscription/src/repository/service.ts` +- [x] Implement `getUserSubscriptions(userId: string)` +- [x] Implement `getSubscriptionsByCase(userId: string)` +- [x] Implement `getSubscriptionsByLocation(userId: string)` +- [x] Implement `createCaseSubscription(userId, caseNumber, caseName)` +- [x] Add unit tests for service + +## Phase 4: Subscription Management Pages + +**Note:** Each page follows the folder structure pattern: `folder/index.ts`, `folder/index.njk`, `folder/en.ts`, `folder/cy.ts`, `folder/index.test.ts` + +### Page 1: Subscription Management (EXISTING - Update) +**Path:** `/subscription-management` (already exists at `libs/verified-pages/src/pages/subscription-management/`) + +- [x] Update `index.ts` - Enhance GET handler to support case subscriptions +- [x] Implement query param handling: `view` (all, case, location) +- [x] Update `en.ts` - Add case-related content +- [x] Update `cy.ts` - Add Welsh translations for case content +- [x] Update `index.njk` - Add tabs with counts (All, By case, By court/tribunal) +- [x] Add table for case subscriptions (Case name, Reference number, Date added, Checkbox) +- [x] Keep existing location subscriptions table +- [x] Update empty state message +- [ ] Update `index.test.ts` - Add tests for new functionality + +### Page 2: Add Subscription Method +- [x] Create folder `libs/verified-pages/src/pages/subscription-add/` +- [x] Create `index.ts` - Controller with GET/POST handlers +- [x] Implement GET handler to render form +- [x] Implement POST handler to validate selection and redirect +- [x] Create `en.ts` - English content object +- [x] Create `cy.ts` - Welsh content object +- [x] Create `index.njk` - Template with radio buttons +- [x] Add body text about published information +- [x] Add error summary component +- [x] Create `index.test.ts` - Unit tests + +### Page 3: Subscribe by Case Name +- [x] Create folder `libs/verified-pages/src/pages/case-name-search/` +- [x] Create `index.ts` - Controller with GET/POST handlers +- [x] Implement GET handler to render form +- [x] Implement POST handler to search and redirect to results +- [x] Handle no results scenario with error message +- [x] Store search results in session +- [x] Redirect to `/case-name-search-results` on success +- [x] Create `en.ts` - English content object +- [x] Create `cy.ts` - Welsh content object +- [x] Create `index.njk` - Template with input field +- [x] Add validation and error messages +- [x] Add error summary component +- [x] Create `index.test.ts` - Unit tests + +### Page 4: Subscribe by Reference +- [x] Create folder `libs/verified-pages/src/pages/case-number-search/` +- [x] Create `index.ts` - Controller with GET/POST handlers +- [x] Implement GET handler to render form +- [x] Implement POST handler to search and redirect to results +- [x] Handle no results scenario with error message +- [x] Store search results in session +- [x] Redirect to `/case-number-search-results` on success +- [x] Create `en.ts` - English content object +- [x] Create `cy.ts` - Welsh content object +- [x] Create `index.njk` - Template with input field +- [x] Add validation and error messages +- [x] Add error summary component +- [x] Create `index.test.ts` - Unit tests + +### Page 5: Case Name Search Results +- [x] Create folder `libs/verified-pages/src/pages/case-name-search-results/` +- [x] Create `index.ts` - Controller with GET/POST handlers +- [x] Implement GET handler to load search results from session +- [x] Implement POST handler to validate selections and redirect to pending-subscriptions +- [x] Store selected cases in session for pending-subscriptions page +- [x] Create `en.ts` - English content object with title "Subscription case search results" +- [x] Create `cy.ts` - Welsh content object +- [x] Create `index.njk` - Template with table and checkboxes +- [x] Add 3 columns: "Select a result" (checkbox), "Case name", "Reference number" +- [x] Add "Continue" button at bottom +- [x] Add validation - at least one case must be selected +- [x] Create `index.test.ts` - Unit tests + +### Page 6: Case Number Search Results +- [x] Create folder `libs/verified-pages/src/pages/case-number-search-results/` +- [x] Create `index.ts` - Controller with GET/POST handlers +- [x] Implement GET handler to load search results from session +- [x] Implement POST handler to validate selections and redirect to pending-subscriptions +- [x] Store selected cases in session for pending-subscriptions page +- [x] Create `en.ts` - English content object with title "Subscription case search results" +- [x] Create `cy.ts` - Welsh content object +- [x] Create `index.njk` - Template with table and checkboxes +- [x] Add 3 columns: "Select a result" (checkbox), "Case name", "Reference number" +- [x] Add "Continue" button at bottom +- [x] Add validation - at least one case must be selected +- [x] Create `index.test.ts` - Unit tests + +### Page 7: Pending Subscriptions (EXISTING - Update) +**Path:** `/pending-subscriptions` (already exists at `libs/verified-pages/src/pages/pending-subscriptions/`) + +- [x] Update `index.ts` - Add support for case subscriptions +- [x] Implement logic to load case subscriptions from session +- [x] Update `en.ts` - Add case subscription table content +- [x] Update `cy.ts` - Add Welsh translations for case content +- [x] Update `index.njk` - Add new table for case subscriptions +- [x] Add case subscription table with 3 columns: "Case Name", "Reference number", "Actions" +- [x] Add "Remove" link in Actions column for each case subscription +- [x] Keep existing court/tribunal subscription table +- [x] Keep existing "Confirm subscription" button +- [ ] Update `index.test.ts` - Add tests for case subscription functionality + +### Page 8: Subscription Confirmed (EXISTING - Verify) +**Path:** `/subscription-confirmed` (already exists at `libs/verified-pages/src/pages/subscription-confirmed/`) + +- [x] Verify existing page works for case subscriptions +- [x] Updated to handle confirmed case subscriptions from session + +### Styles +- [ ] Update `libs/verified-pages/src/assets/css/verified-pages.scss` or create separate file +- [ ] Style subscription tables +- [ ] Style tabs + +## Phase 5: Translations + +**Note:** Translations are handled in separate `en.ts` and `cy.ts` files within each page folder (already covered in Phase 4). Each page folder structure includes these translation files. + +## Phase 6: Subscription Fulfilment + +### Subscription Queries +- [x] Update `libs/notifications/src/notification/subscription-queries.ts` +- [x] Add `findActiveSubscriptionsByCaseNumbers(caseNumbers: string[])` +- [x] Query subscriptions where `searchType = 'CASE_NUMBER'` and `searchValue` in caseNumbers +- [x] Return `SubscriptionWithUser[]` matching existing interface +- [x] Add unit tests for new query function + +### Notification Service +- [x] Update `libs/notifications/src/notification/notification-service.ts` +- [x] Update `sendPublicationNotifications()` to handle both location and case subscriptions +- [x] Query artefact search table to get case numbers for the artefact +- [x] Query case subscriptions using `findActiveSubscriptionsByCaseNumbers()` +- [x] Combine location subscriptions and case subscriptions +- [x] Deduplicate subscriptions by userId +- [x] Process all subscriptions using existing `processUserNotification()` +- [x] Add unit tests for case subscription fulfilment logic +- [ ] Add integration tests for fulfilment flow + +## Phase 7: Access Control + +- [x] Verified existing auth middleware (requireAuth and blockUserAccess) +- [x] Confirmed all subscription pages use requireAuth() for authentication +- [x] Confirmed all subscription pages use blockUserAccess() to prevent SSO admin access +- [x] All new pages (subscription-add, case-name-search, case-number-search, case-name-search-results, case-number-search-results) have proper middleware +- [x] All updated pages (subscription-management, pending-subscriptions, subscription-confirmed) retain proper middleware +- [x] Unit tests for auth middleware exist and are comprehensive (authenticate.test.ts and authorise.test.ts) + +## Phase 8: Module Registration + +**Note:** The `verified-pages` module is already registered. Only verify that: +- [x] Pages are auto-discovered by the existing module registration in `apps/web/src/app.ts` +- [x] Assets are already configured in `apps/web/vite.config.ts` +- [x] No additional registration needed - `@hmcts/subscription` config exports already configured in schema-discovery.ts + +## Phase 9: Testing + +### Unit Tests +- [ ] Test case search service logic +- [ ] Test subscription management service +- [ ] Test repository methods +- [ ] Test form validation logic +- [ ] Test access control middleware +- [ ] Ensure >80% coverage on business logic + +### Integration Tests +- [ ] Test case search by name +- [ ] Test case search by reference +- [ ] Test subscription creation +- [ ] Test subscription fulfilment with case matches +- [ ] Test permission checks with different user roles +- [ ] Test session handling + +### E2E Tests +- [x] Test complete subscription by case name journey +- [x] Test complete subscription by reference journey +- [x] Test multiple search results selection journey +- [x] Test no results found error journey +- [x] Test view subscriptions by case journey +- [x] Test Welsh translation works across all pages +- [x] Test accessibility with Axe (inline with journeys) +- [x] Test keyboard navigation + +## Phase 10: Documentation & Cleanup + +- [ ] Update README if needed +- [ ] Run `yarn lint:fix` to fix any linting issues +- [ ] Run `yarn format` to format code +- [ ] Review all changes +- [ ] Create pull request + +## Notes + +- **Critical**: Verify VIBE-316 is complete before starting - this ticket depends on artefact_search table +- Store minimal data in session (case selection, search results) +- Clear session data after subscription created +- If single search result, skip results page and go directly to confirmation +- Index artefact_search columns for performance (case_number, case_name) +- Limit search results to prevent performance issues (e.g., max 50) +- All pages must support Welsh language +- Use `search_type = 'CASE_NUMBER'` for all case-based subscriptions diff --git a/docs/tickets/VIBE-300/ticket.md b/docs/tickets/VIBE-300/ticket.md new file mode 100644 index 000000000..4a70301ad --- /dev/null +++ b/docs/tickets/VIBE-300/ticket.md @@ -0,0 +1,37 @@ +# VIBE-300: Subscribe by case name, case reference number, case ID or unique reference number (URN) + +**Status:** In Progress +**Branch:** feature/VIBE-300 +**Labels:** subscription, case-search, user-journey + +## Description + +Verified media users need the ability to subscribe to hearing lists by case-specific identifiers (case name, case reference number, case ID, or URN), not just by location. This enables users to receive email notifications for specific cases they are monitoring. + +## User Story + +**As a** verified media user +**I want** to subscribe to hearing lists in CaTH +**So that** I can receive email notifications whenever a list I subscribed to is published. + +## Pre-conditions + +- User has valid credentials and is approved as a verified media user +- Only published information is available for searching +- Email notifications are implemented in Gov Notify +- `artefact_search` table exists (from VIBE-316) with case data +- Subscription table has `search_type` and `search_value` columns (from VIBE-316) + +## Dependencies + +- **VIBE-316**: Case search infrastructure must be implemented first + +## Attachments + +- subscription process phase 2.docx + +## Related Documentation + +- [Specification](./specification.md) - Detailed user journey and technical requirements +- [Implementation Plan](./plan.md) - Phased implementation approach +- [Tasks](./tasks.md) - Detailed task breakdown diff --git a/docs/tickets/VIBE-316/plan.md b/docs/tickets/VIBE-316/plan.md new file mode 100644 index 000000000..a4727c807 --- /dev/null +++ b/docs/tickets/VIBE-316/plan.md @@ -0,0 +1,280 @@ +# VIBE-316: Implementation Plan + +## Overview + +This ticket refactors the subscription system to support multiple search types (not just location). The implementation involves database schema changes, a new admin configuration page, publication processing updates, and data migration. + +## Critical Files + +### New Files to Create + +1. **Database Schema** + - `libs/postgres/prisma/schema.prisma` - Add new tables and modify subscription table + - `libs/postgres/prisma/migrations/` - Migration files for schema changes + +2. **Admin Configuration Module** + - `libs/list-search-config/package.json` - Module configuration + - `libs/list-search-config/tsconfig.json` - TypeScript config + - `libs/list-search-config/src/config.ts` - Module exports + - `libs/list-search-config/src/index.ts` - Business logic exports + - `libs/list-search-config/src/pages/list-search-config.ts` - Page controller + - `libs/list-search-config/src/pages/list-search-config.njk` - Template + - `libs/list-search-config/src/locales/en.ts` - English translations + - `libs/list-search-config/src/locales/cy.ts` - Welsh translations + - `libs/list-search-config/src/list-search-config-service.ts` - Service layer + - `libs/list-search-config/src/list-search-config-repository.ts` - Data access + +3. **Publication Processing** + - `libs/publication/src/artefact-search-extractor.ts` - Extract case data from JSON + - `libs/publication/src/artefact-search-repository.ts` - Data access for artefact_search + +4. **Subscription Updates** + - `libs/subscription/src/subscription-repository.ts` - Update queries to use search_type/search_value + - `libs/subscription/src/subscription-service.ts` - Update business logic + +### Files to Modify + +1. **Database** + - `libs/postgres/prisma/schema.prisma` - Update subscription model, add new models + +2. **Publication Processing** + - `libs/publication/src/publication-service.ts` - Add artefact search extraction + - `libs/publication/src/routes/publication-api.ts` - Integrate extraction + +3. **Subscription Fulfilment** + - `libs/subscription/src/subscription-repository.ts` - Update location queries + - `libs/subscription/src/subscription-service.ts` - Update location logic + +4. **App Registration** + - `apps/web/src/app.ts` - Register list-search-config module + - `apps/web/vite.config.ts` - Register module assets + - `apps/postgres/src/schema-discovery.ts` - Register prisma schema + +5. **Root Configuration** + - `tsconfig.json` - Add @hmcts/list-search-config path + +## Implementation Steps + +### Phase 1: Database Schema (Priority: Critical) + +1. **Create Prisma models** in `libs/postgres/prisma/schema.prisma` + ```prisma + model ListSearchConfig { + id String @id @default(cuid()) + listTypeId String @map("list_type_id") + caseNumberFieldName String @map("case_number_field_name") @db.VarChar(100) + caseNameFieldName String @map("case_name_field_name") @db.VarChar(100) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("list_search_config") + } + + model ArtefactSearch { + id String @id @default(cuid()) + artefactId String @map("artefact_id") + caseNumber String? @map("case_number") + caseName String? @map("case_name") + createdAt DateTime @default(now()) @map("created_at") + + @@map("artefact_search") + } + ``` + +2. **Update Subscription model** + - Add `searchType String @map("search_type") @db.VarChar(50)` + - Add `searchValue String @map("search_value")` + - Keep `locationId` temporarily for migration + +3. **Generate migration** + ```bash + yarn db:migrate:dev + ``` + +4. **Create data migration script** `libs/postgres/scripts/migrate-subscriptions.ts` + - Copy location_id → search_value + - Set search_type = 'LOCATION_ID' + - Validate migration + - Remove location_id column in subsequent migration + +### Phase 2: List Search Config Module (Priority: High) + +1. **Create module structure** + ```bash + mkdir -p libs/list-search-config/src/{pages,locales} + ``` + +2. **Implement repository** `list-search-config-repository.ts` + - `findByListTypeId(listTypeId: string)` + - `create(data: ListSearchConfigData)` + - `update(id: string, data: ListSearchConfigData)` + +3. **Implement service** `list-search-config-service.ts` + - `getConfigForListType(listTypeId: string)` + - `saveConfig(listTypeId: string, caseNumberField: string, caseNameField: string)` + - Validation logic for field names (pattern: `^[a-zA-Z0-9_]+$`) + +4. **Create page controller** `pages/list-search-config.ts` + - GET handler: Load existing config or render empty form + - POST handler: Validate input, save config, redirect + - Content objects (en/cy) + +5. **Create template** `pages/list-search-config.njk` + - Form with govukInput components + - Error summary + - Validation error messages + +6. **Create translations** `locales/en.ts` and `locales/cy.ts` + - Common button text + - Error messages + - Help text + +7. **Register module** in apps/web/src/app.ts + +### Phase 3: Artefact Search Extraction (Priority: High) + +1. **Create extractor** `libs/publication/src/artefact-search-extractor.ts` + - `extractCaseData(listTypeId: string, jsonPayload: unknown, artefactId: string)` + - Look up config from list_search_config + - Extract fields from JSON using configured field names + - Handle missing/null values gracefully + - Return extracted data or null + +2. **Create repository** `libs/publication/src/artefact-search-repository.ts` + - `create(artefactId: string, caseNumber: string | null, caseName: string | null)` + +3. **Integrate into publication service** `libs/publication/src/publication-service.ts` + - After artefact is created, call extractor + - Store extracted data in artefact_search table + - Log but don't fail if extraction fails + +4. **Update API route** `libs/publication/src/routes/publication-api.ts` + - Ensure extraction is called for both manual upload and API publication + +### Phase 4: Subscription Updates (Priority: High) + +1. **Update repository** `libs/subscription/src/subscription-repository.ts` + - Change location queries to use: + ```typescript + where: { + searchType: 'LOCATION_ID', + searchValue: locationId + } + ``` + +2. **Update service** `libs/subscription/src/subscription-service.ts` + - Update location subscription creation to use search_type/search_value + - Update fulfilment queries + +3. **Create subscription when user subscribes to location** + - Set searchType = 'LOCATION_ID' + - Set searchValue = location ID + +### Phase 5: Data Migration (Priority: Critical) + +1. **Run migration script** to migrate existing subscriptions + ```bash + yarn db:migrate + node libs/postgres/scripts/migrate-subscriptions.js + ``` + +2. **Verify migration** + - All subscriptions have search_type set + - All subscriptions have search_value set + - Location-based fulfilment still works + +3. **Remove location_id column** (after verification) + - Create final migration to drop column + - Run `yarn db:migrate:dev` + +### Phase 6: Testing (Priority: High) + +1. **Unit tests** + - ListSearchConfigService validation + - ArtefactSearchExtractor logic + - Repository methods + +2. **Integration tests** + - Publication processing with extraction + - Subscription fulfilment with new schema + - Admin config page save/load + +3. **E2E tests** + - Admin configures list search fields + - Publication is processed and case data extracted + - Location subscription fulfilment works + +### Phase 7: Integration & Registration (Priority: Medium) + +1. **Register module** in apps/web/src/app.ts +2. **Update root tsconfig.json** with module path +3. **Test end-to-end** workflow + +## Technical Considerations + +### Database Migration Strategy + +- Keep `location_id` column during migration to avoid data loss +- Migrate data in transaction +- Verify migration success before dropping column +- Plan rollback strategy + +### Error Handling + +- Publication processing should not fail if: + - List type has no search config + - JSON fields are missing + - Extraction fails +- Log extraction failures for monitoring + +### Performance + +- Index `artefact_search.artefact_id` for lookups +- Index `subscription(search_type, search_value)` for fulfilment queries + +### Backwards Compatibility + +- Existing location subscriptions must continue working +- Fulfilment logic must work with new schema +- API contracts should not change + +## Testing Strategy + +### Unit Tests +- List search config validation +- JSON extraction logic +- Subscription repository queries + +### Integration Tests +- Database migrations +- Publication processing flow +- Subscription fulfilment flow + +### E2E Tests +- Admin configuration journey +- Publication upload with extraction +- Location subscription notification + +## Risks & Mitigations + +| Risk | Impact | Mitigation | +|------|--------|-----------| +| Data migration fails | High | Keep location_id column, use transactions, test thoroughly | +| Extraction breaks publication processing | High | Handle extraction errors gracefully, don't fail publication | +| Existing subscriptions stop working | High | Extensive testing of location-based fulfilment with new schema | +| Config page inaccessible | Medium | Accessibility testing with screen readers | + +## Success Criteria + +1. ✅ Database schema updated with new tables and subscription changes +2. ✅ Admin can configure list search fields for any list type +3. ✅ Publications extract case data and store in artefact_search table +4. ✅ Existing location subscriptions migrated and working +5. ✅ Location-based fulfilment sends notifications correctly +6. ✅ No references to location_id in subscription code +7. ✅ All tests passing +8. ✅ Accessibility standards met + +## Estimated Complexity: Medium-High + +This ticket involves database schema changes, data migration, new module creation, and updates to existing publication/subscription logic. The complexity is medium-high due to the need for careful migration planning and ensuring backwards compatibility. diff --git a/docs/tickets/VIBE-316/specification.md b/docs/tickets/VIBE-316/specification.md new file mode 100644 index 000000000..0868cb429 --- /dev/null +++ b/docs/tickets/VIBE-316/specification.md @@ -0,0 +1,135 @@ +# VIBE-316: Refactor artefact search extraction and subscription process + +## Problem Statement + +Currently, verified users can only subscribe to a location. The system needs to support multiple subscription search types (not just location) so that users can subscribe to publications using different identifiers such as case number or case name in the future. + +## User Story + +**As a** system admin +**I want** the system to support multiple subscription search types (not just location) +**So that** users can subscribe to publications using different identifiers such as case number or case name in the future. + +## Technical Requirements + +### Database Changes + +1. **New Table: list_search_config** + - Stores JSON field name mappings for each list type + - Fields: + - `id` (PK) + - `list_type_id` (FK to list type) + - `case_number_field_name` (varchar 100) + - `case_name_field_name` (varchar 100) + - `created_at` (timestamp) + - `updated_at` (timestamp) + +2. **New Table: artefact_search** + - Stores extracted case information from publications + - Fields: + - `id` (PK) + - `artefact_id` (FK to artefact) + - `case_number` (text, nullable) + - `case_name` (text, nullable) + - `created_at` (timestamp) + +3. **Subscription Table Migration** + - Add columns: + - `search_type` (varchar 50) + - `search_value` (text) + - Remove column: + - `location_id` + - Migrate existing location subscriptions: + - Set `search_type = 'LOCATION_ID'` + - Set `search_value` to location ID value + +### Admin Configuration Page + +**Page: List Search Configuration** + +- **URL Pattern**: `/system-admin/list-configuration/:listTypeId/search-config` +- **Access**: System admin only + +**Form Fields:** +- Case number JSON field name + - Type: text + - Required: Yes + - Max length: 100 characters + - Pattern: `^[a-zA-Z0-9_]+$` (letters, numbers, underscores only) +- Case name JSON field name + - Type: text + - Required: Yes + - Max length: 100 characters + - Pattern: `^[a-zA-Z0-9_]+$` (letters, numbers, underscores only) + +**Content:** +- EN: Title "Configure list search fields" +- EN: Body "Enter the JSON field names used to extract case details for this list type." +- EN: Button "Save configuration" +- CY: Welsh translations required + +**Validation Errors:** +- EN: "Enter the case number JSON field name" +- EN: "Enter the case name JSON field name" +- EN: "Case number field name must contain only letters, numbers and underscores" +- EN: "Case name field name must contain only letters, numbers and underscores" +- CY: Welsh translations required + +**Back Navigation:** +- Returns to System Admin list configuration section + +### Publication Processing + +When a publication is received (via manual upload or `/api/publication`): + +1. Look up configuration in `list_search_config` table using list type +2. Extract case information from JSON using configured field names +3. Store extracted values in `artefact_search` table +4. Handle gracefully if: + - Configuration doesn't exist for list type + - JSON fields are missing or null + - Extraction fails + +### Subscription Fulfilment + +For location-based subscriptions (existing functionality): + +1. Query subscriptions table where: + - `search_type = 'LOCATION_ID'` + - `search_value = ` +2. Send notification emails using existing functionality + +### Data Migration + +Create migration script to: +1. Add new columns to subscription table +2. Migrate existing data: copy `location_id` → `search_value` and set `search_type = 'LOCATION_ID'` +3. Drop `location_id` column after successful migration +4. Create new tables: `list_search_config` and `artefact_search` + +## Accessibility Requirements + +- WCAG 2.2 AA standards +- Form fields with associated labels +- Accessible error messages +- Screen reader announcements for errors +- Full keyboard navigation support + +## Test Scenarios + +1. Saving list search configuration with valid JSON field names succeeds +2. Submitting configuration form with missing fields displays validation errors +3. Submitting configuration with invalid characters displays validation errors +4. Publications with configured JSON fields correctly populate `artefact_search` +5. Publications without matching configuration do not cause system failure +6. Publications with missing JSON fields are handled gracefully +7. Existing location subscriptions are migrated to use `search_type = LOCATION_ID` +8. Location-based fulfilment continues to send notification emails correctly +9. No references to `location_id` remain in subscription table after migration +10. Future subscription types can be added without further schema changes + +## Out of Scope + +- Implementation of case number/case name subscription functionality +- User-facing subscription pages for new search types +- Search functionality using case data diff --git a/docs/tickets/VIBE-316/tasks.md b/docs/tickets/VIBE-316/tasks.md new file mode 100644 index 000000000..5aca9b454 --- /dev/null +++ b/docs/tickets/VIBE-316/tasks.md @@ -0,0 +1,133 @@ +# VIBE-316: Implementation Tasks + +## Phase 1: Database Schema ⚠️ CRITICAL - DO FIRST + +- [ ] Update `libs/postgres/prisma/schema.prisma` to add ListSearchConfig model +- [ ] Update `libs/postgres/prisma/schema.prisma` to add ArtefactSearch model +- [ ] Update Subscription model to add searchType and searchValue fields +- [ ] Generate Prisma migration: `yarn db:migrate:dev` +- [ ] Create data migration script at `libs/postgres/scripts/migrate-subscriptions.ts` +- [ ] Test migration script locally +- [ ] Run migration to migrate existing location subscriptions + +## Phase 2: List Search Config Module + +### Module Setup +- [ ] Create directory structure: `libs/list-search-config/src/{pages,locales}` +- [ ] Create `libs/list-search-config/package.json` +- [ ] Create `libs/list-search-config/tsconfig.json` +- [ ] Create `libs/list-search-config/src/config.ts` +- [ ] Create `libs/list-search-config/src/index.ts` + +### Repository Layer +- [ ] Create `libs/list-search-config/src/list-search-config-repository.ts` +- [ ] Implement `findByListTypeId(listTypeId: string)` +- [ ] Implement `create(data: ListSearchConfigData)` +- [ ] Implement `update(id: string, data: ListSearchConfigData)` +- [ ] Add unit tests for repository + +### Service Layer +- [ ] Create `libs/list-search-config/src/list-search-config-service.ts` +- [ ] Implement `getConfigForListType(listTypeId: string)` +- [ ] Implement `saveConfig(listTypeId, caseNumberField, caseNameField)` +- [ ] Add validation for field name pattern: `^[a-zA-Z0-9_]+$` +- [ ] Add unit tests for service + +### Pages +- [ ] Create `libs/list-search-config/src/pages/list-search-config.ts` controller +- [ ] Implement GET handler to load existing config +- [ ] Implement POST handler with validation +- [ ] Add content objects (en and cy) +- [ ] Create `libs/list-search-config/src/pages/list-search-config.njk` template +- [ ] Add form with govukInput components +- [ ] Add error summary component +- [ ] Add validation error messages + +### Translations +- [ ] Create `libs/list-search-config/src/locales/en.ts` +- [ ] Create `libs/list-search-config/src/locales/cy.ts` +- [ ] Add button text, error messages, help text + +### Module Registration +- [ ] Register module in `apps/web/src/app.ts` +- [ ] Register assets in `apps/web/vite.config.ts` +- [ ] Register Prisma schema in `apps/postgres/src/schema-discovery.ts` +- [ ] Update root `tsconfig.json` with module path + +## Phase 3: Artefact Search Extraction + +### Extractor Implementation +- [ ] Create `libs/publication/src/artefact-search-extractor.ts` +- [ ] Implement `extractCaseData(listTypeId, jsonPayload, artefactId)` +- [ ] Add logic to look up config from list_search_config table +- [ ] Add logic to extract fields from JSON using configured field names +- [ ] Add graceful handling for missing/null values +- [ ] Add unit tests for extractor + +### Repository +- [ ] Create `libs/publication/src/artefact-search-repository.ts` +- [ ] Implement `create(artefactId, caseNumber, caseName)` +- [ ] Add unit tests for repository + +### Integration +- [ ] Update `libs/publication/src/publication-service.ts` to call extractor +- [ ] Store extracted data in artefact_search table +- [ ] Add error logging (don't fail publication on extraction error) +- [ ] Update `libs/publication/src/routes/publication-api.ts` to integrate extraction +- [ ] Add integration tests + +## Phase 4: Subscription Updates + +### Repository Updates +- [ ] Update `libs/subscription/src/subscription-repository.ts` +- [ ] Change location queries to use searchType/searchValue +- [ ] Update all location-based queries +- [ ] Add unit tests for updated queries + +### Service Updates +- [ ] Update `libs/subscription/src/subscription-service.ts` +- [ ] Update location subscription creation to use search_type/search_value +- [ ] Update fulfilment queries +- [ ] Add unit tests for updated service + +## Phase 5: Final Migration + +- [ ] Verify all location subscriptions migrated correctly +- [ ] Verify location-based fulfilment works with new schema +- [ ] Create migration to drop location_id column +- [ ] Run `yarn db:migrate:dev` to drop column + +## Phase 6: Testing + +### Unit Tests +- [ ] Test list search config validation +- [ ] Test artefact search extraction logic +- [ ] Test repository methods +- [ ] Ensure >80% coverage on business logic + +### Integration Tests +- [ ] Test publication processing with extraction +- [ ] Test subscription fulfilment with new schema +- [ ] Test admin config page save/load + +### E2E Tests +- [ ] Test admin configures list search fields journey +- [ ] Test publication is processed and case data extracted +- [ ] Test location subscription fulfilment works +- [ ] Include Welsh translation checks +- [ ] Include accessibility checks with Axe + +## Phase 7: Documentation & Cleanup + +- [ ] Update README if needed +- [ ] Run `yarn lint:fix` to fix any linting issues +- [ ] Run `yarn format` to format code +- [ ] Review all changes +- [ ] Create pull request + +## Notes + +- **Critical**: Do Phase 1 first - database schema changes are required for everything else +- Keep `location_id` column during migration to avoid data loss +- Test migration thoroughly before dropping the column +- Ensure existing location subscriptions continue working throughout diff --git a/e2e-tests/tests/bulk-unsubscribe.spec.ts b/e2e-tests/tests/bulk-unsubscribe.spec.ts index 7dc5429d4..b06657687 100644 --- a/e2e-tests/tests/bulk-unsubscribe.spec.ts +++ b/e2e-tests/tests/bulk-unsubscribe.spec.ts @@ -94,8 +94,13 @@ async function deleteTestData(testData: TestData): Promise { try { await prisma.subscription.deleteMany({ where: { - locationId: { - in: [testData.locationId1, testData.locationId2, testData.locationId3], + searchType: "LOCATION_ID", + searchValue: { + in: [ + testData.locationId1.toString(), + testData.locationId2.toString(), + testData.locationId3.toString() + ], }, }, }); @@ -114,6 +119,14 @@ async function deleteTestData(testData: TestData): Promise { test.describe("Bulk Unsubscribe", () => { test.beforeEach(async ({ page }, testInfo) => { + // Validate required environment variables + if (!process.env.CFT_VALID_TEST_ACCOUNT || !process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD) { + throw new Error( + 'Missing required environment variables: CFT_VALID_TEST_ACCOUNT and CFT_VALID_TEST_ACCOUNT_PASSWORD. ' + + 'Please run E2E tests using: node e2e-tests/run-with-credentials.js test bulk-unsubscribe.spec.ts' + ); + } + const testData = await createTestData(); testDataMap.set(testInfo.testId, testData); @@ -125,8 +138,8 @@ test.describe("Bulk Unsubscribe", () => { await loginWithCftIdam( page, - process.env.CFT_VALID_TEST_ACCOUNT!, - process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD! + process.env.CFT_VALID_TEST_ACCOUNT, + process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD ); await expect(page).toHaveURL(/\/account-home/); @@ -153,6 +166,10 @@ test.describe("Bulk Unsubscribe", () => { // Add first subscription await page.getByRole("button", { name: /add email subscription/i }).click(); await page.waitForLoadState("networkidle"); + // Select court or tribunal subscription method + await page.getByRole("radio", { name: /court or tribunal/i }).check(); + await page.getByRole("button", { name: /continue/i }).click(); + await page.waitForLoadState("networkidle"); const location1Checkbox = page.locator(`#location-${testData.locationId1}`); await location1Checkbox.check(); await page.locator("form[method='post']").getByRole("button", { name: /continue/i }).click(); @@ -163,6 +180,10 @@ test.describe("Bulk Unsubscribe", () => { // Add second subscription await page.getByRole("button", { name: /add email subscription/i }).click(); await page.waitForLoadState("networkidle"); + // Select court or tribunal subscription method + await page.getByRole("radio", { name: /court or tribunal/i }).check(); + await page.getByRole("button", { name: /continue/i }).click(); + await page.waitForLoadState("networkidle"); const location2Checkbox = page.locator(`#location-${testData.locationId2}`); await location2Checkbox.check(); await page.locator("form[method='post']").getByRole("button", { name: /continue/i }).click(); @@ -173,6 +194,10 @@ test.describe("Bulk Unsubscribe", () => { // Add third subscription await page.getByRole("button", { name: /add email subscription/i }).click(); await page.waitForLoadState("networkidle"); + // Select court or tribunal subscription method + await page.getByRole("radio", { name: /court or tribunal/i }).check(); + await page.getByRole("button", { name: /continue/i }).click(); + await page.waitForLoadState("networkidle"); const location3Checkbox = page.locator(`#location-${testData.locationId3}`); await location3Checkbox.check(); await page.locator("form[method='post']").getByRole("button", { name: /continue/i }).click(); @@ -196,38 +221,30 @@ test.describe("Bulk Unsubscribe", () => { .analyze(); expect(accessibilityScanResults.violations).toEqual([]); - // STEP 4: Test "All subscriptions" tab displays both tables - const allTab = page.getByRole("tab", { name: /all subscriptions/i }); - const caseTab = page.getByRole("tab", { name: /subscriptions by case/i }); - const courtTab = page.getByRole("tab", { name: /subscriptions by court or tribunal/i }); - - await expect(allTab).toBeVisible(); - await expect(caseTab).toBeVisible(); - await expect(courtTab).toBeVisible(); - - // All subscriptions tab should be active by default - await expect(allTab).toHaveAttribute("aria-selected", "true"); - - // Verify subscriptions are displayed in the active tab panel - const activeTabPanel = page.locator('[role="tabpanel"]:visible'); - await expect(activeTabPanel.getByRole('cell', { name: testData.locationName1 }).first()).toBeVisible(); - await expect(activeTabPanel.getByRole('cell', { name: testData.locationName2 }).first()).toBeVisible(); - await expect(activeTabPanel.getByRole('cell', { name: testData.locationName3 }).first()).toBeVisible(); - - // STEP 5: Test keyboard navigation for tabs - await allTab.focus(); - await page.keyboard.press("ArrowRight"); - await expect(caseTab).toBeFocused(); - await page.keyboard.press("ArrowRight"); - await expect(courtTab).toBeFocused(); - - // STEP 6: Test "Subscriptions by court or tribunal" tab - await courtTab.click(); - await expect(courtTab).toHaveAttribute("aria-selected", "true"); - const courtTabPanel = page.locator('[role="tabpanel"]:visible'); - await expect(courtTabPanel.getByRole('cell', { name: testData.locationName1 }).first()).toBeVisible(); - await expect(courtTabPanel.getByRole('cell', { name: testData.locationName2 }).first()).toBeVisible(); - await expect(courtTabPanel.getByRole('cell', { name: testData.locationName3 }).first()).toBeVisible(); + // STEP 4: Test "All subscriptions" view displays both tables + const allLink = page.getByRole("link", { name: /all subscriptions/i }); + const caseLink = page.getByRole("link", { name: /subscriptions by case/i }); + const courtLink = page.getByRole("link", { name: /subscriptions by court or tribunal/i }); + + await expect(allLink).toBeVisible(); + await expect(caseLink).toBeVisible(); + await expect(courtLink).toBeVisible(); + + // Verify both tables are displayed (all subscriptions view shows both) + await expect(page.getByRole('cell', { name: testData.locationName1 }).first()).toBeVisible(); + await expect(page.getByRole('cell', { name: testData.locationName2 }).first()).toBeVisible(); + await expect(page.getByRole('cell', { name: testData.locationName3 }).first()).toBeVisible(); + + // STEP 5: Test "Subscriptions by court or tribunal" view + await courtLink.click(); + await expect(page).toHaveURL(/view=location/); + await expect(page.getByRole('cell', { name: testData.locationName1 }).first()).toBeVisible(); + await expect(page.getByRole('cell', { name: testData.locationName2 }).first()).toBeVisible(); + await expect(page.getByRole('cell', { name: testData.locationName3 }).first()).toBeVisible(); + + // STEP 6: Navigate back to all subscriptions + await allLink.click(); + await expect(page).toHaveURL(/view=all/); // STEP 7: Test Welsh translation await page.goto("/bulk-unsubscribe?lng=cy"); @@ -246,11 +263,11 @@ test.describe("Bulk Unsubscribe", () => { await expect(errorSummary).toBeVisible(); await expect(page.getByText(/at least one subscription must be selected/i)).toBeVisible(); - // STEP 9: Test select-all functionality - const selectAllCheckbox = page.getByRole('checkbox', { name: /select all/i }).first(); - await selectAllCheckbox.check(); + // STEP 9: Test select-all functionality for court subscriptions + const selectAllCourtCheckbox = page.getByRole('checkbox', { name: /select all court subscriptions/i }); + await selectAllCourtCheckbox.check(); - // Verify all individual checkboxes are checked + // Verify all court subscription checkboxes are checked const checkbox1 = page.getByRole('checkbox', { name: new RegExp(`Select ${testData.locationName1}`) }).first(); const checkbox2 = page.getByRole('checkbox', { name: new RegExp(`Select ${testData.locationName2}`) }).first(); const checkbox3 = page.getByRole('checkbox', { name: new RegExp(`Select ${testData.locationName3}`) }).first(); @@ -260,7 +277,7 @@ test.describe("Bulk Unsubscribe", () => { await expect(checkbox3).toBeChecked(); // Uncheck select-all - await selectAllCheckbox.uncheck(); + await selectAllCourtCheckbox.uncheck(); await expect(checkbox1).not.toBeChecked(); await expect(checkbox2).not.toBeChecked(); await expect(checkbox3).not.toBeChecked(); @@ -269,11 +286,11 @@ test.describe("Bulk Unsubscribe", () => { await checkbox1.check(); await checkbox2.check(); - // STEP 11: Test tab switching - // Note: Checkbox selections are not currently synchronized across tabs - // Each tab maintains independent checkbox state - await courtTab.click(); - await allTab.click(); + // STEP 11: Test view switching + // Note: Checkbox selections are not currently synchronized across views + // Each view maintains independent checkbox state + await courtLink.click(); + await allLink.click(); // STEP 12: Navigate back to subscription management await page.goto("/subscription-management"); @@ -361,8 +378,9 @@ test.describe("Bulk Unsubscribe", () => { // STEP 21: Verify subscriptions were actually deleted from database const remainingSubscriptions = await prisma.subscription.findMany({ where: { - locationId: { - in: [testData.locationId1, testData.locationId2], + searchType: "LOCATION_ID", + searchValue: { + in: [testData.locationId1.toString(), testData.locationId2.toString()], }, }, }); @@ -372,7 +390,8 @@ test.describe("Bulk Unsubscribe", () => { // STEP 22: Verify non-deleted subscription still exists const subscription3 = await prisma.subscription.findFirst({ where: { - locationId: testData.locationId3, + searchType: "LOCATION_ID", + searchValue: testData.locationId3.toString(), }, }); diff --git a/e2e-tests/tests/case-subscriptions.spec.ts b/e2e-tests/tests/case-subscriptions.spec.ts new file mode 100644 index 000000000..80d6be492 --- /dev/null +++ b/e2e-tests/tests/case-subscriptions.spec.ts @@ -0,0 +1,490 @@ +import { expect, test } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; +import { loginWithCftIdam } from "../utils/cft-idam-helpers.js"; +import { prisma } from "@hmcts/postgres"; + +// Store test data per test to avoid parallel test conflicts +interface TestCaseData { + caseName: string; + caseNumber: string; + artefactId: string; +} + +const testDataMap = new Map(); + +async function createTestCaseData(): Promise { + const timestamp = Date.now(); + const randomId = Math.floor(Math.random() * 1000000); + const testCaseName = `Test Case ${timestamp}-${randomId}`; + const testCaseNumber = `TC-${timestamp}-${randomId}`; + const testArtefactId = `test-artefact-${timestamp}-${randomId}`; + + // Create a test artefact in artefact_search table + // This assumes VIBE-316 is complete and artefact_search table exists + await prisma.artefactSearch.create({ + data: { + artefactId: testArtefactId, + caseName: testCaseName, + caseNumber: testCaseNumber, + locationId: 1 // Use first location from seed data + } + }); + + return { + caseName: testCaseName, + caseNumber: testCaseNumber, + artefactId: testArtefactId + }; +} + +async function deleteTestCaseData(caseData: TestCaseData): Promise { + try { + // Delete subscriptions first + await prisma.subscription.deleteMany({ + where: { + OR: [{ caseName: caseData.caseName }, { caseNumber: caseData.caseNumber }] + } + }); + + // Delete artefact search data + await prisma.artefactSearch.deleteMany({ + where: { artefactId: caseData.artefactId } + }); + } catch (error) { + console.log("Test case data cleanup:", error); + } +} + +test.describe("Case Subscriptions", () => { + test.beforeEach(async ({ page }, testInfo) => { + // Create test case data and store in map + const caseData = await createTestCaseData(); + testDataMap.set(testInfo.testId, caseData); + + // Navigate to sign-in page + await page.goto("/sign-in"); + + // Select HMCTS account option + const hmctsRadio = page.getByRole("radio", { name: /with a myhmcts account/i }); + await hmctsRadio.check(); + + // Click continue + const continueButton = page.getByRole("button", { name: /continue/i }); + await continueButton.click(); + + // Perform CFT IDAM login + await loginWithCftIdam(page, process.env.CFT_VALID_TEST_ACCOUNT!, process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD!); + + // Should be redirected to account-home after successful login + await expect(page).toHaveURL(/\/account-home/); + }); + + test.afterEach(async ({}, testInfo) => { + const caseData = testDataMap.get(testInfo.testId); + if (caseData) { + await deleteTestCaseData(caseData); + testDataMap.delete(testInfo.testId); + } + }); + + test.describe("Subscribe by Case Name Journey", () => { + test("should complete subscription by case name with validation, Welsh, and accessibility @nightly", async ({ page }, testInfo) => { + const caseData = testDataMap.get(testInfo.testId); + if (!caseData) throw new Error("Test case data not found"); + + // Step 1: Navigate to subscription management + await page.goto("/account-home"); + const emailSubsTile = page.locator(".verified-tile").nth(2); + await emailSubsTile.click(); + await expect(page).toHaveURL("/subscription-management"); + + // Verify subscription management page + await expect(page.getByRole("heading", { name: /your email subscriptions/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /add email subscription/i })).toBeVisible(); + + // Test accessibility on subscription management page + let accessibilityScanResults = await new AxeBuilder({ page }).disableRules(["region"]).analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // Step 2: Navigate to subscription method selection + await page.getByRole("button", { name: /add email subscription/i }).click(); + await expect(page).toHaveURL("/subscription-add"); + + // Verify subscription method selection page + await expect(page.getByRole("heading", { name: /how do you want to subscribe/i })).toBeVisible(); + + // Test validation - continue without selection + await page.getByRole("button", { name: /continue/i }).click(); + await expect(page.getByText(/select how you want to subscribe/i)).toBeVisible(); + + // Test Welsh translation + await page.getByRole("link", { name: "Cymraeg" }).click(); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await page.getByRole("link", { name: "English" }).click(); + + // Test accessibility on subscription method page + accessibilityScanResults = await new AxeBuilder({ page }).disableRules(["region"]).analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // Select case name option + const caseNameRadio = page.getByRole("radio", { name: /by case name/i }); + await caseNameRadio.check(); + await page.getByRole("button", { name: /continue/i }).click(); + + // Step 3: Case name search page + await expect(page).toHaveURL("/case-name-search"); + await expect(page.getByRole("heading", { name: /subscribe by case name/i })).toBeVisible(); + + // Test validation - submit without entering case name + await page.getByRole("button", { name: /search/i }).click(); + await expect(page.getByText(/enter a case name/i)).toBeVisible(); + + // Test keyboard navigation + await page.getByLabel(/case name/i).fill(""); + await page.keyboard.press("Tab"); + await page.keyboard.press("Enter"); + await expect(page.getByText(/enter a case name/i)).toBeVisible(); + + // Test accessibility on case name search page + accessibilityScanResults = await new AxeBuilder({ page }).disableRules(["region"]).analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // Enter valid case name and search + await page.getByLabel(/case name/i).fill(caseData.caseName); + await page.getByRole("button", { name: /search/i }).click(); + + // Step 4: Search results page + await expect(page).toHaveURL("/case-name-search-results"); + await expect(page.getByRole("heading", { name: /subscription case search results/i })).toBeVisible(); + + // Verify results table with case name and reference number + await expect(page.getByText(caseData.caseName)).toBeVisible(); + await expect(page.getByText(caseData.caseNumber)).toBeVisible(); + + // Test validation - continue without selecting a case + await page.getByRole("button", { name: /continue/i }).click(); + await expect(page.getByText(/select at least one case/i)).toBeVisible(); + + // Test Welsh on results page + await page.getByRole("link", { name: "Cymraeg" }).click(); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await page.getByRole("link", { name: "English" }).click(); + + // Test accessibility on search results page + accessibilityScanResults = await new AxeBuilder({ page }).disableRules(["region"]).analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // Select the case + const caseCheckbox = page.getByRole("checkbox").first(); + await caseCheckbox.check(); + await expect(caseCheckbox).toBeChecked(); + await page.getByRole("button", { name: /continue/i }).click(); + + // Step 5: Pending subscriptions page + await expect(page).toHaveURL("/pending-subscriptions"); + await expect(page.locator("h1")).toBeVisible(); + + // Verify case subscription is shown in table + await expect(page.getByText(caseData.caseName)).toBeVisible(); + await expect(page.getByText(caseData.caseNumber)).toBeVisible(); + + // Test accessibility on pending subscriptions page + accessibilityScanResults = await new AxeBuilder({ page }).disableRules(["region"]).analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // Confirm subscription + const confirmButton = page.getByRole("button", { name: /confirm/i }); + await confirmButton.click(); + + // Step 6: Confirmation page + await expect(page).toHaveURL("/subscription-confirmed", { timeout: 10000 }); + const panel = page.locator(".govuk-panel--confirmation"); + await expect(panel).toBeVisible(); + + // Test accessibility on confirmation page + accessibilityScanResults = await new AxeBuilder({ page }).disableRules(["region"]).analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // Navigate back to subscription management + const manageLink = page.getByRole("link", { name: /manage.*subscriptions/i }); + await manageLink.click(); + await expect(page).toHaveURL("/subscription-management"); + + // Verify subscription appears in the list + await expect(page.getByText(caseData.caseName)).toBeVisible(); + }); + }); + + test.describe("Subscribe by Case Reference Journey", () => { + test("should complete subscription by case reference with validation, Welsh, and accessibility @nightly", async ({ page }, testInfo) => { + const caseData = testDataMap.get(testInfo.testId); + if (!caseData) throw new Error("Test case data not found"); + + // Step 1: Navigate to subscription management + await page.goto("/subscription-management"); + await expect(page.getByRole("heading", { name: /your email subscriptions/i })).toBeVisible(); + + // Step 2: Navigate to subscription method selection + await page.getByRole("button", { name: /add email subscription/i }).click(); + await expect(page).toHaveURL("/subscription-add"); + + // Test accessibility + let accessibilityScanResults = await new AxeBuilder({ page }).disableRules(["region"]).analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // Select case reference option + const caseRefRadio = page.getByRole("radio", { name: /by case reference/i }); + await caseRefRadio.check(); + await page.getByRole("button", { name: /continue/i }).click(); + + // Step 3: Case reference search page + await expect(page).toHaveURL("/case-number-search"); + await expect(page.getByRole("heading", { name: /subscribe by case reference/i })).toBeVisible(); + + // Test validation - submit without entering reference + await page.getByRole("button", { name: /search/i }).click(); + await expect(page.getByText(/enter a case reference/i)).toBeVisible(); + + // Test Welsh translation + await page.getByRole("link", { name: "Cymraeg" }).click(); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await page.getByRole("link", { name: "English" }).click(); + + // Test accessibility + accessibilityScanResults = await new AxeBuilder({ page }).disableRules(["region"]).analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // Enter valid case reference and search + await page.getByLabel(/case reference/i).fill(caseData.caseNumber); + await page.getByRole("button", { name: /search/i }).click(); + + // Step 4: Search results page + await expect(page).toHaveURL("/case-number-search-results"); + await expect(page.getByRole("heading", { name: /subscription case search results/i })).toBeVisible(); + + // Verify results + await expect(page.getByText(caseData.caseName)).toBeVisible(); + await expect(page.getByText(caseData.caseNumber)).toBeVisible(); + + // Test accessibility + accessibilityScanResults = await new AxeBuilder({ page }).disableRules(["region"]).analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // Select the case + const caseCheckbox = page.getByRole("checkbox").first(); + await caseCheckbox.check(); + await page.getByRole("button", { name: /continue/i }).click(); + + // Step 5: Pending subscriptions and confirmation + await expect(page).toHaveURL("/pending-subscriptions"); + await expect(page.getByText(caseData.caseName)).toBeVisible(); + + const confirmButton = page.getByRole("button", { name: /confirm/i }); + await confirmButton.click(); + + await expect(page).toHaveURL("/subscription-confirmed", { timeout: 10000 }); + const panel = page.locator(".govuk-panel--confirmation"); + await expect(panel).toBeVisible(); + }); + }); + + test.describe("Multiple Search Results Journey", () => { + test("should handle multiple search results selection @nightly", async ({ page }, testInfo) => { + // Create additional test cases + const caseData1 = testDataMap.get(testInfo.testId); + if (!caseData1) throw new Error("Test case data not found"); + + const timestamp = Date.now(); + const caseData2 = { + caseName: `Test Case ${timestamp}-2`, + caseNumber: `TC-${timestamp}-2`, + artefactId: `test-artefact-${timestamp}-2` + }; + + // Create second test case with similar name for multiple results + await prisma.artefactSearch.create({ + data: { + artefactId: caseData2.artefactId, + caseName: caseData2.caseName, + caseNumber: caseData2.caseNumber, + locationId: 1 + } + }); + + try { + // Navigate to case name search + await page.goto("/subscription-management"); + await page.getByRole("button", { name: /add email subscription/i }).click(); + const caseNameRadio = page.getByRole("radio", { name: /by case name/i }); + await caseNameRadio.check(); + await page.getByRole("button", { name: /continue/i }).click(); + + // Search with partial name that returns multiple results + await page.getByLabel(/case name/i).fill("Test Case"); + await page.getByRole("button", { name: /search/i }).click(); + + // Verify multiple results displayed + await expect(page).toHaveURL("/case-name-search-results"); + const checkboxes = page.getByRole("checkbox"); + await expect(checkboxes).not.toHaveCount(0); + + // Test accessibility + const accessibilityScanResults = await new AxeBuilder({ page }).disableRules(["region"]).analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // Select multiple cases + await checkboxes.first().check(); + await checkboxes.nth(1).check(); + + // Continue to pending subscriptions + await page.getByRole("button", { name: /continue/i }).click(); + await expect(page).toHaveURL("/pending-subscriptions"); + + // Verify both cases are listed + const pendingTable = page.locator("table"); + await expect(pendingTable).toBeVisible(); + + // Test removing one subscription from pending + const removeButtons = page.getByRole("button", { name: /remove/i }); + await removeButtons.first().click(); + + // Confirm remaining subscription + await page.getByRole("button", { name: /confirm/i }).click(); + await expect(page).toHaveURL("/subscription-confirmed", { timeout: 10000 }); + } finally { + // Clean up second test case + await prisma.subscription.deleteMany({ + where: { caseName: caseData2.caseName } + }); + await prisma.artefactSearch.deleteMany({ + where: { artefactId: caseData2.artefactId } + }); + } + }); + }); + + test.describe("No Results Found Journey", () => { + test("should handle no search results with validation and accessibility @nightly", async ({ page }) => { + // Navigate to case name search + await page.goto("/subscription-management"); + await page.getByRole("button", { name: /add email subscription/i }).click(); + const caseNameRadio = page.getByRole("radio", { name: /by case name/i }); + await caseNameRadio.check(); + await page.getByRole("button", { name: /continue/i }).click(); + + // Search for non-existent case + await page.getByLabel(/case name/i).fill("NonExistentCase123456789"); + await page.getByRole("button", { name: /search/i }).click(); + + // Should stay on search page with error message + await expect(page).toHaveURL("/case-name-search"); + await expect(page.getByText(/no results found/i)).toBeVisible(); + + // Test Welsh translation of error + await page.getByRole("link", { name: "Cymraeg" }).click(); + await expect(page.getByText(/dim canlyniadau/i)).toBeVisible(); + await page.getByRole("link", { name: "English" }).click(); + + // Test accessibility with error message + const accessibilityScanResults = await new AxeBuilder({ page }).disableRules(["region"]).analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // Test same for case reference search + await page.goto("/subscription-management"); + await page.getByRole("button", { name: /add email subscription/i }).click(); + const caseRefRadio = page.getByRole("radio", { name: /by case reference/i }); + await caseRefRadio.check(); + await page.getByRole("button", { name: /continue/i }).click(); + + await page.getByLabel(/case reference/i).fill("NOEXIST-999"); + await page.getByRole("button", { name: /search/i }).click(); + + await expect(page).toHaveURL("/case-number-search"); + await expect(page.getByText(/no results found/i)).toBeVisible(); + }); + }); + + test.describe("View Subscriptions by Case Journey", () => { + test("should view and manage case subscriptions with tabs and accessibility @nightly", async ({ page }, testInfo) => { + const caseData = testDataMap.get(testInfo.testId); + if (!caseData) throw new Error("Test case data not found"); + + // First create a case subscription + await page.goto("/subscription-management"); + await page.getByRole("button", { name: /add email subscription/i }).click(); + const caseNameRadio = page.getByRole("radio", { name: /by case name/i }); + await caseNameRadio.check(); + await page.getByRole("button", { name: /continue/i }).click(); + await page.getByLabel(/case name/i).fill(caseData.caseName); + await page.getByRole("button", { name: /search/i }).click(); + await page.getByRole("checkbox").first().check(); + await page.getByRole("button", { name: /continue/i }).click(); + await page.getByRole("button", { name: /confirm/i }).click(); + await expect(page).toHaveURL("/subscription-confirmed", { timeout: 10000 }); + + // Navigate back to subscription management + const manageLink = page.getByRole("link", { name: /manage.*subscriptions/i }); + await manageLink.click(); + await expect(page).toHaveURL("/subscription-management"); + + // Verify tabs are present (All, By case, By court/tribunal) + await expect(page.getByRole("link", { name: /all/i })).toBeVisible(); + await expect(page.getByRole("link", { name: /by case/i })).toBeVisible(); + await expect(page.getByRole("link", { name: /by court.*tribunal/i })).toBeVisible(); + + // Test Welsh translation of tabs + await page.getByRole("link", { name: "Cymraeg" }).click(); + await expect(page.getByRole("link", { name: /pob/i })).toBeVisible(); + await page.getByRole("link", { name: "English" }).click(); + + // Test accessibility with tabs + let accessibilityScanResults = await new AxeBuilder({ page }).disableRules(["region"]).analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // Click "By case" tab + await page.getByRole("link", { name: /by case/i }).click(); + await expect(page).toHaveURL(/view=case/); + + // Verify case subscription is displayed + await expect(page.getByText(caseData.caseName)).toBeVisible(); + await expect(page.getByText(caseData.caseNumber)).toBeVisible(); + + // Test accessibility on case view + accessibilityScanResults = await new AxeBuilder({ page }).disableRules(["region"]).analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // Test removing case subscription + const removeButton = page.getByRole("button", { + name: `Remove subscription for ${caseData.caseName}` + }); + await removeButton.click(); + await expect(page).toHaveURL(/\/delete-subscription/); + + // Confirm removal + await page.getByRole("radio", { name: /yes/i }).check(); + await page.getByRole("button", { name: /continue/i }).click(); + await expect(page).toHaveURL("/unsubscribe-confirmation"); + + // Navigate back to subscription management + await page.getByRole("link", { name: /manage.*subscriptions/i }).click(); + + // Verify case subscription is removed + await page.getByRole("link", { name: /by case/i }).click(); + await expect(page.getByText(caseData.caseName)).not.toBeVisible(); + }); + }); + + test.describe("Authentication Protection", () => { + test("should require authentication for case subscription pages @nightly", async ({ page, context }) => { + await context.clearCookies(); + + // Test all case subscription pages redirect to sign-in when not authenticated + const protectedPages = ["/subscription-add", "/case-name-search", "/case-name-search-results", "/case-number-search", "/case-number-search-results"]; + + for (const url of protectedPages) { + await page.goto(url); + await expect(page).toHaveURL(/\/sign-in/); + } + }); + }); +}); diff --git a/e2e-tests/tests/delete-court.spec.ts b/e2e-tests/tests/delete-court.spec.ts index 1cde4f990..51d7d570f 100644 --- a/e2e-tests/tests/delete-court.spec.ts +++ b/e2e-tests/tests/delete-court.spec.ts @@ -266,7 +266,8 @@ test.describe("Delete Court Journey", () => { await prisma.subscription.create({ data: { userId: testUser.userId, - locationId: courtLocationId + searchType: "LOCATION_ID", + searchValue: courtLocationId.toString() } }); diff --git a/e2e-tests/tests/email-subscriptions.spec.ts b/e2e-tests/tests/email-subscriptions.spec.ts index 54c46d57d..d570f3047 100644 --- a/e2e-tests/tests/email-subscriptions.spec.ts +++ b/e2e-tests/tests/email-subscriptions.spec.ts @@ -86,8 +86,12 @@ async function deleteTestLocation(locationData: TestLocationData): Promise if (!locationData.locationId) return; // Delete subscriptions first (if any) + // Subscriptions are linked to locations via searchType and searchValue await prisma.subscription.deleteMany({ - where: { locationId: locationData.locationId }, + where: { + searchType: "LOCATION_ID", + searchValue: locationData.locationId.toString(), + }, }); // Delete location (cascade will handle relationships) @@ -103,6 +107,14 @@ async function deleteTestLocation(locationData: TestLocationData): Promise test.describe("Email Subscriptions", () => { // Create test location and authenticate before each test test.beforeEach(async ({ page }, testInfo) => { + // Validate required environment variables + if (!process.env.CFT_VALID_TEST_ACCOUNT || !process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD) { + throw new Error( + 'Missing required environment variables: CFT_VALID_TEST_ACCOUNT and CFT_VALID_TEST_ACCOUNT_PASSWORD. ' + + 'Please run E2E tests using: node e2e-tests/run-with-credentials.js test email-subscriptions.spec.ts' + ); + } + // Create test location and store in map const locationData = await createTestLocation(); testLocationMap.set(testInfo.testId, locationData); @@ -121,8 +133,8 @@ test.describe("Email Subscriptions", () => { // Perform CFT IDAM login await loginWithCftIdam( page, - process.env.CFT_VALID_TEST_ACCOUNT!, - process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD! + process.env.CFT_VALID_TEST_ACCOUNT, + process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD ); // Should be redirected to account-home after successful login @@ -164,8 +176,13 @@ test.describe("Email Subscriptions", () => { .analyze(); expect(accessibilityScanResults.violations).toEqual([]); - // Step 2: Navigate to location search + // Step 2: Navigate to subscription method selection await page.getByRole("button", { name: /add email subscription/i }).click(); + await expect(page).toHaveURL("/subscription-add"); + + // Select court or tribunal subscription method + await page.getByRole("radio", { name: /court or tribunal/i }).check(); + await page.getByRole("button", { name: /continue/i }).click(); await expect(page).toHaveURL("/location-name-search"); // Verify location search page @@ -185,10 +202,17 @@ test.describe("Email Subscriptions", () => { // Test back navigation from location search await page.locator(".govuk-back-link").click(); + await expect(page).toHaveURL("/subscription-add"); + + // Test back navigation from subscription method selection + await page.locator(".govuk-back-link").click(); await expect(page).toHaveURL("/subscription-management"); // Navigate back to location search await page.getByRole("button", { name: /add email subscription/i }).click(); + await expect(page).toHaveURL("/subscription-add"); + await page.getByRole("radio", { name: /court or tribunal/i }).check(); + await page.getByRole("button", { name: /continue/i }).click(); await expect(page).toHaveURL("/location-name-search"); // Step 3: Select the test location and continue @@ -257,6 +281,12 @@ test.describe("Email Subscriptions", () => { await emailSubsTile.click(); await page.getByRole("button", { name: /add email subscription/i }).click(); await page.waitForLoadState("networkidle"); + + // Select court or tribunal subscription method + await page.getByRole("radio", { name: /court or tribunal/i }).check(); + await page.getByRole("button", { name: /continue/i }).click(); + await page.waitForLoadState("networkidle"); + const testLocationCheckbox = page.locator(`#location-${locationData.locationId}`); await testLocationCheckbox.check(); await page.locator("form[method='post']").getByRole("button", { name: /continue/i }).click(); diff --git a/e2e-tests/tests/manage-list-types.spec.ts b/e2e-tests/tests/manage-list-types.spec.ts new file mode 100644 index 000000000..4fea15279 --- /dev/null +++ b/e2e-tests/tests/manage-list-types.spec.ts @@ -0,0 +1,414 @@ +import AxeBuilder from "@axe-core/playwright"; +import type { Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; +import { loginWithSSO } from "../utils/sso-helpers.js"; + +// Helper function to authenticate as System Admin +async function authenticateSystemAdmin(page: Page) { + await page.goto("/system-admin-dashboard"); + + if (page.url().includes("login.microsoftonline.com")) { + const systemAdminEmail = process.env.SSO_TEST_SYSTEM_ADMIN_EMAIL!; + const systemAdminPassword = process.env.SSO_TEST_SYSTEM_ADMIN_PASSWORD!; + await loginWithSSO(page, systemAdminEmail, systemAdminPassword); + } +} + +test.describe("Manage List Types End-to-End Flow", () => { + test.beforeEach(async ({ page }) => { + await authenticateSystemAdmin(page); + }); + + test.describe("Complete End-to-End Journey", () => { + test("should complete full list type configuration flow from list to success", async ({ page }) => { + // Step 1: Navigate to manage list types page + await page.goto("/manage-list-types"); + await expect(page.locator("h1")).toHaveText("Manage list types"); + + // Step 2: Verify table is displayed with list types + const table = page.locator(".govuk-table"); + await expect(table).toBeVisible(); + + // Step 3: Verify table headers + const headers = page.locator(".govuk-table__header"); + await expect(headers).toHaveCount(2); + await expect(headers.nth(0)).toHaveText("Name"); + + // Step 4: Verify at least one list type is shown + const rows = page.locator(".govuk-table__body .govuk-table__row"); + await expect(rows.first()).toBeVisible(); + + // Step 5: Click "Manage" link for first list type + const firstManageLink = page.locator(".govuk-table__body .govuk-link").first(); + await expect(firstManageLink).toHaveText("Manage"); + await firstManageLink.click(); + await page.waitForURL(/\/list-search-config\/\d+/); + + // Step 6: Verify configuration form page loads + await expect(page.locator("h1")).toHaveText("Configure list type search fields"); + await expect(page.getByText("Enter the JSON field names")).toBeVisible(); + + // Step 7: Fill in the configuration form + await page.fill('input[name="caseNumberFieldName"]', "caseNumber"); + await page.fill('input[name="caseNameFieldName"]', "caseName"); + + // Step 8: Submit the form + await page.getByRole("button", { name: /confirm/i }).click(); + + // Step 9: Verify success page + await page.waitForURL("/list-search-config-success", { timeout: 10000 }); + await expect(page.locator("h1")).toHaveText("List type search configuration updated"); + + // Step 10: Verify return link + const returnLink = page.getByRole("link", { name: /manage list types/i }); + await expect(returnLink).toBeVisible(); + await expect(returnLink).toHaveAttribute("href", "/manage-list-types"); + }); + + test("should support updating existing configuration", async ({ page }) => { + await page.goto("/manage-list-types"); + + // Navigate to first list type configuration + await page.locator(".govuk-table__body .govuk-link").first().click(); + await page.waitForURL(/\/list-search-config\/\d+/); + + // Verify existing values can be updated + const caseNumberInput = page.locator('input[name="caseNumberFieldName"]'); + const caseNameInput = page.locator('input[name="caseNameFieldName"]'); + + // Clear and set new values + await caseNumberInput.fill(""); + await caseNumberInput.fill("updatedCaseNumber"); + await caseNameInput.fill(""); + await caseNameInput.fill("updatedCaseName"); + + // Submit + await page.getByRole("button", { name: /confirm/i }).click(); + await page.waitForURL("/list-search-config-success"); + await expect(page.locator("h1")).toHaveText("List type search configuration updated"); + }); + }); + + test.describe("Manage List Types Page", () => { + test("should display all list types in alphabetical order", async ({ page }) => { + await page.goto("/manage-list-types"); + + const rows = page.locator(".govuk-table__body .govuk-table__row"); + const count = await rows.count(); + expect(count).toBeGreaterThan(0); + + // Verify all rows have name and manage link + for (let i = 0; i < count; i++) { + const nameCell = rows.nth(i).locator(".govuk-table__cell").first(); + const manageLink = rows.nth(i).locator(".govuk-link"); + + await expect(nameCell).toHaveText(/.+/); // Has some text + await expect(manageLink).toHaveText("Manage"); + await expect(manageLink).toHaveAttribute("href", /\/list-search-config\/\d+/); + } + }); + + test("should meet WCAG 2.2 AA standards", async ({ page }) => { + await page.goto("/manage-list-types"); + + const accessibilityScanResults = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]) + .analyze(); + + expect(accessibilityScanResults.violations).toEqual([]); + }); + + test("should display Welsh content correctly", async ({ page }) => { + await page.goto("/manage-list-types?lng=cy"); + await page.waitForLoadState("networkidle"); + + await expect(page.locator("h1")).toHaveText("Rheoli mathau rhestr"); + await expect(page.locator(".govuk-table__header").first()).toHaveText("Enw"); + await expect(page.locator(".govuk-table__body .govuk-link").first()).toHaveText("Rheoli"); + }); + }); + + test.describe("Configure List Type Form Page", () => { + test("should load form with all required fields", async ({ page }) => { + // Use a known list type ID + await page.goto("/list-search-config/1"); + await page.waitForLoadState("networkidle"); + + await expect(page.locator("h1")).toHaveText("Configure list type search fields"); + await expect(page.getByText("Enter the JSON field names")).toBeVisible(); + + // Verify form fields + const caseNumberInput = page.locator('input[name="caseNumberFieldName"]'); + const caseNameInput = page.locator('input[name="caseNameFieldName"]'); + const confirmButton = page.getByRole("button", { name: /confirm/i }); + + await expect(caseNumberInput).toBeVisible(); + await expect(caseNameInput).toBeVisible(); + await expect(confirmButton).toBeVisible(); + }); + + test("should validate field name format", async ({ page }) => { + await page.goto("/list-search-config/1"); + await page.waitForLoadState("networkidle"); + + // Enter invalid field names (with special characters) + await page.fill('input[name="caseNumberFieldName"]', "case-number!"); + await page.fill('input[name="caseNameFieldName"]', "case@name#"); + + await page.getByRole("button", { name: /confirm/i }).click(); + + // Verify error summary appears + const errorSummary = page.locator(".govuk-error-summary"); + await expect(errorSummary).toBeVisible(); + await expect(errorSummary.locator(".govuk-error-summary__title")).toHaveText("There is a problem"); + + // Verify field-level errors + const errorMessages = page.locator(".govuk-error-message"); + expect(await errorMessages.count()).toBeGreaterThan(0); + }); + + test("should allow both fields to be blank", async ({ page }) => { + await page.goto("/list-search-config/1"); + await page.waitForLoadState("networkidle"); + + // Leave both fields blank + await page.fill('input[name="caseNumberFieldName"]', ""); + await page.fill('input[name="caseNameFieldName"]', ""); + + await page.getByRole("button", { name: /confirm/i }).click(); + + // Should successfully save with blank values + await page.waitForURL("/list-search-config-success", { timeout: 10000 }); + await expect(page.locator("h1")).toHaveText("List type search configuration updated"); + }); + + test("should allow only one field to be populated", async ({ page }) => { + await page.goto("/list-search-config/1"); + await page.waitForLoadState("networkidle"); + + // Only fill case number field + await page.fill('input[name="caseNumberFieldName"]', "caseNumber"); + await page.fill('input[name="caseNameFieldName"]', ""); + + await page.getByRole("button", { name: /confirm/i }).click(); + + // Should successfully save + await page.waitForURL("/list-search-config-success"); + await expect(page.locator("h1")).toHaveText("List type search configuration updated"); + }); + + test("should accept valid field names with underscores and numbers", async ({ page }) => { + await page.goto("/list-search-config/1"); + await page.waitForLoadState("networkidle"); + + await page.fill('input[name="caseNumberFieldName"]', "case_number_123"); + await page.fill('input[name="caseNameFieldName"]', "case_name_456"); + + await page.getByRole("button", { name: /confirm/i }).click(); + + await page.waitForURL("/list-search-config-success"); + await expect(page.locator("h1")).toHaveText("List type search configuration updated"); + }); + + test("should preserve form data when validation fails", async ({ page }) => { + await page.goto("/list-search-config/1"); + await page.waitForLoadState("networkidle"); + + const invalidValue1 = "invalid!field"; + const invalidValue2 = "another@invalid"; + + await page.fill('input[name="caseNumberFieldName"]', invalidValue1); + await page.fill('input[name="caseNameFieldName"]', invalidValue2); + + await page.getByRole("button", { name: /confirm/i }).click(); + + // Verify form still shows the entered values + await expect(page.locator('input[name="caseNumberFieldName"]')).toHaveValue(invalidValue1); + await expect(page.locator('input[name="caseNameFieldName"]')).toHaveValue(invalidValue2); + }); + + test("should meet WCAG 2.2 AA standards on form page", async ({ page }) => { + await page.goto("/list-search-config/1"); + await page.waitForLoadState("networkidle"); + + const accessibilityScanResults = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]) + .analyze(); + + expect(accessibilityScanResults.violations).toEqual([]); + }); + + test("should meet WCAG 2.2 AA standards on error page", async ({ page }) => { + await page.goto("/list-search-config/1"); + await page.waitForLoadState("networkidle"); + + // Trigger validation errors + await page.fill('input[name="caseNumberFieldName"]', "invalid!"); + await page.getByRole("button", { name: /confirm/i }).click(); + + const accessibilityScanResults = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]) + .analyze(); + + expect(accessibilityScanResults.violations).toEqual([]); + }); + + test("should display Welsh content on form page", async ({ page }) => { + await page.goto("/list-search-config/1?lng=cy"); + await page.waitForLoadState("networkidle"); + + await expect(page.locator("h1")).toHaveText("Ffurfweddu meysydd chwilio math rhestr"); + await expect(page.getByText("Rhowch yr enwau meysydd JSON")).toBeVisible(); + + const caseNumberLabel = page.locator('label[for="caseNumberFieldName"]'); + const caseNameLabel = page.locator('label[for="caseNameFieldName"]'); + + await expect(caseNumberLabel).toHaveText("Enw maes JSON rhif achos"); + await expect(caseNameLabel).toHaveText("Enw maes JSON enw achos"); + + await expect(page.getByRole("button", { name: /cadarnhau/i })).toBeVisible(); + }); + + test("should handle invalid list type ID", async ({ page }) => { + const response = await page.goto("/list-search-config/invalid"); + + expect(response?.status()).toBe(400); + }); + }); + + test.describe("Success Page", () => { + test("should display success confirmation", async ({ page }) => { + await page.goto("/manage-list-types"); + await page.waitForLoadState("networkidle"); + + await page.locator(".govuk-table__body .govuk-link").first().click(); + await page.waitForURL(/\/list-search-config\/\d+/); + + await page.fill('input[name="caseNumberFieldName"]', "testField"); + await page.getByRole("button", { name: /confirm/i }).click(); + + await page.waitForURL("/list-search-config-success"); + + await expect(page.locator("h1")).toHaveText("List type search configuration updated"); + await expect(page.getByText("What do you want to do next?")).toBeVisible(); + + const returnLink = page.getByRole("link", { name: /manage list types/i }); + await expect(returnLink).toBeVisible(); + }); + + test("should navigate back to manage list types from success page", async ({ page }) => { + await page.goto("/manage-list-types"); + await page.waitForLoadState("networkidle"); + + await page.locator(".govuk-table__body .govuk-link").first().click(); + await page.waitForURL(/\/list-search-config\/\d+/); + + await page.fill('input[name="caseNumberFieldName"]', "testField"); + await page.getByRole("button", { name: /confirm/i }).click(); + + await page.waitForURL("/list-search-config-success"); + + await page.getByRole("link", { name: /manage list types/i }).click(); + await page.waitForURL("/manage-list-types"); + + await expect(page.locator("h1")).toHaveText("Manage list types"); + }); + + test("should display Welsh content on success page", async ({ page }) => { + // Navigate directly to the Welsh form page + await page.goto("/list-search-config/1?lng=cy"); + await page.waitForLoadState("networkidle"); + + await page.fill('input[name="caseNumberFieldName"]', "testField"); + await page.getByRole("button", { name: /cadarnhau/i }).click(); + + await page.waitForURL(/\/list-search-config-success\?lng=cy/); + + await expect(page.locator("h1")).toContainText("Ffurfweddiad chwilio math rhestr wedi'i ddiweddaru"); + await expect(page.getByText("Beth hoffech chi ei wneud nesaf?")).toBeVisible(); + }); + + test("should meet WCAG 2.2 AA standards on success page", async ({ page }) => { + await page.goto("/manage-list-types"); + await page.waitForLoadState("networkidle"); + + await page.locator(".govuk-table__body .govuk-link").first().click(); + await page.waitForURL(/\/list-search-config\/\d+/); + + await page.fill('input[name="caseNumberFieldName"]', "testField"); + await page.getByRole("button", { name: /confirm/i }).click(); + + await page.waitForURL("/list-search-config-success"); + + const accessibilityScanResults = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"]) + .analyze(); + + expect(accessibilityScanResults.violations).toEqual([]); + }); + }); + + test.describe("Keyboard Navigation", () => { + test("should be keyboard accessible throughout the flow", async ({ page }) => { + await page.goto("/list-search-config/1"); + await page.waitForLoadState("networkidle"); + + // Verify tab navigation works on form + const caseNumberInput = page.locator('input[name="caseNumberFieldName"]'); + const caseNameInput = page.locator('input[name="caseNameFieldName"]'); + const confirmButton = page.getByRole("button", { name: /confirm/i }); + + await caseNumberInput.focus(); + await expect(caseNumberInput).toBeFocused(); + + await page.keyboard.press("Tab"); + await expect(caseNameInput).toBeFocused(); + + await page.keyboard.press("Tab"); + await expect(confirmButton).toBeFocused(); + + // Verify form can be submitted with keyboard + await caseNumberInput.fill("testCase"); + await caseNameInput.fill("testName"); + await confirmButton.focus(); + await page.keyboard.press("Enter"); + + await page.waitForURL("/list-search-config-success"); + await expect(page.locator("h1")).toHaveText("List type search configuration updated"); + }); + }); + + test.describe("Multiple List Types", () => { + test("should configure different list types independently @nightly", async ({ page }) => { + await page.goto("/manage-list-types"); + await page.waitForLoadState("networkidle"); + + const manageLinks = page.locator(".govuk-table__body .govuk-link"); + const linkCount = await manageLinks.count(); + + // Test at least 2 different list types if available + if (linkCount >= 2) { + // Configure first list type + await manageLinks.nth(0).click(); + await page.waitForURL(/\/list-search-config\/\d+/); + await page.fill('input[name="caseNumberFieldName"]', "firstType_caseNum"); + await page.fill('input[name="caseNameFieldName"]', "firstType_caseName"); + await page.getByRole("button", { name: /confirm/i }).click(); + await page.waitForURL("/list-search-config-success"); + + // Go back and configure second list type + await page.getByRole("link", { name: /manage list types/i }).click(); + await page.waitForURL("/manage-list-types"); + + await manageLinks.nth(1).click(); + await page.waitForURL(/\/list-search-config\/\d+/); + await page.fill('input[name="caseNumberFieldName"]', "secondType_caseNum"); + await page.fill('input[name="caseNameFieldName"]', "secondType_caseName"); + await page.getByRole("button", { name: /confirm/i }).click(); + await page.waitForURL("/list-search-config-success"); + + await expect(page.locator("h1")).toHaveText("List type search configuration updated"); + } + }); + }); +}); diff --git a/e2e-tests/tests/system-admin-dashboard.spec.ts b/e2e-tests/tests/system-admin-dashboard.spec.ts index 18c25f8cf..75e28e499 100644 --- a/e2e-tests/tests/system-admin-dashboard.spec.ts +++ b/e2e-tests/tests/system-admin-dashboard.spec.ts @@ -21,9 +21,9 @@ test.describe("System Admin Dashboard", () => { await expect(heading).toHaveClass(/govuk-heading-l/); }); - test("should display all 8 admin tiles", async ({ page }) => { + test("should display all 9 admin tiles", async ({ page }) => { const tiles = page.locator(".admin-tile"); - await expect(tiles).toHaveCount(8); + await expect(tiles).toHaveCount(9); }); test("should display correct tile titles and links", async ({ page }) => { @@ -35,7 +35,8 @@ test.describe("System Admin Dashboard", () => { { title: "Blob Explorer", href: "/blob-explorer-locations" }, { title: "Bulk Create Media Accounts", href: "/bulk-media-accounts" }, { title: "Audit Log Viewer", href: "/audit-log-viewer" }, - { title: "Manage Location Metadata", href: "/location-metadata-search" } + { title: "Manage Location Metadata", href: "/location-metadata-search" }, + { title: "Manage List Types", href: "/manage-list-types" } ]; for (const { title, href } of tileData) { @@ -47,21 +48,22 @@ test.describe("System Admin Dashboard", () => { test("should display descriptions for all tiles", async ({ page }) => { const descriptions = page.locator(".admin-tile__description"); - await expect(descriptions).toHaveCount(8); + await expect(descriptions).toHaveCount(9); await expect(descriptions.nth(0)).toContainText("Upload CSV location reference data"); await expect(descriptions.nth(1)).toContainText("Delete court from reference data"); - await expect(descriptions.nth(2)).toContainText("View, create, update and remove third-party users"); + await expect(descriptions.nth(2)).toContainText("View, create, update and remove third-party users and subscriptions"); await expect(descriptions.nth(3)).toContainText("Search, update and delete users"); await expect(descriptions.nth(4)).toContainText("Discover content uploaded to all locations"); await expect(descriptions.nth(5)).toContainText("Upload a CSV file for bulk creation of media accounts"); await expect(descriptions.nth(6)).toContainText("View audit logs on system admin actions"); await expect(descriptions.nth(7)).toContainText("View, update and remove location metadata"); + await expect(descriptions.nth(8)).toContainText("View, create and update list type configuration"); }); test("should display tiles in 2-column grid", async ({ page }) => { const gridColumns = page.locator(".govuk-grid-column-one-half"); - await expect(gridColumns).toHaveCount(8); + await expect(gridColumns).toHaveCount(9); }); }); @@ -79,9 +81,9 @@ test.describe("System Admin Dashboard", () => { test("should have accessible links @nightly", async ({ page }) => { const links = page.locator("a.admin-tile"); - await expect(links).toHaveCount(8); + await expect(links).toHaveCount(9); - for (let i = 0; i < 8; i++) { + for (let i = 0; i < 9; i++) { await expect(links.nth(i)).toBeVisible(); } }); @@ -126,8 +128,8 @@ test.describe("System Admin Dashboard", () => { const tagName = await firstTile.evaluate((el) => el.tagName.toLowerCase()); expect(tagName).toBe("a"); - // Verify all 8 tiles are accessible links - await expect(tileLinks).toHaveCount(8); + // Verify all 9 tiles are accessible links + await expect(tileLinks).toHaveCount(9); }); }); @@ -140,7 +142,7 @@ test.describe("System Admin Dashboard", () => { await expect(heading).toBeVisible(); const tiles = page.locator(".admin-tile"); - await expect(tiles).toHaveCount(8); + await expect(tiles).toHaveCount(9); }); test("should display correctly on tablet viewport (768x1024) @nightly", async ({ page }) => { @@ -151,7 +153,7 @@ test.describe("System Admin Dashboard", () => { await expect(heading).toBeVisible(); const tiles = page.locator(".admin-tile"); - await expect(tiles).toHaveCount(8); + await expect(tiles).toHaveCount(9); }); test("should display correctly on desktop viewport (1920x1080) @nightly", async ({ page }) => { @@ -162,7 +164,7 @@ test.describe("System Admin Dashboard", () => { await expect(heading).toBeVisible(); const tiles = page.locator(".admin-tile"); - await expect(tiles).toHaveCount(8); + await expect(tiles).toHaveCount(9); }); }); diff --git a/e2e-tests/utils/notification-helpers.ts b/e2e-tests/utils/notification-helpers.ts index bc55f405e..d4e046cd3 100644 --- a/e2e-tests/utils/notification-helpers.ts +++ b/e2e-tests/utils/notification-helpers.ts @@ -41,7 +41,8 @@ export async function createTestSubscription(userId: string, locationId: number) data: { subscriptionId, userId, - locationId, + searchType: "LOCATION_ID", + searchValue: locationId.toString(), dateAdded: new Date() } }); diff --git a/libs/admin-pages/src/pages/manual-upload-summary/index.ts b/libs/admin-pages/src/pages/manual-upload-summary/index.ts index d209ea55e..021cc7416 100644 --- a/libs/admin-pages/src/pages/manual-upload-summary/index.ts +++ b/libs/admin-pages/src/pages/manual-upload-summary/index.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { requireRole, USER_ROLES } from "@hmcts/auth"; import { getLocationById } from "@hmcts/location"; import { sendPublicationNotifications } from "@hmcts/notifications"; -import { createArtefact, mockListTypes, Provenance } from "@hmcts/publication"; +import { createArtefact, extractAndStoreArtefactSearch, mockListTypes, Provenance } from "@hmcts/publication"; import { formatDate, formatDateRange, parseDate } from "@hmcts/web-core"; import type { Request, RequestHandler, Response } from "express"; import { saveUploadedFile } from "../../manual-upload/file-storage.js"; @@ -109,6 +109,19 @@ const postHandler = async (req: Request, res: Response) => { // Save file to temporary storage with artefactId as filename (will overwrite if exists) await saveUploadedFile(artefactId, uploadData.fileName, uploadData.file); + // Extract and store artefact search data for JSON files + if (!isFlatFile) { + try { + const jsonPayload = JSON.parse(uploadData.file.toString("utf-8")); + await extractAndStoreArtefactSearch(artefactId, listTypeId, jsonPayload); + } catch (error) { + console.error("[Manual Upload] Failed to extract artefact search data", { + artefactId, + error: error instanceof Error ? error.message : String(error) + }); + } + } + // Trigger email notifications for subscribers try { // Get location name diff --git a/libs/admin-pages/src/pages/non-strategic-upload-summary/index.ts b/libs/admin-pages/src/pages/non-strategic-upload-summary/index.ts index b833ad97e..3ad1ba907 100644 --- a/libs/admin-pages/src/pages/non-strategic-upload-summary/index.ts +++ b/libs/admin-pages/src/pages/non-strategic-upload-summary/index.ts @@ -6,7 +6,7 @@ import "@hmcts/london-administrative-court-daily-cause-list"; // Register London import "@hmcts/court-of-appeal-civil-daily-cause-list"; // Register civil appeal converter (19) import "@hmcts/administrative-court-daily-cause-list"; // Register admin court converters (20-23) import { getLocationById } from "@hmcts/location"; -import { createArtefact, mockListTypes, Provenance } from "@hmcts/publication"; +import { createArtefact, extractAndStoreArtefactSearch, mockListTypes, Provenance } from "@hmcts/publication"; import { formatDate, formatDateRange, parseDate } from "@hmcts/web-core"; import type { Request, RequestHandler, Response } from "express"; import { saveUploadedFile } from "../../manual-upload/file-storage.js"; @@ -125,6 +125,16 @@ const postHandler = async (req: Request, res: Response) => { if (hasConverterForListType(listTypeId)) { const hearingsData = await convertExcelForListType(listTypeId, uploadData.file); await saveUploadedFile(artefactId, `${artefactId}.json`, Buffer.from(JSON.stringify(hearingsData))); + + // Extract and store artefact search data from converted JSON + try { + await extractAndStoreArtefactSearch(artefactId, listTypeId, hearingsData); + } catch (error) { + console.error("[Non-Strategic Upload] Failed to extract artefact search data from converted Excel", { + artefactId, + error: error instanceof Error ? error.message : String(error) + }); + } } } diff --git a/libs/api/src/blob-ingestion/repository/service.test.ts b/libs/api/src/blob-ingestion/repository/service.test.ts index 6d5b34d57..0cacce057 100644 --- a/libs/api/src/blob-ingestion/repository/service.test.ts +++ b/libs/api/src/blob-ingestion/repository/service.test.ts @@ -5,6 +5,7 @@ import { processBlobIngestion } from "./service.js"; // Mock the dependencies vi.mock("@hmcts/publication", () => ({ createArtefact: vi.fn(), + extractAndStoreArtefactSearch: vi.fn(), Provenance: { MANUAL_UPLOAD: "MANUAL_UPLOAD", XHIBIT: "XHIBIT", @@ -40,7 +41,7 @@ vi.mock("@hmcts/notifications", () => ({ })); describe("processBlobIngestion", async () => { - const { createArtefact, mockListTypes } = await import("@hmcts/publication"); + const { createArtefact, extractAndStoreArtefactSearch, mockListTypes } = await import("@hmcts/publication"); const { createIngestionLog } = await import("./queries.js"); const { validateBlobRequest } = await import("../validation.js"); const { saveUploadedFile } = await import("../file-storage.js"); @@ -49,6 +50,7 @@ describe("processBlobIngestion", async () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(extractAndStoreArtefactSearch).mockResolvedValue(undefined); }); const validRequest: BlobIngestionRequest = { diff --git a/libs/api/src/blob-ingestion/repository/service.ts b/libs/api/src/blob-ingestion/repository/service.ts index 97ac56d3a..ec002f740 100644 --- a/libs/api/src/blob-ingestion/repository/service.ts +++ b/libs/api/src/blob-ingestion/repository/service.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { getLocationById } from "@hmcts/location"; import { sendPublicationNotifications } from "@hmcts/notifications"; -import { createArtefact, mockListTypes, Provenance } from "@hmcts/publication"; +import { createArtefact, extractAndStoreArtefactSearch, mockListTypes, Provenance } from "@hmcts/publication"; import { saveUploadedFile } from "../file-storage.js"; import { validateBlobRequest } from "../validation.js"; import type { BlobIngestionRequest, BlobIngestionResponse } from "./model.js"; @@ -83,6 +83,9 @@ export async function processBlobIngestion(request: BlobIngestionRequest, rawBod const jsonBuffer = Buffer.from(JSON.stringify(request.hearing_list)); await saveUploadedFile(artefactId, "upload.json", jsonBuffer); + // Extract and store artefact search data for case number/name search + await extractAndStoreArtefactSearch(artefactId, validation.listTypeId, request.hearing_list); + // Log successful ingestion await createIngestionLog({ id: randomUUID(), diff --git a/libs/list-search-config/package.json b/libs/list-search-config/package.json new file mode 100644 index 000000000..6cf509788 --- /dev/null +++ b/libs/list-search-config/package.json @@ -0,0 +1,27 @@ +{ + "name": "@hmcts/list-search-config", + "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", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "format": "biome format --write .", + "lint": "biome check .", + "lint:fix": "biome check --write ." + }, + "peerDependencies": { + "express": "^5.2.0" + } +} diff --git a/libs/list-search-config/prisma/schema.prisma b/libs/list-search-config/prisma/schema.prisma new file mode 100644 index 000000000..c8ae43eb8 --- /dev/null +++ b/libs/list-search-config/prisma/schema.prisma @@ -0,0 +1,21 @@ +generator client { + provider = "prisma-client-js" + output = "../../../node_modules/.prisma/client" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model ListSearchConfig { + id String @id @default(uuid()) + listTypeId Int @map("list_type_id") + caseNumberFieldName String @map("case_number_field_name") @db.VarChar(100) + caseNameFieldName String @map("case_name_field_name") @db.VarChar(100) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([listTypeId]) + @@map("list_search_config") +} diff --git a/libs/list-search-config/src/config.test.ts b/libs/list-search-config/src/config.test.ts new file mode 100644 index 000000000..0b2e64ae3 --- /dev/null +++ b/libs/list-search-config/src/config.test.ts @@ -0,0 +1,33 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import * as config from "./config.js"; + +describe("List Search Config Module Config", () => { + describe("prismaSchemas", () => { + it("should export prismaSchemas as a string", () => { + expect(typeof config.prismaSchemas).toBe("string"); + }); + + it("should point to the prisma directory", () => { + expect(config.prismaSchemas).toContain("prisma"); + }); + + it("should be an absolute path", () => { + expect(path.isAbsolute(config.prismaSchemas)).toBe(true); + }); + + it("should end with prisma directory", () => { + expect(config.prismaSchemas.endsWith("prisma")).toBe(true); + }); + + it("should be within the list-search-config module", () => { + expect(config.prismaSchemas).toContain("list-search-config"); + }); + + it("should exist as a resolvable path", () => { + // The path should be constructible from __dirname and ../prisma + const expectedPath = path.join(path.dirname(import.meta.url.replace("file://", "")), "../prisma"); + expect(path.normalize(config.prismaSchemas)).toBe(path.normalize(expectedPath)); + }); + }); +}); diff --git a/libs/list-search-config/src/config.ts b/libs/list-search-config/src/config.ts new file mode 100644 index 000000000..a573c4aee --- /dev/null +++ b/libs/list-search-config/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); + +// Module configuration for app registration +export const prismaSchemas = path.join(__dirname, "../prisma"); diff --git a/libs/list-search-config/src/index.ts b/libs/list-search-config/src/index.ts new file mode 100644 index 000000000..bfb18c1f9 --- /dev/null +++ b/libs/list-search-config/src/index.ts @@ -0,0 +1,4 @@ +// Business logic exports + +export * from "./repository/queries.js"; +export * from "./repository/service.js"; diff --git a/libs/list-search-config/src/repository/queries.test.ts b/libs/list-search-config/src/repository/queries.test.ts new file mode 100644 index 000000000..53c2c541d --- /dev/null +++ b/libs/list-search-config/src/repository/queries.test.ts @@ -0,0 +1,296 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create, findByListTypeId, update, upsert } from "./queries.js"; + +vi.mock("@hmcts/postgres", () => ({ + prisma: { + listSearchConfig: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + upsert: vi.fn() + } + } +})); + +describe("list-search-config queries", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("findByListTypeId", () => { + it("should find config by list type id", async () => { + const mockConfig = { + listTypeId: 1, + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }; + + const { prisma } = await import("@hmcts/postgres"); + vi.mocked(prisma.listSearchConfig.findUnique).mockResolvedValue(mockConfig as any); + + const result = await findByListTypeId(1); + + expect(result).toEqual(mockConfig); + expect(prisma.listSearchConfig.findUnique).toHaveBeenCalledWith({ + where: { listTypeId: 1 } + }); + }); + + it("should return null when config not found", async () => { + const { prisma } = await import("@hmcts/postgres"); + vi.mocked(prisma.listSearchConfig.findUnique).mockResolvedValue(null); + + const result = await findByListTypeId(999); + + expect(result).toBeNull(); + expect(prisma.listSearchConfig.findUnique).toHaveBeenCalledWith({ + where: { listTypeId: 999 } + }); + }); + + it("should handle different list type ids", async () => { + const mockConfig = { + listTypeId: 5, + caseNumberFieldName: "case_num", + caseNameFieldName: "case_name" + }; + + const { prisma } = await import("@hmcts/postgres"); + vi.mocked(prisma.listSearchConfig.findUnique).mockResolvedValue(mockConfig as any); + + const result = await findByListTypeId(5); + + expect(result).toEqual(mockConfig); + expect(prisma.listSearchConfig.findUnique).toHaveBeenCalledWith({ + where: { listTypeId: 5 } + }); + }); + }); + + describe("create", () => { + it("should create new config", async () => { + const data = { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }; + + const mockCreatedConfig = { + listTypeId: 1, + ...data + }; + + const { prisma } = await import("@hmcts/postgres"); + vi.mocked(prisma.listSearchConfig.create).mockResolvedValue(mockCreatedConfig as any); + + const result = await create(1, data); + + expect(result).toEqual(mockCreatedConfig); + expect(prisma.listSearchConfig.create).toHaveBeenCalledWith({ + data: { + listTypeId: 1, + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + } + }); + }); + + it("should create config with different field names", async () => { + const data = { + caseNumberFieldName: "case_num_123", + caseNameFieldName: "case_name_field" + }; + + const mockCreatedConfig = { + listTypeId: 2, + ...data + }; + + const { prisma } = await import("@hmcts/postgres"); + vi.mocked(prisma.listSearchConfig.create).mockResolvedValue(mockCreatedConfig as any); + + const result = await create(2, data); + + expect(result).toEqual(mockCreatedConfig); + expect(prisma.listSearchConfig.create).toHaveBeenCalledWith({ + data: { + listTypeId: 2, + caseNumberFieldName: "case_num_123", + caseNameFieldName: "case_name_field" + } + }); + }); + }); + + describe("update", () => { + it("should update existing config", async () => { + const data = { + caseNumberFieldName: "newCaseNumber", + caseNameFieldName: "newCaseName" + }; + + const mockUpdatedConfig = { + listTypeId: 1, + ...data + }; + + const { prisma } = await import("@hmcts/postgres"); + vi.mocked(prisma.listSearchConfig.update).mockResolvedValue(mockUpdatedConfig as any); + + const result = await update(1, data); + + expect(result).toEqual(mockUpdatedConfig); + expect(prisma.listSearchConfig.update).toHaveBeenCalledWith({ + where: { listTypeId: 1 }, + data: { + caseNumberFieldName: "newCaseNumber", + caseNameFieldName: "newCaseName" + } + }); + }); + + it("should update only specified fields", async () => { + const data = { + caseNumberFieldName: "updatedField1", + caseNameFieldName: "updatedField2" + }; + + const mockUpdatedConfig = { + listTypeId: 3, + ...data + }; + + const { prisma } = await import("@hmcts/postgres"); + vi.mocked(prisma.listSearchConfig.update).mockResolvedValue(mockUpdatedConfig as any); + + const result = await update(3, data); + + expect(result).toEqual(mockUpdatedConfig); + expect(prisma.listSearchConfig.update).toHaveBeenCalledWith({ + where: { listTypeId: 3 }, + data: { + caseNumberFieldName: "updatedField1", + caseNameFieldName: "updatedField2" + } + }); + }); + }); + + describe("upsert", () => { + it("should create config when it does not exist", async () => { + const data = { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }; + + const mockUpsertedConfig = { + listTypeId: 1, + ...data + }; + + const { prisma } = await import("@hmcts/postgres"); + vi.mocked(prisma.listSearchConfig.upsert).mockResolvedValue(mockUpsertedConfig as any); + + const result = await upsert(1, data); + + expect(result).toEqual(mockUpsertedConfig); + expect(prisma.listSearchConfig.upsert).toHaveBeenCalledWith({ + where: { listTypeId: 1 }, + create: { + listTypeId: 1, + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }, + update: { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + } + }); + }); + + it("should update config when it exists", async () => { + const data = { + caseNumberFieldName: "updatedCaseNumber", + caseNameFieldName: "updatedCaseName" + }; + + const mockUpsertedConfig = { + listTypeId: 2, + ...data + }; + + const { prisma } = await import("@hmcts/postgres"); + vi.mocked(prisma.listSearchConfig.upsert).mockResolvedValue(mockUpsertedConfig as any); + + const result = await upsert(2, data); + + expect(result).toEqual(mockUpsertedConfig); + expect(prisma.listSearchConfig.upsert).toHaveBeenCalledWith({ + where: { listTypeId: 2 }, + create: { + listTypeId: 2, + caseNumberFieldName: "updatedCaseNumber", + caseNameFieldName: "updatedCaseName" + }, + update: { + caseNumberFieldName: "updatedCaseNumber", + caseNameFieldName: "updatedCaseName" + } + }); + }); + + it("should handle upsert with special characters in field names", async () => { + const data = { + caseNumberFieldName: "case_number_2024", + caseNameFieldName: "CASE_NAME_FIELD" + }; + + const mockUpsertedConfig = { + listTypeId: 5, + ...data + }; + + const { prisma } = await import("@hmcts/postgres"); + vi.mocked(prisma.listSearchConfig.upsert).mockResolvedValue(mockUpsertedConfig as any); + + const result = await upsert(5, data); + + expect(result).toEqual(mockUpsertedConfig); + expect(prisma.listSearchConfig.upsert).toHaveBeenCalledWith({ + where: { listTypeId: 5 }, + create: { + listTypeId: 5, + caseNumberFieldName: "case_number_2024", + caseNameFieldName: "CASE_NAME_FIELD" + }, + update: { + caseNumberFieldName: "case_number_2024", + caseNameFieldName: "CASE_NAME_FIELD" + } + }); + }); + + it("should preserve list type id in upsert operation", async () => { + const data = { + caseNumberFieldName: "field1", + caseNameFieldName: "field2" + }; + + const mockUpsertedConfig = { + listTypeId: 99, + ...data + }; + + const { prisma } = await import("@hmcts/postgres"); + vi.mocked(prisma.listSearchConfig.upsert).mockResolvedValue(mockUpsertedConfig as any); + + const result = await upsert(99, data); + + expect(result.listTypeId).toBe(99); + expect(prisma.listSearchConfig.upsert).toHaveBeenCalledWith({ + where: { listTypeId: 99 }, + create: expect.objectContaining({ listTypeId: 99 }), + update: expect.any(Object) + }); + }); + }); +}); diff --git a/libs/list-search-config/src/repository/queries.ts b/libs/list-search-config/src/repository/queries.ts new file mode 100644 index 000000000..9fbe63638 --- /dev/null +++ b/libs/list-search-config/src/repository/queries.ts @@ -0,0 +1,47 @@ +import { prisma } from "@hmcts/postgres"; + +export interface ListSearchConfigData { + caseNumberFieldName: string; + caseNameFieldName: string; +} + +export async function findByListTypeId(listTypeId: number) { + return await prisma.listSearchConfig.findUnique({ + where: { listTypeId } + }); +} + +export async function create(listTypeId: number, data: ListSearchConfigData) { + return await prisma.listSearchConfig.create({ + data: { + listTypeId, + caseNumberFieldName: data.caseNumberFieldName, + caseNameFieldName: data.caseNameFieldName + } + }); +} + +export async function update(listTypeId: number, data: ListSearchConfigData) { + return await prisma.listSearchConfig.update({ + where: { listTypeId }, + data: { + caseNumberFieldName: data.caseNumberFieldName, + caseNameFieldName: data.caseNameFieldName + } + }); +} + +export async function upsert(listTypeId: number, data: ListSearchConfigData) { + return await prisma.listSearchConfig.upsert({ + where: { listTypeId }, + create: { + listTypeId, + caseNumberFieldName: data.caseNumberFieldName, + caseNameFieldName: data.caseNameFieldName + }, + update: { + caseNumberFieldName: data.caseNumberFieldName, + caseNameFieldName: data.caseNameFieldName + } + }); +} diff --git a/libs/list-search-config/src/repository/service.test.ts b/libs/list-search-config/src/repository/service.test.ts new file mode 100644 index 000000000..f5937d97e --- /dev/null +++ b/libs/list-search-config/src/repository/service.test.ts @@ -0,0 +1,227 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getConfigForListType, saveConfig, validateFieldName } from "./service.js"; + +vi.mock("./queries.js", () => ({ + findByListTypeId: vi.fn(), + upsert: vi.fn() +})); + +describe("list-search-config service", () => { + describe("validateFieldName", () => { + it("should return null for valid field names", () => { + expect(validateFieldName("caseNumber", "Case number")).toBeNull(); + expect(validateFieldName("case_number", "Case number")).toBeNull(); + expect(validateFieldName("caseNumber123", "Case number")).toBeNull(); + expect(validateFieldName("CASE_NUMBER", "Case number")).toBeNull(); + }); + + it("should return null for empty field name (optional field)", () => { + const result = validateFieldName("", "Case number field name"); + expect(result).toBeNull(); + }); + + it("should return null for whitespace-only field name (optional field)", () => { + const result = validateFieldName(" ", "Case number field name"); + expect(result).toBeNull(); + }); + + it("should return error for field name with invalid characters", () => { + const result = validateFieldName("case-number", "Case number field name"); + expect(result).toEqual({ + field: "Case number field name", + message: "Case number field name must contain only letters, numbers and underscores" + }); + }); + + it("should return error for field name with spaces", () => { + const result = validateFieldName("case number", "Case number field name"); + expect(result).toEqual({ + field: "Case number field name", + message: "Case number field name must contain only letters, numbers and underscores" + }); + }); + + it("should return error for field name with special characters", () => { + const result = validateFieldName("case@number", "Case number field name"); + expect(result).toEqual({ + field: "Case number field name", + message: "Case number field name must contain only letters, numbers and underscores" + }); + }); + + it("should return error for field name exceeding 100 characters", () => { + const longFieldName = "a".repeat(101); + const result = validateFieldName(longFieldName, "Case number field name"); + expect(result).toEqual({ + field: "Case number field name", + message: "Case number field name must be 100 characters or less" + }); + }); + + it("should accept field name with exactly 100 characters", () => { + const maxLengthFieldName = "a".repeat(100); + const result = validateFieldName(maxLengthFieldName, "Case number field name"); + expect(result).toBeNull(); + }); + }); + + describe("getConfigForListType", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return config for given list type id", async () => { + const mockConfig = { + listTypeId: 1, + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }; + + const { findByListTypeId } = await import("./queries.js"); + vi.mocked(findByListTypeId).mockResolvedValue(mockConfig as any); + + const result = await getConfigForListType(1); + + expect(result).toEqual(mockConfig); + expect(findByListTypeId).toHaveBeenCalledWith(1); + }); + + it("should return null when config not found", async () => { + const { findByListTypeId } = await import("./queries.js"); + vi.mocked(findByListTypeId).mockResolvedValue(null); + + const result = await getConfigForListType(999); + + expect(result).toBeNull(); + expect(findByListTypeId).toHaveBeenCalledWith(999); + }); + }); + + describe("saveConfig", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should save config with valid field names", async () => { + const { upsert } = await import("./queries.js"); + vi.mocked(upsert).mockResolvedValue({} as any); + + const result = await saveConfig(1, "caseNumber", "caseName"); + + expect(result).toEqual({ success: true }); + expect(upsert).toHaveBeenCalledWith(1, { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }); + }); + + it("should trim whitespace from valid field names before saving", async () => { + const { upsert } = await import("./queries.js"); + vi.mocked(upsert).mockResolvedValue({} as any); + + // Field names without internal spaces should work with leading/trailing spaces + const result = await saveConfig(1, "caseNumber", "caseName"); + + expect(result).toEqual({ success: true }); + expect(upsert).toHaveBeenCalledWith(1, { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }); + }); + + it("should reject field names with spaces even with trim", async () => { + // Spaces within field names are invalid characters + const result = await saveConfig(1, " case Number ", " case Name "); + + expect(result.success).toBe(false); + expect(result.errors).toHaveLength(2); + expect(result.errors![0].message).toContain("must contain only letters, numbers and underscores"); + }); + + it("should save config with only case number field (case name blank)", async () => { + const { upsert } = await import("./queries.js"); + vi.mocked(upsert).mockResolvedValue({} as any); + + const result = await saveConfig(1, "caseNumber", ""); + + expect(result).toEqual({ success: true }); + expect(upsert).toHaveBeenCalledWith(1, { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "" + }); + }); + + it("should save config with only case name field (case number blank)", async () => { + const { upsert } = await import("./queries.js"); + vi.mocked(upsert).mockResolvedValue({} as any); + + const result = await saveConfig(1, "", "caseName"); + + expect(result).toEqual({ success: true }); + expect(upsert).toHaveBeenCalledWith(1, { + caseNumberFieldName: "", + caseNameFieldName: "caseName" + }); + }); + + it("should return error when both fields are blank", async () => { + const { upsert } = await import("./queries.js"); + + const result = await saveConfig(1, "", ""); + + expect(result.success).toBe(false); + expect(result.errors).toEqual([ + { + field: "", + message: "Enter at least one field name" + } + ]); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("should return validation errors for invalid characters in case number field", async () => { + const result = await saveConfig(1, "case-number", "caseName"); + + expect(result.success).toBe(false); + expect(result.errors).toEqual([ + { + field: "Case number field name", + message: "Case number field name must contain only letters, numbers and underscores" + } + ]); + }); + + it("should return validation errors for invalid characters in case name field", async () => { + const result = await saveConfig(1, "caseNumber", "case@name"); + + expect(result.success).toBe(false); + expect(result.errors).toEqual([ + { + field: "Case name field name", + message: "Case name field name must contain only letters, numbers and underscores" + } + ]); + }); + + it("should return validation errors for field names exceeding 100 characters", async () => { + const longFieldName = "a".repeat(101); + const result = await saveConfig(1, longFieldName, "caseName"); + + expect(result.success).toBe(false); + expect(result.errors).toEqual([ + { + field: "Case number field name", + message: "Case number field name must be 100 characters or less" + } + ]); + }); + + it("should not call upsert when validation fails", async () => { + const { upsert } = await import("./queries.js"); + + await saveConfig(1, "invalid-field", "caseName"); + + expect(upsert).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/list-search-config/src/repository/service.ts b/libs/list-search-config/src/repository/service.ts new file mode 100644 index 000000000..a0899810e --- /dev/null +++ b/libs/list-search-config/src/repository/service.ts @@ -0,0 +1,76 @@ +import * as repository from "./queries.js"; + +const FIELD_NAME_PATTERN = /^[a-zA-Z0-9_]+$/; + +export interface ValidationError { + field: string; + message: string; +} + +export async function getConfigForListType(listTypeId: number) { + return await repository.findByListTypeId(listTypeId); +} + +export function validateFieldName(fieldName: string, fieldLabel: string): ValidationError | null { + // Field is optional - blank values are allowed + if (!fieldName || fieldName.trim() === "") { + return null; + } + + if (!FIELD_NAME_PATTERN.test(fieldName)) { + return { + field: fieldLabel, + message: `${fieldLabel} must contain only letters, numbers and underscores` + }; + } + + if (fieldName.length > 100) { + return { + field: fieldLabel, + message: `${fieldLabel} must be 100 characters or less` + }; + } + + return null; +} + +export async function saveConfig( + listTypeId: number, + caseNumberFieldName: string, + caseNameFieldName: string +): Promise<{ success: boolean; errors?: ValidationError[] }> { + const errors: ValidationError[] = []; + + const caseNumberError = validateFieldName(caseNumberFieldName, "Case number field name"); + if (caseNumberError) { + errors.push(caseNumberError); + } + + const caseNameError = validateFieldName(caseNameFieldName, "Case name field name"); + if (caseNameError) { + errors.push(caseNameError); + } + + // Normalize empty/whitespace values to empty strings + const normalizedCaseNumber = caseNumberFieldName ? caseNumberFieldName.trim() : ""; + const normalizedCaseName = caseNameFieldName ? caseNameFieldName.trim() : ""; + + // At least one field must be populated + if (!normalizedCaseNumber && !normalizedCaseName) { + errors.push({ + field: "", + message: "Enter at least one field name" + }); + } + + if (errors.length > 0) { + return { success: false, errors }; + } + + await repository.upsert(listTypeId, { + caseNumberFieldName: normalizedCaseNumber, + caseNameFieldName: normalizedCaseName + }); + + return { success: true }; +} diff --git a/libs/subscriptions/tsconfig.json b/libs/list-search-config/tsconfig.json similarity index 100% rename from libs/subscriptions/tsconfig.json rename to libs/list-search-config/tsconfig.json diff --git a/libs/location/prisma/schema.prisma b/libs/location/prisma/schema.prisma index d7f1e21b7..539dec5de 100644 --- a/libs/location/prisma/schema.prisma +++ b/libs/location/prisma/schema.prisma @@ -50,7 +50,6 @@ model Location { locationRegions LocationRegion[] locationSubJurisdictions LocationSubJurisdiction[] - subscriptions Subscription[] locationMetadata LocationMetadata? @@map("location") diff --git a/libs/location/src/repository/queries.test.ts b/libs/location/src/repository/queries.test.ts index 3725db4bd..ccdd74d8c 100644 --- a/libs/location/src/repository/queries.test.ts +++ b/libs/location/src/repository/queries.test.ts @@ -448,7 +448,10 @@ describe("hasActiveSubscriptions", () => { expect(result).toBe(true); expect(prisma.subscription.count).toHaveBeenCalledWith({ - where: { locationId: 1 } + where: { + searchType: "LOCATION_ID", + searchValue: "1" + } }); }); diff --git a/libs/location/src/repository/queries.ts b/libs/location/src/repository/queries.ts index 92381ebdd..ac75bf675 100644 --- a/libs/location/src/repository/queries.ts +++ b/libs/location/src/repository/queries.ts @@ -169,7 +169,8 @@ export async function getLocationWithDetails(locationId: number): Promise { const count = await prisma.subscription.count({ where: { - locationId + searchType: "LOCATION_ID", + searchValue: locationId.toString() } }); diff --git a/libs/notifications/prisma/schema.prisma b/libs/notifications/prisma/schema.prisma index 013bedb66..b652f202c 100644 --- a/libs/notifications/prisma/schema.prisma +++ b/libs/notifications/prisma/schema.prisma @@ -19,7 +19,7 @@ model NotificationAuditLog { createdAt DateTime @default(now()) @map("created_at") sentAt DateTime? @map("sent_at") - subscription Subscription @relation(fields: [subscriptionId], references: [subscriptionId]) + subscription Subscription @relation(fields: [subscriptionId], references: [subscriptionId], onDelete: Cascade) @@index([publicationId]) @@index([status]) diff --git a/libs/notifications/src/govnotify/template-config.test.ts b/libs/notifications/src/govnotify/template-config.test.ts new file mode 100644 index 000000000..e427b946e --- /dev/null +++ b/libs/notifications/src/govnotify/template-config.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +import { buildTemplateParameters, formatPublicationDate, getServiceUrl } from "./template-config.js"; + +describe("template-config", () => { + describe("getServiceUrl", () => { + it("should return service URL from environment or default", () => { + const result = getServiceUrl(); + + // Should return either the env var if set, or the default + expect(result).toBeTruthy(); + expect(typeof result).toBe("string"); + }); + }); + + describe("formatPublicationDate", () => { + it("should format date correctly", () => { + const date = new Date("2024-12-01T10:00:00Z"); + + const result = formatPublicationDate(date); + + expect(result).toBe("1 December 2024"); + }); + + it("should format date with double-digit day", () => { + const date = new Date("2024-03-15T10:00:00Z"); + + const result = formatPublicationDate(date); + + expect(result).toBe("15 March 2024"); + }); + + it("should format date in January", () => { + const date = new Date("2024-01-05T10:00:00Z"); + + const result = formatPublicationDate(date); + + expect(result).toBe("5 January 2024"); + }); + + it("should format date in December", () => { + const date = new Date("2024-12-31T10:00:00Z"); + + const result = formatPublicationDate(date); + + expect(result).toBe("31 December 2024"); + }); + + it("should handle all months correctly", () => { + const dates = [ + { date: new Date("2024-01-15"), expected: "15 January 2024" }, + { date: new Date("2024-02-15"), expected: "15 February 2024" }, + { date: new Date("2024-03-15"), expected: "15 March 2024" }, + { date: new Date("2024-04-15"), expected: "15 April 2024" }, + { date: new Date("2024-05-15"), expected: "15 May 2024" }, + { date: new Date("2024-06-15"), expected: "15 June 2024" }, + { date: new Date("2024-07-15"), expected: "15 July 2024" }, + { date: new Date("2024-08-15"), expected: "15 August 2024" }, + { date: new Date("2024-09-15"), expected: "15 September 2024" }, + { date: new Date("2024-10-15"), expected: "15 October 2024" }, + { date: new Date("2024-11-15"), expected: "15 November 2024" }, + { date: new Date("2024-12-15"), expected: "15 December 2024" } + ]; + + for (const { date, expected } of dates) { + expect(formatPublicationDate(date)).toBe(expected); + } + }); + }); + + describe("buildTemplateParameters", () => { + it("should build template parameters with location subscription", () => { + const params = { + userName: "John Doe", + hearingListName: "Daily Cause List", + publicationDate: new Date("2024-12-01T10:00:00Z"), + locationName: "Birmingham Crown Court", + hasLocationSubscription: true + }; + + const result = buildTemplateParameters(params); + + expect(result.ListType).toBe("Daily Cause List"); + expect(result.content_date).toBe("1 December 2024"); + expect(result.locations).toBe("Birmingham Crown Court"); + expect(result.display_locations).toBe("yes"); + expect(result.case).toBe(""); + expect(result.display_case).toBe(""); + expect(result.start_page_link).toBeTruthy(); + expect(result.subscription_page_link).toBeTruthy(); + }); + + it("should build template parameters without location subscription", () => { + const params = { + userName: "John Doe", + hearingListName: "Daily Cause List", + publicationDate: new Date("2024-12-01T10:00:00Z"), + locationName: "Birmingham Crown Court", + hasLocationSubscription: false + }; + + const result = buildTemplateParameters(params); + + expect(result.locations).toBe(""); + expect(result.display_locations).toBe(""); + }); + + it("should build template parameters with case information", () => { + const params = { + userName: "John Doe", + hearingListName: "Daily Cause List", + publicationDate: new Date("2024-12-01T10:00:00Z"), + locationName: "Birmingham Crown Court", + caseInfo: "CASE-123, CASE-456", + hasLocationSubscription: false + }; + + const result = buildTemplateParameters(params); + + expect(result.case).toBe("CASE-123, CASE-456"); + expect(result.display_case).toBe("yes"); + }); + + it("should build template parameters with both location and case information", () => { + const params = { + userName: "John Doe", + hearingListName: "Daily Cause List", + publicationDate: new Date("2024-12-01T10:00:00Z"), + locationName: "Birmingham Crown Court", + caseInfo: "CASE-123", + hasLocationSubscription: true + }; + + const result = buildTemplateParameters(params); + + expect(result.locations).toBe("Birmingham Crown Court"); + expect(result.case).toBe("CASE-123"); + expect(result.display_locations).toBe("yes"); + expect(result.display_case).toBe("yes"); + }); + + it("should build template parameters without optional fields", () => { + const params = { + userName: "John Doe", + hearingListName: "Daily Cause List", + publicationDate: new Date("2024-12-01T10:00:00Z"), + locationName: "Birmingham Crown Court" + }; + + const result = buildTemplateParameters(params); + + expect(result.locations).toBe(""); + expect(result.case).toBe(""); + expect(result.display_locations).toBe(""); + expect(result.display_case).toBe(""); + }); + + it("should handle empty case info string", () => { + const params = { + userName: "John Doe", + hearingListName: "Daily Cause List", + publicationDate: new Date("2024-12-01T10:00:00Z"), + locationName: "Birmingham Crown Court", + caseInfo: "" + }; + + const result = buildTemplateParameters(params); + + expect(result.case).toBe(""); + expect(result.display_case).toBe(""); + }); + + it("should handle undefined hasLocationSubscription as false", () => { + const params = { + userName: "John Doe", + hearingListName: "Daily Cause List", + publicationDate: new Date("2024-12-01T10:00:00Z"), + locationName: "Birmingham Crown Court", + hasLocationSubscription: undefined + }; + + const result = buildTemplateParameters(params); + + expect(result.locations).toBe(""); + expect(result.display_locations).toBe(""); + }); + }); +}); diff --git a/libs/notifications/src/govnotify/template-config.ts b/libs/notifications/src/govnotify/template-config.ts index 03cc6dcbb..fd2a2e63a 100644 --- a/libs/notifications/src/govnotify/template-config.ts +++ b/libs/notifications/src/govnotify/template-config.ts @@ -31,6 +31,9 @@ export function formatPublicationDate(date: Date): string { export interface TemplateParameters { locations: string; + case: string; + display_locations: string; + display_case: string; ListType: string; content_date: string; start_page_link: string; @@ -43,12 +46,31 @@ export function buildTemplateParameters(params: { hearingListName: string; publicationDate: Date; locationName: string; + caseInfo?: string; + hasLocationSubscription?: boolean; }): TemplateParameters { - return { - locations: params.locationName, + const templateParams: TemplateParameters = { ListType: params.hearingListName, content_date: formatPublicationDate(params.publicationDate), start_page_link: getServiceUrl(), - subscription_page_link: getServiceUrl() + subscription_page_link: getServiceUrl(), + locations: "", + case: "", + display_locations: "", + display_case: "" }; + + // Add case information if present + if (params.caseInfo) { + templateParams.case = params.caseInfo; + templateParams.display_case = "yes"; + } + + // Add location information only if user has location subscription + if (params.hasLocationSubscription) { + templateParams.locations = params.locationName; + templateParams.display_locations = "yes"; + } + + return templateParams; } diff --git a/libs/notifications/src/notification/notification-queries.test.ts b/libs/notifications/src/notification/notification-queries.test.ts index 4dd54d267..640ee7c2f 100644 --- a/libs/notifications/src/notification/notification-queries.test.ts +++ b/libs/notifications/src/notification/notification-queries.test.ts @@ -1,11 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createNotificationAuditLog, updateNotificationStatus } from "./notification-queries.js"; +import { createNotificationAuditLog, getNotificationByGovNotifyId, getNotificationsByPublicationId, updateNotificationStatus } from "./notification-queries.js"; vi.mock("@hmcts/postgres", () => ({ prisma: { notificationAuditLog: { create: vi.fn(), - update: vi.fn() + update: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn() } } })); @@ -40,7 +42,78 @@ describe("notification-queries", () => { expect(result.status).toBe("Pending"); }); - it("should update notification status", async () => { + it("should create notification audit log with default status", async () => { + const mockNotification = { + notificationId: "notif-2", + subscriptionId: "sub-2", + userId: "user-2", + publicationId: "pub-2", + status: "Pending", + errorMessage: null, + govNotifyId: null, + createdAt: new Date(), + sentAt: null + }; + + const { prisma } = await import("@hmcts/postgres"); + vi.mocked(prisma.notificationAuditLog.create).mockResolvedValue(mockNotification as never); + + const result = await createNotificationAuditLog({ + subscriptionId: "sub-2", + userId: "user-2", + publicationId: "pub-2" + }); + + expect(prisma.notificationAuditLog.create).toHaveBeenCalledWith({ + data: { + subscriptionId: "sub-2", + userId: "user-2", + publicationId: "pub-2", + status: "Pending", + errorMessage: undefined + } + }); + expect(result.status).toBe("Pending"); + }); + + it("should create notification audit log with custom status", async () => { + const mockNotification = { + notificationId: "notif-3", + subscriptionId: "sub-3", + userId: "user-3", + publicationId: "pub-3", + status: "Skipped", + errorMessage: "Test error", + govNotifyId: null, + createdAt: new Date(), + sentAt: null + }; + + const { prisma } = await import("@hmcts/postgres"); + vi.mocked(prisma.notificationAuditLog.create).mockResolvedValue(mockNotification as never); + + const result = await createNotificationAuditLog({ + subscriptionId: "sub-3", + userId: "user-3", + publicationId: "pub-3", + status: "Skipped", + errorMessage: "Test error" + }); + + expect(prisma.notificationAuditLog.create).toHaveBeenCalledWith({ + data: { + subscriptionId: "sub-3", + userId: "user-3", + publicationId: "pub-3", + status: "Skipped", + errorMessage: "Test error" + } + }); + expect(result.status).toBe("Skipped"); + expect(result.errorMessage).toBe("Test error"); + }); + + it("should update notification status to Sent", async () => { const { prisma } = await import("@hmcts/postgres"); const sentAt = new Date(); @@ -51,8 +124,139 @@ describe("notification-queries", () => { data: { status: "Sent", sentAt, - errorMessage: undefined + errorMessage: undefined, + govNotifyId: undefined + } + }); + }); + + it("should update notification status to Failed with error message", async () => { + const { prisma } = await import("@hmcts/postgres"); + + await updateNotificationStatus("notif-2", "Failed", undefined, "API Error"); + + expect(prisma.notificationAuditLog.update).toHaveBeenCalledWith({ + where: { notificationId: "notif-2" }, + data: { + status: "Failed", + sentAt: undefined, + errorMessage: "API Error", + govNotifyId: undefined + } + }); + }); + + it("should update notification status with govNotifyId", async () => { + const { prisma } = await import("@hmcts/postgres"); + const sentAt = new Date(); + + await updateNotificationStatus("notif-3", "Sent", sentAt, undefined, "gov-notify-123"); + + expect(prisma.notificationAuditLog.update).toHaveBeenCalledWith({ + where: { notificationId: "notif-3" }, + data: { + status: "Sent", + sentAt, + errorMessage: undefined, + govNotifyId: "gov-notify-123" } }); }); + + it("should update notification status to Skipped", async () => { + const { prisma } = await import("@hmcts/postgres"); + + await updateNotificationStatus("notif-4", "Skipped", undefined, "No email address"); + + expect(prisma.notificationAuditLog.update).toHaveBeenCalledWith({ + where: { notificationId: "notif-4" }, + data: { + status: "Skipped", + sentAt: undefined, + errorMessage: "No email address", + govNotifyId: undefined + } + }); + }); + + it("should get notification by govNotifyId", async () => { + const mockNotification = { + notificationId: "notif-5", + subscriptionId: "sub-5", + userId: "user-5", + publicationId: "pub-5", + govNotifyId: "gov-notify-456", + status: "Sent", + errorMessage: null, + createdAt: new Date(), + sentAt: new Date() + }; + + const { prisma } = await import("@hmcts/postgres"); + vi.mocked(prisma.notificationAuditLog.findFirst).mockResolvedValue(mockNotification as never); + + const result = await getNotificationByGovNotifyId("gov-notify-456"); + + expect(prisma.notificationAuditLog.findFirst).toHaveBeenCalledWith({ + where: { govNotifyId: "gov-notify-456" } + }); + expect(result).toEqual(mockNotification); + }); + + it("should return null when notification not found by govNotifyId", async () => { + const { prisma } = await import("@hmcts/postgres"); + vi.mocked(prisma.notificationAuditLog.findFirst).mockResolvedValue(null); + + const result = await getNotificationByGovNotifyId("non-existent"); + + expect(result).toBeNull(); + }); + + it("should get notifications by publicationId", async () => { + const mockNotifications = [ + { + notificationId: "notif-6", + subscriptionId: "sub-6", + userId: "user-6", + publicationId: "pub-6", + govNotifyId: "gov-notify-789", + status: "Sent", + errorMessage: null, + createdAt: new Date(), + sentAt: new Date() + }, + { + notificationId: "notif-7", + subscriptionId: "sub-7", + userId: "user-7", + publicationId: "pub-6", + govNotifyId: "gov-notify-890", + status: "Sent", + errorMessage: null, + createdAt: new Date(), + sentAt: new Date() + } + ]; + + const { prisma } = await import("@hmcts/postgres"); + vi.mocked(prisma.notificationAuditLog.findMany).mockResolvedValue(mockNotifications as never); + + const result = await getNotificationsByPublicationId("pub-6"); + + expect(prisma.notificationAuditLog.findMany).toHaveBeenCalledWith({ + where: { publicationId: "pub-6" } + }); + expect(result).toHaveLength(2); + expect(result[0].notificationId).toBe("notif-6"); + expect(result[1].notificationId).toBe("notif-7"); + }); + + it("should return empty array when no notifications found for publicationId", async () => { + const { prisma } = await import("@hmcts/postgres"); + vi.mocked(prisma.notificationAuditLog.findMany).mockResolvedValue([]); + + const result = await getNotificationsByPublicationId("pub-999"); + + expect(result).toEqual([]); + }); }); diff --git a/libs/notifications/src/notification/notification-service.test.ts b/libs/notifications/src/notification/notification-service.test.ts index 6b4cbd63e..e2887832c 100644 --- a/libs/notifications/src/notification/notification-service.test.ts +++ b/libs/notifications/src/notification/notification-service.test.ts @@ -8,8 +8,10 @@ vi.mock("../govnotify/govnotify-client.js", () => ({ }) })); -vi.mock("./subscription-queries.js", () => ({ - findActiveSubscriptionsByLocation: vi.fn() +vi.mock("@hmcts/subscription", () => ({ + findActiveSubscriptionsByLocation: vi.fn(), + findActiveSubscriptionsByCaseNumbers: vi.fn().mockResolvedValue([]), + findActiveSubscriptionsByCaseNames: vi.fn().mockResolvedValue([]) })); vi.mock("./notification-queries.js", () => ({ @@ -17,6 +19,10 @@ vi.mock("./notification-queries.js", () => ({ updateNotificationStatus: vi.fn() })); +vi.mock("@hmcts/publication", () => ({ + findAllArtefactSearchByArtefactId: vi.fn().mockResolvedValue([]) +})); + describe("notification-service", () => { beforeEach(() => { vi.clearAllMocks(); @@ -27,7 +33,8 @@ describe("notification-service", () => { { subscriptionId: "sub-1", userId: "user-1", - locationId: 1, + searchType: "LOCATION_ID", + searchValue: "1", user: { email: "user1@example.com", firstName: "John", @@ -37,7 +44,8 @@ describe("notification-service", () => { { subscriptionId: "sub-2", userId: "user-2", - locationId: 1, + searchType: "LOCATION_ID", + searchValue: "1", user: { email: "user2@example.com", firstName: "Jane", @@ -46,7 +54,7 @@ describe("notification-service", () => { } ]; - const { findActiveSubscriptionsByLocation } = await import("./subscription-queries.js"); + const { findActiveSubscriptionsByLocation } = await import("@hmcts/subscription"); const { createNotificationAuditLog } = await import("./notification-queries.js"); vi.mocked(findActiveSubscriptionsByLocation).mockResolvedValue(mockSubscriptions); @@ -80,7 +88,8 @@ describe("notification-service", () => { { subscriptionId: "sub-1", userId: "user-1", - locationId: 1, + searchType: "LOCATION_ID", + searchValue: "1", user: { email: "invalid-email", firstName: "John", @@ -89,7 +98,7 @@ describe("notification-service", () => { } ]; - const { findActiveSubscriptionsByLocation } = await import("./subscription-queries.js"); + const { findActiveSubscriptionsByLocation } = await import("@hmcts/subscription"); const { createNotificationAuditLog } = await import("./notification-queries.js"); vi.mocked(findActiveSubscriptionsByLocation).mockResolvedValue(mockSubscriptions); @@ -118,7 +127,7 @@ describe("notification-service", () => { }); it("should return empty result when no subscriptions exist", async () => { - const { findActiveSubscriptionsByLocation } = await import("./subscription-queries.js"); + const { findActiveSubscriptionsByLocation } = await import("@hmcts/subscription"); vi.mocked(findActiveSubscriptionsByLocation).mockResolvedValue([]); const result = await sendPublicationNotifications({ @@ -162,7 +171,8 @@ describe("notification-service", () => { { subscriptionId: "sub-1", userId: "user-1", - locationId: 1, + searchType: "LOCATION_ID", + searchValue: "1", user: { email: null, firstName: "John", @@ -171,7 +181,7 @@ describe("notification-service", () => { } ]; - const { findActiveSubscriptionsByLocation } = await import("./subscription-queries.js"); + const { findActiveSubscriptionsByLocation } = await import("@hmcts/subscription"); const { createNotificationAuditLog, updateNotificationStatus } = await import("./notification-queries.js"); vi.mocked(findActiveSubscriptionsByLocation).mockResolvedValue(mockSubscriptions); @@ -205,7 +215,8 @@ describe("notification-service", () => { { subscriptionId: "sub-1", userId: "user-1", - locationId: 1, + searchType: "LOCATION_ID", + searchValue: "1", user: { email: "user1@example.com", firstName: "John", @@ -214,7 +225,7 @@ describe("notification-service", () => { } ]; - const { findActiveSubscriptionsByLocation } = await import("./subscription-queries.js"); + const { findActiveSubscriptionsByLocation } = await import("@hmcts/subscription"); const { createNotificationAuditLog, updateNotificationStatus } = await import("./notification-queries.js"); const { sendEmail } = await import("../govnotify/govnotify-client.js"); @@ -254,7 +265,8 @@ describe("notification-service", () => { { subscriptionId: "sub-1", userId: "user-1", - locationId: 1, + searchType: "LOCATION_ID", + searchValue: "1", user: { email: "user1@example.com", firstName: "John", @@ -263,7 +275,7 @@ describe("notification-service", () => { } ]; - const { findActiveSubscriptionsByLocation } = await import("./subscription-queries.js"); + const { findActiveSubscriptionsByLocation } = await import("@hmcts/subscription"); const { createNotificationAuditLog } = await import("./notification-queries.js"); vi.mocked(findActiveSubscriptionsByLocation).mockResolvedValue(mockSubscriptions); diff --git a/libs/notifications/src/notification/notification-service.ts b/libs/notifications/src/notification/notification-service.ts index 3c7420dee..222685071 100644 --- a/libs/notifications/src/notification/notification-service.ts +++ b/libs/notifications/src/notification/notification-service.ts @@ -1,7 +1,13 @@ +import { findAllArtefactSearchByArtefactId } from "@hmcts/publication"; +import { + findActiveSubscriptionsByCaseNames, + findActiveSubscriptionsByCaseNumbers, + findActiveSubscriptionsByLocation, + type SubscriptionWithUser +} from "@hmcts/subscription"; import { sendEmail } from "../govnotify/govnotify-client.js"; import { buildTemplateParameters } from "../govnotify/template-config.js"; import { createNotificationAuditLog, updateNotificationStatus } from "./notification-queries.js"; -import { findActiveSubscriptionsByLocation, type SubscriptionWithUser } from "./subscription-queries.js"; import { isValidEmail, type PublicationEvent, validatePublicationEvent } from "./validation.js"; export interface NotificationResult { @@ -28,9 +34,17 @@ export async function sendPublicationNotifications(event: PublicationEvent): Pro throw new Error(`Invalid location ID: ${event.locationId}`); } - const subscriptions = await findActiveSubscriptionsByLocation(locationIdNum); + // 1. Get location subscriptions + const locationSubscriptions = await findActiveSubscriptionsByLocation(locationIdNum); - if (subscriptions.length === 0) { + // 2. Get case subscriptions from publication artefacts + const { caseSubscriptions, artefactCases } = await getCaseSubscriptionsForPublication(event.publicationId); + + // 3. Group subscriptions by userId while preserving case vs location information + const allSubscriptions = [...locationSubscriptions, ...caseSubscriptions]; + const userSubscriptionsMap = groupSubscriptionsByUser(allSubscriptions); + + if (userSubscriptionsMap.size === 0) { return { totalSubscriptions: 0, sent: 0, @@ -40,10 +54,13 @@ export async function sendPublicationNotifications(event: PublicationEvent): Pro }; } - const results = await Promise.allSettled(subscriptions.map((subscription) => processUserNotification(subscription, event))); + // 5. Process all subscriptions + const results = await Promise.allSettled( + Array.from(userSubscriptionsMap.entries()).map(([userId, subscriptions]) => processUserNotification(subscriptions, event, artefactCases)) + ); const result: NotificationResult = { - totalSubscriptions: subscriptions.length, + totalSubscriptions: userSubscriptionsMap.size, sent: 0, failed: 0, skipped: 0, @@ -79,12 +96,135 @@ export async function sendPublicationNotifications(event: PublicationEvent): Pro return result; } -async function processUserNotification(subscription: SubscriptionWithUser, event: PublicationEvent): Promise { +interface ArtefactCase { + caseNumber: string | null; + caseName: string | null; +} + +async function getCaseSubscriptionsForPublication(publicationId: string): Promise<{ + caseSubscriptions: SubscriptionWithUser[]; + artefactCases: ArtefactCase[]; +}> { + const artefactCases = await findAllArtefactSearchByArtefactId(publicationId); + const caseNumbers = artefactCases.map((ac) => ac.caseNumber).filter((cn): cn is string => cn !== null); + const caseNames = artefactCases.map((ac) => ac.caseName).filter((cn): cn is string => cn !== null); + + const caseNumberSubscriptions = caseNumbers.length > 0 ? await findActiveSubscriptionsByCaseNumbers(caseNumbers) : []; + const caseNameSubscriptions = caseNames.length > 0 ? await findActiveSubscriptionsByCaseNames(caseNames) : []; + + const seenSubscriptionIds = new Set(); + const caseSubscriptions: SubscriptionWithUser[] = []; + + for (const sub of [...caseNumberSubscriptions, ...caseNameSubscriptions]) { + if (!seenSubscriptionIds.has(sub.subscriptionId)) { + seenSubscriptionIds.add(sub.subscriptionId); + caseSubscriptions.push(sub); + } + } + + return { caseSubscriptions, artefactCases }; +} + +function groupSubscriptionsByUser(subscriptions: SubscriptionWithUser[]): Map { + const grouped = new Map(); + + for (const subscription of subscriptions) { + const existing = grouped.get(subscription.userId) || []; + existing.push(subscription); + grouped.set(subscription.userId, existing); + } + + return grouped; +} + +function formatCaseInfo(subscriptions: SubscriptionWithUser[], artefactCases: ArtefactCase[]): string | undefined { + const caseSubscriptions = subscriptions.filter((sub) => sub.searchType === "CASE_NUMBER" || sub.searchType === "CASE_NAME"); + + if (caseSubscriptions.length === 0) { + return undefined; + } + + const artefactByCaseNumber = createArtefactMapByCaseNumber(artefactCases); + const artefactByCaseName = createArtefactMapByCaseName(artefactCases); + + const displayedCases = collectDisplayValuesForSubscriptions(caseSubscriptions, artefactByCaseNumber, artefactByCaseName); + + const caseInfo = Array.from(displayedCases).join(", "); + return caseInfo || undefined; +} + +function createArtefactMapByCaseNumber(artefactCases: ArtefactCase[]): Map { + const map = new Map(); + + for (const artefactCase of artefactCases) { + if (artefactCase.caseNumber) { + map.set(artefactCase.caseNumber.toLowerCase(), artefactCase); + } + } + + return map; +} + +function createArtefactMapByCaseName(artefactCases: ArtefactCase[]): Map { + const map = new Map(); + + for (const artefactCase of artefactCases) { + if (artefactCase.caseName) { + map.set(artefactCase.caseName.toLowerCase(), artefactCase); + } + } + + return map; +} + +function collectDisplayValuesForSubscriptions( + caseSubscriptions: SubscriptionWithUser[], + artefactByCaseNumber: Map, + artefactByCaseName: Map +): Set { + const displayedCases = new Set(); + + for (const sub of caseSubscriptions) { + const searchValue = sub.searchValue; + + const matchedByCaseNumber = artefactByCaseNumber.get(searchValue.toLowerCase()); + if (matchedByCaseNumber) { + if (matchedByCaseNumber.caseNumber) { + displayedCases.add(matchedByCaseNumber.caseNumber); + } + continue; + } + + const matchedByCaseName = artefactByCaseName.get(searchValue.toLowerCase()); + if (matchedByCaseName) { + if (matchedByCaseName.caseName) { + displayedCases.add(matchedByCaseName.caseName); + } + } + } + + return displayedCases; +} + +function hasLocationSubscription(subscriptions: SubscriptionWithUser[]): boolean { + return subscriptions.some((sub) => sub.searchType === "LOCATION_ID"); +} + +async function processUserNotification( + subscriptions: SubscriptionWithUser[], + event: PublicationEvent, + artefactCases: ArtefactCase[] +): Promise { + // Use the first subscription to get user info (all subscriptions have the same user) + const firstSubscription = subscriptions[0]; + const userId = firstSubscription.userId; + const user = firstSubscription.user; + try { - if (!subscription.user.email) { + if (!user.email) { const notification = await createNotificationAuditLog({ - subscriptionId: subscription.subscriptionId, - userId: subscription.userId, + subscriptionId: firstSubscription.subscriptionId, + userId, publicationId: event.publicationId, status: "Skipped" }); @@ -93,14 +233,14 @@ async function processUserNotification(subscription: SubscriptionWithUser, event return { status: "skipped", - error: `User ${subscription.userId}: No email address` + error: `User ${userId}: No email address` }; } - if (!isValidEmail(subscription.user.email)) { + if (!isValidEmail(user.email)) { const notification = await createNotificationAuditLog({ - subscriptionId: subscription.subscriptionId, - userId: subscription.userId, + subscriptionId: firstSubscription.subscriptionId, + userId, publicationId: event.publicationId, status: "Skipped" }); @@ -109,27 +249,32 @@ async function processUserNotification(subscription: SubscriptionWithUser, event return { status: "skipped", - error: `User ${subscription.userId}: Invalid email format` + error: `User ${userId}: Invalid email format` }; } const notification = await createNotificationAuditLog({ - subscriptionId: subscription.subscriptionId, - userId: subscription.userId, + subscriptionId: firstSubscription.subscriptionId, + userId, publicationId: event.publicationId, status: "Pending" }); - const userName = buildUserName(subscription.user.firstName, subscription.user.surname); + const userName = buildUserName(user.firstName, user.surname); + const caseInfo = formatCaseInfo(subscriptions, artefactCases); + const hasLocation = hasLocationSubscription(subscriptions); + const templateParameters = buildTemplateParameters({ userName, hearingListName: event.hearingListName, publicationDate: event.publicationDate, - locationName: event.locationName + locationName: event.locationName, + caseInfo, + hasLocationSubscription: hasLocation }); const emailResult = await sendEmail({ - emailAddress: subscription.user.email, + emailAddress: user.email, templateParameters }); @@ -143,13 +288,13 @@ async function processUserNotification(subscription: SubscriptionWithUser, event await updateNotificationStatus(notification.notificationId, "Failed", undefined, emailResult.error); return { status: "failed", - error: `User ${subscription.userId}: ${emailResult.error}` + error: `User ${userId}: ${emailResult.error}` }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return { status: "failed", - error: `User ${subscription.userId}: ${errorMessage}` + error: `User ${userId}: ${errorMessage}` }; } } diff --git a/libs/notifications/src/notification/subscription-queries.test.ts b/libs/notifications/src/notification/subscription-queries.test.ts deleted file mode 100644 index b43186b92..000000000 --- a/libs/notifications/src/notification/subscription-queries.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { findActiveSubscriptionsByLocation } from "./subscription-queries.js"; - -vi.mock("@hmcts/postgres", () => ({ - prisma: { - subscription: { - findMany: vi.fn() - } - } -})); - -describe("subscription-queries", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should find subscriptions by location", async () => { - const mockSubscriptions = [ - { - subscriptionId: "sub-1", - userId: "user-1", - locationId: 1, - user: { - email: "user1@example.com", - firstName: "John", - surname: "Doe" - } - }, - { - subscriptionId: "sub-2", - userId: "user-2", - locationId: 1, - user: { - email: "user2@example.com", - firstName: "Jane", - surname: "Smith" - } - } - ]; - - const { prisma } = await import("@hmcts/postgres"); - vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions as never); - - const result = await findActiveSubscriptionsByLocation(1); - - expect(result).toHaveLength(2); - expect(result[0].user.email).toBe("user1@example.com"); - expect(prisma.subscription.findMany).toHaveBeenCalledWith({ - where: { locationId: 1 }, - include: { - user: { - select: { - email: true, - firstName: true, - surname: true - } - } - } - }); - }); -}); diff --git a/libs/notifications/src/notification/subscription-queries.ts b/libs/notifications/src/notification/subscription-queries.ts deleted file mode 100644 index fd7e47f42..000000000 --- a/libs/notifications/src/notification/subscription-queries.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { prisma } from "@hmcts/postgres"; - -export interface SubscriptionWithUser { - subscriptionId: string; - userId: string; - locationId: number; - user: { - email: string; - firstName: string | null; - surname: string | null; - }; -} - -export async function findActiveSubscriptionsByLocation(locationId: number): Promise { - const subscriptions = await prisma.subscription.findMany({ - where: { - locationId - }, - include: { - user: { - select: { - email: true, - firstName: true, - surname: true - } - } - } - }); - - return subscriptions; -} diff --git a/libs/publication/src/artefact-search-extractor.test.ts b/libs/publication/src/artefact-search-extractor.test.ts new file mode 100644 index 000000000..4e6a3e5a7 --- /dev/null +++ b/libs/publication/src/artefact-search-extractor.test.ts @@ -0,0 +1,460 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { extractAndStoreArtefactSearch } from "./artefact-search-extractor.js"; + +vi.mock("@hmcts/list-search-config", () => ({ + getConfigForListType: vi.fn() +})); + +vi.mock("./repository/queries.js", () => ({ + createArtefactSearch: vi.fn(), + deleteArtefactSearchByArtefactId: vi.fn() +})); + +const { getConfigForListType } = await import("@hmcts/list-search-config"); +const { createArtefactSearch, deleteArtefactSearchByArtefactId } = await import("./repository/queries.js"); + +describe("extractAndStoreArtefactSearch", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should extract and store case data from valid payload", async () => { + const mockConfig = { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }; + + const mockPayload = { + caseNumber: "CASE-123", + caseName: "Smith vs Jones" + }; + + vi.mocked(getConfigForListType).mockResolvedValue(mockConfig as any); + vi.mocked(deleteArtefactSearchByArtefactId).mockResolvedValue({} as any); + vi.mocked(createArtefactSearch).mockResolvedValue({} as any); + + await extractAndStoreArtefactSearch("artefact-1", 1, mockPayload); + + expect(deleteArtefactSearchByArtefactId).toHaveBeenCalledWith("artefact-1"); + expect(createArtefactSearch).toHaveBeenCalledWith("artefact-1", "CASE-123", "Smith vs Jones"); + }); + + it("should extract only case number when case name is missing", async () => { + const mockConfig = { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }; + + const mockPayload = { + caseNumber: "CASE-456" + }; + + vi.mocked(getConfigForListType).mockResolvedValue(mockConfig as any); + vi.mocked(deleteArtefactSearchByArtefactId).mockResolvedValue({} as any); + vi.mocked(createArtefactSearch).mockResolvedValue({} as any); + + await extractAndStoreArtefactSearch("artefact-2", 1, mockPayload); + + expect(deleteArtefactSearchByArtefactId).toHaveBeenCalledWith("artefact-2"); + expect(createArtefactSearch).toHaveBeenCalledWith("artefact-2", "CASE-456", null); + }); + + it("should extract only case name when case number is missing", async () => { + const mockConfig = { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }; + + const mockPayload = { + caseName: "Jones vs Smith" + }; + + vi.mocked(getConfigForListType).mockResolvedValue(mockConfig as any); + vi.mocked(deleteArtefactSearchByArtefactId).mockResolvedValue({} as any); + vi.mocked(createArtefactSearch).mockResolvedValue({} as any); + + await extractAndStoreArtefactSearch("artefact-3", 1, mockPayload); + + expect(deleteArtefactSearchByArtefactId).toHaveBeenCalledWith("artefact-3"); + expect(createArtefactSearch).toHaveBeenCalledWith("artefact-3", null, "Jones vs Smith"); + }); + + it("should not store when no case data is present", async () => { + const mockConfig = { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }; + + const mockPayload = { + otherField: "value" + }; + + vi.mocked(getConfigForListType).mockResolvedValue(mockConfig as any); + + await extractAndStoreArtefactSearch("artefact-4", 1, mockPayload); + + expect(createArtefactSearch).not.toHaveBeenCalled(); + }); + + it("should not store when both case fields are non-string types", async () => { + const mockConfig = { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }; + + const mockPayload = { + caseNumber: 123, + caseName: { name: "test" } + }; + + vi.mocked(getConfigForListType).mockResolvedValue(mockConfig as any); + + await extractAndStoreArtefactSearch("artefact-5", 1, mockPayload); + + expect(createArtefactSearch).not.toHaveBeenCalled(); + }); + + it("should not process when config is not found", async () => { + vi.mocked(getConfigForListType).mockResolvedValue(null); + + await extractAndStoreArtefactSearch("artefact-6", 1, { caseNumber: "CASE-123" }); + + expect(createArtefactSearch).not.toHaveBeenCalled(); + }); + + it("should not process when payload is null", async () => { + const mockConfig = { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }; + + vi.mocked(getConfigForListType).mockResolvedValue(mockConfig as any); + + await extractAndStoreArtefactSearch("artefact-7", 1, null); + + expect(createArtefactSearch).not.toHaveBeenCalled(); + }); + + it("should handle array of cases at root level", async () => { + const mockConfig = { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }; + + const mockPayload = [ + { caseNumber: "CASE-001", caseName: "Case One" }, + { caseNumber: "CASE-002", caseName: "Case Two" } + ]; + + vi.mocked(getConfigForListType).mockResolvedValue(mockConfig as any); + vi.mocked(deleteArtefactSearchByArtefactId).mockResolvedValue({} as any); + vi.mocked(createArtefactSearch).mockResolvedValue({} as any); + + await extractAndStoreArtefactSearch("artefact-8", 1, mockPayload); + + expect(deleteArtefactSearchByArtefactId).toHaveBeenCalledWith("artefact-8"); + expect(createArtefactSearch).toHaveBeenCalledTimes(2); + expect(createArtefactSearch).toHaveBeenNthCalledWith(1, "artefact-8", "CASE-001", "Case One"); + expect(createArtefactSearch).toHaveBeenNthCalledWith(2, "artefact-8", "CASE-002", "Case Two"); + }); + + it("should handle empty array at root level", async () => { + const mockConfig = { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }; + + vi.mocked(getConfigForListType).mockResolvedValue(mockConfig as any); + + await extractAndStoreArtefactSearch("artefact-8b", 1, []); + + expect(deleteArtefactSearchByArtefactId).not.toHaveBeenCalled(); + expect(createArtefactSearch).not.toHaveBeenCalled(); + }); + + it("should not process when payload is not an object", async () => { + const mockConfig = { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }; + + vi.mocked(getConfigForListType).mockResolvedValue(mockConfig as any); + + await extractAndStoreArtefactSearch("artefact-9", 1, "string"); + + expect(createArtefactSearch).not.toHaveBeenCalled(); + }); + + it("should handle errors during extraction and storage", async () => { + const mockConfig = { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }; + + const mockPayload = { + caseNumber: "CASE-123", + caseName: "Test Case" + }; + + const error = new Error("Database error"); + + vi.mocked(getConfigForListType).mockResolvedValue(mockConfig as any); + vi.mocked(createArtefactSearch).mockRejectedValue(error); + + await extractAndStoreArtefactSearch("artefact-10", 1, mockPayload); + + expect(console.error).toHaveBeenCalledWith("[ArtefactSearch] Failed to extract/store for artefact artefact-10:", error); + }); + + it("should handle errors during config retrieval", async () => { + const error = new Error("Config retrieval failed"); + + vi.mocked(getConfigForListType).mockRejectedValue(error); + + await extractAndStoreArtefactSearch("artefact-11", 1, { caseNumber: "CASE-123" }); + + expect(console.error).toHaveBeenCalledWith("[ArtefactSearch] Failed to extract/store for artefact artefact-11:", error); + }); + + it("should handle mixed string and non-string values correctly", async () => { + const mockConfig = { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }; + + const mockPayload = { + caseNumber: "CASE-789", + caseName: 12345 + }; + + vi.mocked(getConfigForListType).mockResolvedValue(mockConfig as any); + vi.mocked(deleteArtefactSearchByArtefactId).mockResolvedValue({} as any); + vi.mocked(createArtefactSearch).mockResolvedValue({} as any); + + await extractAndStoreArtefactSearch("artefact-12", 1, mockPayload); + + expect(deleteArtefactSearchByArtefactId).toHaveBeenCalledWith("artefact-12"); + expect(createArtefactSearch).toHaveBeenCalledWith("artefact-12", "CASE-789", null); + }); + + it("should extract multiple cases from nested structure by finding field names", async () => { + const mockConfig = { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }; + + const mockPayload = { + courtLists: [ + { + courtHouse: { + courtRoom: [ + { + session: [ + { + sittings: [ + { + hearing: [ + { + case: [ + { caseNumber: "CASE-001", caseName: "Smith vs Jones" }, + { caseNumber: "CASE-002", caseName: "Brown vs Green" } + ] + } + ] + } + ] + } + ] + } + ] + } + } + ] + }; + + vi.mocked(getConfigForListType).mockResolvedValue(mockConfig as any); + vi.mocked(deleteArtefactSearchByArtefactId).mockResolvedValue({} as any); + vi.mocked(createArtefactSearch).mockResolvedValue({} as any); + + await extractAndStoreArtefactSearch("artefact-13", 8, mockPayload); + + expect(deleteArtefactSearchByArtefactId).toHaveBeenCalledWith("artefact-13"); + expect(createArtefactSearch).toHaveBeenCalledTimes(2); + expect(createArtefactSearch).toHaveBeenNthCalledWith(1, "artefact-13", "CASE-001", "Smith vs Jones"); + expect(createArtefactSearch).toHaveBeenNthCalledWith(2, "artefact-13", "CASE-002", "Brown vs Green"); + }); + + it("should extract cases from deeply nested structure with multiple arrays", async () => { + const mockConfig = { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }; + + const mockPayload = { + courtLists: [ + { + courtHouse: { + courtRoom: [ + { + session: [ + { + sittings: [ + { hearing: [{ case: [{ caseNumber: "CASE-001", caseName: "Case One" }] }] }, + { hearing: [{ case: [{ caseNumber: "CASE-002", caseName: "Case Two" }] }] } + ] + } + ] + }, + { + session: [ + { + sittings: [{ hearing: [{ case: [{ caseNumber: "CASE-003", caseName: "Case Three" }] }] }] + } + ] + } + ] + } + }, + { + courtHouse: { + courtRoom: [ + { + session: [ + { + sittings: [{ hearing: [{ case: [{ caseNumber: "CASE-004", caseName: "Case Four" }] }] }] + } + ] + } + ] + } + } + ] + }; + + vi.mocked(getConfigForListType).mockResolvedValue(mockConfig as any); + vi.mocked(deleteArtefactSearchByArtefactId).mockResolvedValue({} as any); + vi.mocked(createArtefactSearch).mockResolvedValue({} as any); + + await extractAndStoreArtefactSearch("artefact-14", 8, mockPayload); + + expect(deleteArtefactSearchByArtefactId).toHaveBeenCalledWith("artefact-14"); + expect(createArtefactSearch).toHaveBeenCalledTimes(4); + expect(createArtefactSearch).toHaveBeenNthCalledWith(1, "artefact-14", "CASE-001", "Case One"); + expect(createArtefactSearch).toHaveBeenNthCalledWith(2, "artefact-14", "CASE-002", "Case Two"); + expect(createArtefactSearch).toHaveBeenNthCalledWith(3, "artefact-14", "CASE-003", "Case Three"); + expect(createArtefactSearch).toHaveBeenNthCalledWith(4, "artefact-14", "CASE-004", "Case Four"); + }); + + it("should handle nested structure with missing case data", async () => { + const mockConfig = { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }; + + const mockPayload = { + courtLists: [] + }; + + vi.mocked(getConfigForListType).mockResolvedValue(mockConfig as any); + + await extractAndStoreArtefactSearch("artefact-15", 8, mockPayload); + + expect(deleteArtefactSearchByArtefactId).not.toHaveBeenCalled(); + expect(createArtefactSearch).not.toHaveBeenCalled(); + }); + + it("should handle structure without matching field names", async () => { + const mockConfig = { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "caseName" + }; + + const mockPayload = { + courtLists: [ + { + courtHouse: { + courtRoom: [ + { + someOtherField: "value" + } + ] + } + } + ] + }; + + vi.mocked(getConfigForListType).mockResolvedValue(mockConfig as any); + + await extractAndStoreArtefactSearch("artefact-16", 8, mockPayload); + + expect(deleteArtefactSearchByArtefactId).not.toHaveBeenCalled(); + expect(createArtefactSearch).not.toHaveBeenCalled(); + }); + + it("should handle blank configuration fields", async () => { + const mockConfig = { + caseNumberFieldName: "", + caseNameFieldName: "" + }; + + const mockPayload = { + caseNumber: "CASE-123", + caseName: "Test Case" + }; + + vi.mocked(getConfigForListType).mockResolvedValue(mockConfig as any); + + await extractAndStoreArtefactSearch("artefact-17", 1, mockPayload); + + expect(deleteArtefactSearchByArtefactId).not.toHaveBeenCalled(); + expect(createArtefactSearch).not.toHaveBeenCalled(); + }); + + it("should extract when only case number field is configured", async () => { + const mockConfig = { + caseNumberFieldName: "caseNumber", + caseNameFieldName: "" + }; + + const mockPayload = { + caseNumber: "CASE-123", + caseName: "Test Case" + }; + + vi.mocked(getConfigForListType).mockResolvedValue(mockConfig as any); + vi.mocked(deleteArtefactSearchByArtefactId).mockResolvedValue({} as any); + vi.mocked(createArtefactSearch).mockResolvedValue({} as any); + + await extractAndStoreArtefactSearch("artefact-18", 1, mockPayload); + + expect(deleteArtefactSearchByArtefactId).toHaveBeenCalledWith("artefact-18"); + expect(createArtefactSearch).toHaveBeenCalledWith("artefact-18", "CASE-123", null); + }); + + it("should extract when only case name field is configured", async () => { + const mockConfig = { + caseNumberFieldName: "", + caseNameFieldName: "caseName" + }; + + const mockPayload = { + caseNumber: "CASE-123", + caseName: "Test Case" + }; + + vi.mocked(getConfigForListType).mockResolvedValue(mockConfig as any); + vi.mocked(deleteArtefactSearchByArtefactId).mockResolvedValue({} as any); + vi.mocked(createArtefactSearch).mockResolvedValue({} as any); + + await extractAndStoreArtefactSearch("artefact-19", 1, mockPayload); + + expect(deleteArtefactSearchByArtefactId).toHaveBeenCalledWith("artefact-19"); + expect(createArtefactSearch).toHaveBeenCalledWith("artefact-19", null, "Test Case"); + }); +}); diff --git a/libs/publication/src/artefact-search-extractor.ts b/libs/publication/src/artefact-search-extractor.ts new file mode 100644 index 000000000..d46215f58 --- /dev/null +++ b/libs/publication/src/artefact-search-extractor.ts @@ -0,0 +1,165 @@ +import { getConfigForListType } from "@hmcts/list-search-config"; +import * as repository from "./repository/queries.js"; + +interface CaseData { + caseNumber: string | null; + caseName: string | null; +} + +interface CaseObject { + caseNumber?: unknown; + caseName?: unknown; + [key: string]: unknown; +} + +/** + * Recursively searches for objects containing the specified field names. + * Returns all objects that contain at least one of the target fields. + * + * Example: Given fieldNames ["caseNumber", "caseName"], finds all objects + * in the JSON structure that have either or both fields. + */ +function findObjectsWithFields(data: unknown, fieldNames: string[]): CaseObject[] { + const results: CaseObject[] = []; + + function traverse(current: unknown): void { + if (!current || typeof current !== "object") { + return; + } + + if (Array.isArray(current)) { + for (const item of current) { + traverse(item); + } + return; + } + + const obj = current as Record; + + // Check if this object has any of the target fields + const hasTargetField = fieldNames.some((fieldName) => fieldName in obj); + + if (hasTargetField) { + results.push(obj as CaseObject); + } + + // Continue traversing nested objects and arrays + for (const value of Object.values(obj)) { + traverse(value); + } + } + + traverse(data); + return results; +} + +/** + * Extracts case data from a single object by reading the configured field names. + * Returns null if no valid case data is found. + */ +function extractCaseDataFromObject( + obj: Record, + caseNumberFieldName: string, + caseNameFieldName: string, + hasCaseNumberField: boolean, + hasCaseNameField: boolean +): CaseData | null { + const caseNumberValue = hasCaseNumberField ? obj[caseNumberFieldName] : null; + const caseNameValue = hasCaseNameField ? obj[caseNameFieldName] : null; + + const caseNumber = typeof caseNumberValue === "string" ? caseNumberValue : null; + const caseName = typeof caseNameValue === "string" ? caseNameValue : null; + + if (caseNumber || caseName) { + return { caseNumber, caseName }; + } + + return null; +} + +/** + * Attempts to extract case data from the root level of a flat object structure. + * Returns an array with a single case if found, otherwise returns null. + */ +function extractFromRootLevel( + jsonPayload: unknown, + caseNumberFieldName: string, + caseNameFieldName: string, + hasCaseNumberField: boolean, + hasCaseNameField: boolean +): CaseData[] | null { + if (Array.isArray(jsonPayload)) { + return null; + } + + const rootObj = jsonPayload as Record; + const hasRootFields = (hasCaseNumberField && caseNumberFieldName in rootObj) || (hasCaseNameField && caseNameFieldName in rootObj); + + if (!hasRootFields) { + return null; + } + + const caseData = extractCaseDataFromObject(rootObj, caseNumberFieldName, caseNameFieldName, hasCaseNumberField, hasCaseNameField); + return caseData ? [caseData] : null; +} + +/** + * Extracts all case data from a JSON payload by searching for objects + * containing the configured field names anywhere in the structure. + * Returns an array of case data objects. + */ +function extractCases(jsonPayload: unknown, caseNumberFieldName: string, caseNameFieldName: string): CaseData[] { + const hasCaseNumberField = Boolean(caseNumberFieldName && caseNumberFieldName.trim() !== ""); + const hasCaseNameField = Boolean(caseNameFieldName && caseNameFieldName.trim() !== ""); + + if (!jsonPayload || (!hasCaseNumberField && !hasCaseNameField)) { + return []; + } + + // First, check if the fields exist at the root level (flat structure) + const rootLevelCases = extractFromRootLevel(jsonPayload, caseNumberFieldName, caseNameFieldName, hasCaseNumberField, hasCaseNameField); + if (rootLevelCases) { + return rootLevelCases; + } + + // Build list of field names to search for + const fieldNamesToSearch: string[] = []; + if (hasCaseNumberField) fieldNamesToSearch.push(caseNumberFieldName); + if (hasCaseNameField) fieldNamesToSearch.push(caseNameFieldName); + + // Search for objects containing these field names anywhere in the structure + const caseObjects = findObjectsWithFields(jsonPayload, fieldNamesToSearch); + + const cases: CaseData[] = []; + for (const obj of caseObjects) { + const caseData = extractCaseDataFromObject(obj, caseNumberFieldName, caseNameFieldName, hasCaseNumberField, hasCaseNameField); + if (caseData) { + cases.push(caseData); + } + } + + return cases; +} + +export async function extractAndStoreArtefactSearch(artefactId: string, listTypeId: number, jsonPayload: unknown): Promise { + try { + const config = await getConfigForListType(listTypeId); + + if (config && jsonPayload) { + // Extract all cases from the payload (handles both objects and arrays) + const cases = extractCases(jsonPayload, config.caseNumberFieldName, config.caseNameFieldName); + + if (cases.length > 0) { + // Delete existing entries for this artefact to ensure idempotency + await repository.deleteArtefactSearchByArtefactId(artefactId); + + // Create new entries for all cases + for (const caseData of cases) { + await repository.createArtefactSearch(artefactId, caseData.caseNumber, caseData.caseName); + } + } + } + } catch (error) { + console.error(`[ArtefactSearch] Failed to extract/store for artefact ${artefactId}:`, error); + } +} diff --git a/libs/publication/src/index.ts b/libs/publication/src/index.ts index 233caeb4c..6e88bf885 100644 --- a/libs/publication/src/index.ts +++ b/libs/publication/src/index.ts @@ -1,4 +1,5 @@ export { type ListType, mockListTypes } from "@hmcts/list-types-common"; +export { extractAndStoreArtefactSearch } from "./artefact-search-extractor.js"; export { requirePublicationAccess, requirePublicationDataAccess } from "./authorisation/middleware.js"; export { canAccessPublication, @@ -17,7 +18,12 @@ export { type ArtefactMetadata, type ArtefactSummary, createArtefact, + createArtefactSearch, deleteArtefacts, + findAllArtefactSearchByArtefactId, + findArtefactSearchByArtefactId, + findByCaseName, + findByCaseNumber, getArtefactById, getArtefactListTypeId, getArtefactMetadata, @@ -30,4 +36,7 @@ export { } from "./repository/queries.js"; export { getFlatFileUrl, getJsonContent, getRenderedTemplateUrl } from "./repository/service.js"; export { Sensitivity } from "./sensitivity.js"; -export { type ValidationResult, validateJson } from "./validation/json-validator.js"; +export { + type ValidationResult, + validateJson +} from "./validation/json-validator.js"; diff --git a/libs/publication/src/repository/artefact-search-queries.test.ts b/libs/publication/src/repository/artefact-search-queries.test.ts new file mode 100644 index 000000000..217d3f0bb --- /dev/null +++ b/libs/publication/src/repository/artefact-search-queries.test.ts @@ -0,0 +1,365 @@ +import { prisma } from "@hmcts/postgres"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createArtefactSearch, + deleteArtefactSearchByArtefactId, + findAllArtefactSearchByArtefactId, + findArtefactSearchByArtefactId, + findByCaseName, + findByCaseNumber +} from "./artefact-search-queries.js"; + +vi.mock("@hmcts/postgres", () => ({ + prisma: { + artefactSearch: { + create: vi.fn(), + findFirst: vi.fn(), + findMany: vi.fn(), + deleteMany: vi.fn() + } + } +})); + +describe("artefact-search-queries", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("createArtefactSearch", () => { + it("should create artefact search with all fields", async () => { + const mockArtefactSearch = { + id: "search-1", + artefactId: "art-123", + caseNumber: "CASE456", + caseName: "Test Case", + createdAt: new Date() + }; + + vi.mocked(prisma.artefactSearch.create).mockResolvedValue(mockArtefactSearch); + + const result = await createArtefactSearch("art-123", "CASE456", "Test Case"); + + expect(prisma.artefactSearch.create).toHaveBeenCalledWith({ + data: { + artefactId: "art-123", + caseNumber: "CASE456", + caseName: "Test Case" + } + }); + expect(result).toEqual(mockArtefactSearch); + }); + + it("should create artefact search with null case number", async () => { + const mockArtefactSearch = { + id: "search-1", + artefactId: "art-123", + caseNumber: null, + caseName: "Test Case", + createdAt: new Date() + }; + + vi.mocked(prisma.artefactSearch.create).mockResolvedValue(mockArtefactSearch); + + const result = await createArtefactSearch("art-123", null, "Test Case"); + + expect(prisma.artefactSearch.create).toHaveBeenCalledWith({ + data: { + artefactId: "art-123", + caseNumber: null, + caseName: "Test Case" + } + }); + expect(result).toEqual(mockArtefactSearch); + }); + + it("should create artefact search with null case name", async () => { + const mockArtefactSearch = { + id: "search-1", + artefactId: "art-123", + caseNumber: "CASE456", + caseName: null, + createdAt: new Date() + }; + + vi.mocked(prisma.artefactSearch.create).mockResolvedValue(mockArtefactSearch); + + const result = await createArtefactSearch("art-123", "CASE456", null); + + expect(prisma.artefactSearch.create).toHaveBeenCalledWith({ + data: { + artefactId: "art-123", + caseNumber: "CASE456", + caseName: null + } + }); + expect(result).toEqual(mockArtefactSearch); + }); + + it("should create artefact search with both case fields null", async () => { + const mockArtefactSearch = { + id: "search-1", + artefactId: "art-123", + caseNumber: null, + caseName: null, + createdAt: new Date() + }; + + vi.mocked(prisma.artefactSearch.create).mockResolvedValue(mockArtefactSearch); + + const result = await createArtefactSearch("art-123", null, null); + + expect(prisma.artefactSearch.create).toHaveBeenCalledWith({ + data: { + artefactId: "art-123", + caseNumber: null, + caseName: null + } + }); + expect(result).toEqual(mockArtefactSearch); + }); + }); + + describe("findArtefactSearchByArtefactId", () => { + it("should find first artefact search by artefact ID", async () => { + const mockArtefactSearch = { + id: "search-1", + artefactId: "art-123", + caseNumber: "CASE456", + caseName: "Test Case", + createdAt: new Date() + }; + + vi.mocked(prisma.artefactSearch.findFirst).mockResolvedValue(mockArtefactSearch); + + const result = await findArtefactSearchByArtefactId("art-123"); + + expect(prisma.artefactSearch.findFirst).toHaveBeenCalledWith({ + where: { artefactId: "art-123" } + }); + expect(result).toEqual(mockArtefactSearch); + }); + + it("should return null when artefact search not found", async () => { + vi.mocked(prisma.artefactSearch.findFirst).mockResolvedValue(null); + + const result = await findArtefactSearchByArtefactId("non-existent"); + + expect(result).toBeNull(); + }); + }); + + describe("findAllArtefactSearchByArtefactId", () => { + it("should find all artefact searches by artefact ID", async () => { + const mockArtefactSearches = [ + { + id: "search-1", + artefactId: "art-123", + caseNumber: "CASE456", + caseName: "Test Case 1", + createdAt: new Date() + }, + { + id: "search-2", + artefactId: "art-123", + caseNumber: "CASE789", + caseName: "Test Case 2", + createdAt: new Date() + } + ]; + + vi.mocked(prisma.artefactSearch.findMany).mockResolvedValue(mockArtefactSearches); + + const result = await findAllArtefactSearchByArtefactId("art-123"); + + expect(prisma.artefactSearch.findMany).toHaveBeenCalledWith({ + where: { artefactId: "art-123" } + }); + expect(result).toEqual(mockArtefactSearches); + }); + + it("should return empty array when no artefact searches found", async () => { + vi.mocked(prisma.artefactSearch.findMany).mockResolvedValue([]); + + const result = await findAllArtefactSearchByArtefactId("non-existent"); + + expect(result).toEqual([]); + }); + + it("should find single artefact search in array", async () => { + const mockArtefactSearches = [ + { + id: "search-1", + artefactId: "art-123", + caseNumber: "CASE456", + caseName: "Test Case", + createdAt: new Date() + } + ]; + + vi.mocked(prisma.artefactSearch.findMany).mockResolvedValue(mockArtefactSearches); + + const result = await findAllArtefactSearchByArtefactId("art-123"); + + expect(result).toHaveLength(1); + expect(result[0].artefactId).toBe("art-123"); + }); + }); + + describe("deleteArtefactSearchByArtefactId", () => { + it("should delete all artefact searches by artefact ID", async () => { + const mockDeleteResult = { count: 2 }; + + vi.mocked(prisma.artefactSearch.deleteMany).mockResolvedValue(mockDeleteResult); + + const result = await deleteArtefactSearchByArtefactId("art-123"); + + expect(prisma.artefactSearch.deleteMany).toHaveBeenCalledWith({ + where: { artefactId: "art-123" } + }); + expect(result).toEqual(mockDeleteResult); + }); + + it("should return zero count when no artefact searches found to delete", async () => { + const mockDeleteResult = { count: 0 }; + + vi.mocked(prisma.artefactSearch.deleteMany).mockResolvedValue(mockDeleteResult); + + const result = await deleteArtefactSearchByArtefactId("non-existent"); + + expect(result.count).toBe(0); + }); + + it("should delete single artefact search", async () => { + const mockDeleteResult = { count: 1 }; + + vi.mocked(prisma.artefactSearch.deleteMany).mockResolvedValue(mockDeleteResult); + + const result = await deleteArtefactSearchByArtefactId("art-123"); + + expect(result.count).toBe(1); + }); + }); + + describe("findByCaseName", () => { + it("should search for case name with case-insensitive partial match", async () => { + const mockResults = [ + { + id: "1", + artefactId: "art-1", + caseNumber: "CASE123", + caseName: "Test Case Name", + createdAt: new Date("2024-01-01") + } + ]; + + vi.mocked(prisma.artefactSearch.findMany).mockResolvedValue(mockResults); + + const result = await findByCaseName("Test"); + + expect(prisma.artefactSearch.findMany).toHaveBeenCalledWith({ + where: { + caseName: { + contains: "Test", + mode: "insensitive" + } + }, + take: 50, + orderBy: { + createdAt: "desc" + } + }); + expect(result).toEqual(mockResults); + }); + + it("should limit results to 50", async () => { + vi.mocked(prisma.artefactSearch.findMany).mockResolvedValue([]); + + await findByCaseName("Common"); + + expect(prisma.artefactSearch.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: 50 + }) + ); + }); + + it("should order results by createdAt descending", async () => { + vi.mocked(prisma.artefactSearch.findMany).mockResolvedValue([]); + + await findByCaseName("Test"); + + expect(prisma.artefactSearch.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { + createdAt: "desc" + } + }) + ); + }); + }); + + describe("findByCaseNumber", () => { + it("should search for exact case number match", async () => { + const mockResults = [ + { + id: "1", + artefactId: "art-1", + caseNumber: "CASE123", + caseName: "Test Case", + createdAt: new Date("2024-01-01") + } + ]; + + vi.mocked(prisma.artefactSearch.findMany).mockResolvedValue(mockResults); + + const result = await findByCaseNumber("CASE123"); + + expect(prisma.artefactSearch.findMany).toHaveBeenCalledWith({ + where: { + caseNumber: { + equals: "CASE123" + } + }, + take: 50, + orderBy: { + createdAt: "desc" + } + }); + expect(result).toEqual(mockResults); + }); + + it("should return empty array when no match found", async () => { + vi.mocked(prisma.artefactSearch.findMany).mockResolvedValue([]); + + const result = await findByCaseNumber("NONEXISTENT"); + + expect(result).toEqual([]); + }); + + it("should limit results to 50", async () => { + vi.mocked(prisma.artefactSearch.findMany).mockResolvedValue([]); + + await findByCaseNumber("CASE123"); + + expect(prisma.artefactSearch.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + take: 50 + }) + ); + }); + + it("should order results by createdAt descending", async () => { + vi.mocked(prisma.artefactSearch.findMany).mockResolvedValue([]); + + await findByCaseNumber("CASE123"); + + expect(prisma.artefactSearch.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { + createdAt: "desc" + } + }) + ); + }); + }); +}); diff --git a/libs/publication/src/repository/artefact-search-queries.ts b/libs/publication/src/repository/artefact-search-queries.ts new file mode 100644 index 000000000..368d4fa26 --- /dev/null +++ b/libs/publication/src/repository/artefact-search-queries.ts @@ -0,0 +1,58 @@ +import { prisma } from "@hmcts/postgres"; + +export async function createArtefactSearch(artefactId: string, caseNumber: string | null, caseName: string | null) { + return await prisma.artefactSearch.create({ + data: { + artefactId, + caseNumber, + caseName + } + }); +} + +export async function findArtefactSearchByArtefactId(artefactId: string) { + return await prisma.artefactSearch.findFirst({ + where: { artefactId } + }); +} + +export async function findAllArtefactSearchByArtefactId(artefactId: string) { + return await prisma.artefactSearch.findMany({ + where: { artefactId } + }); +} + +export async function deleteArtefactSearchByArtefactId(artefactId: string) { + return await prisma.artefactSearch.deleteMany({ + where: { artefactId } + }); +} + +export async function findByCaseName(caseName: string) { + return await prisma.artefactSearch.findMany({ + where: { + caseName: { + contains: caseName, + mode: "insensitive" + } + }, + take: 50, + orderBy: { + createdAt: "desc" + } + }); +} + +export async function findByCaseNumber(caseNumber: string) { + return await prisma.artefactSearch.findMany({ + where: { + caseNumber: { + equals: caseNumber + } + }, + take: 50, + orderBy: { + createdAt: "desc" + } + }); +} diff --git a/libs/publication/src/repository/queries.test.ts b/libs/publication/src/repository/queries.test.ts index 91d3059dd..1de81c419 100644 --- a/libs/publication/src/repository/queries.test.ts +++ b/libs/publication/src/repository/queries.test.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createArtefact, + createArtefactSearch, + deleteArtefactSearchByArtefactId, deleteArtefacts, + findArtefactSearchByArtefactId, getArtefactById, getArtefactListTypeId, getArtefactMetadata, @@ -22,6 +25,11 @@ vi.mock("@hmcts/postgres", () => ({ findMany: vi.fn(), deleteMany: vi.fn() }, + artefactSearch: { + create: vi.fn(), + findFirst: vi.fn(), + deleteMany: vi.fn() + }, $queryRaw: vi.fn() } })); @@ -1125,3 +1133,204 @@ describe("getArtefactListTypeId", () => { expect(result).toBe(5); }); }); + +describe("createArtefactSearch", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should create artefact search with all fields", async () => { + const mockArtefactSearch = { + artefactSearchId: "search-id-1", + artefactId: "artefact-123", + caseNumber: "CASE-456", + caseName: "Smith vs Jones" + }; + + vi.mocked(prisma.artefactSearch.create).mockResolvedValue(mockArtefactSearch as any); + + const result = await createArtefactSearch("artefact-123", "CASE-456", "Smith vs Jones"); + + expect(result).toEqual(mockArtefactSearch); + expect(prisma.artefactSearch.create).toHaveBeenCalledWith({ + data: { + artefactId: "artefact-123", + caseNumber: "CASE-456", + caseName: "Smith vs Jones" + } + }); + }); + + it("should create artefact search with null case number", async () => { + const mockArtefactSearch = { + artefactSearchId: "search-id-2", + artefactId: "artefact-456", + caseNumber: null, + caseName: "Jones vs Smith" + }; + + vi.mocked(prisma.artefactSearch.create).mockResolvedValue(mockArtefactSearch as any); + + const result = await createArtefactSearch("artefact-456", null, "Jones vs Smith"); + + expect(result).toEqual(mockArtefactSearch); + expect(prisma.artefactSearch.create).toHaveBeenCalledWith({ + data: { + artefactId: "artefact-456", + caseNumber: null, + caseName: "Jones vs Smith" + } + }); + }); + + it("should create artefact search with null case name", async () => { + const mockArtefactSearch = { + artefactSearchId: "search-id-3", + artefactId: "artefact-789", + caseNumber: "CASE-789", + caseName: null + }; + + vi.mocked(prisma.artefactSearch.create).mockResolvedValue(mockArtefactSearch as any); + + const result = await createArtefactSearch("artefact-789", "CASE-789", null); + + expect(result).toEqual(mockArtefactSearch); + expect(prisma.artefactSearch.create).toHaveBeenCalledWith({ + data: { + artefactId: "artefact-789", + caseNumber: "CASE-789", + caseName: null + } + }); + }); + + it("should create artefact search with both fields null", async () => { + const mockArtefactSearch = { + artefactSearchId: "search-id-4", + artefactId: "artefact-000", + caseNumber: null, + caseName: null + }; + + vi.mocked(prisma.artefactSearch.create).mockResolvedValue(mockArtefactSearch as any); + + const result = await createArtefactSearch("artefact-000", null, null); + + expect(result).toEqual(mockArtefactSearch); + expect(prisma.artefactSearch.create).toHaveBeenCalledWith({ + data: { + artefactId: "artefact-000", + caseNumber: null, + caseName: null + } + }); + }); +}); + +describe("findArtefactSearchByArtefactId", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should find artefact search by artefact id", async () => { + const mockArtefactSearch = { + artefactSearchId: "search-id-1", + artefactId: "artefact-123", + caseNumber: "CASE-456", + caseName: "Smith vs Jones" + }; + + vi.mocked(prisma.artefactSearch.findFirst).mockResolvedValue(mockArtefactSearch as any); + + const result = await findArtefactSearchByArtefactId("artefact-123"); + + expect(result).toEqual(mockArtefactSearch); + expect(prisma.artefactSearch.findFirst).toHaveBeenCalledWith({ + where: { artefactId: "artefact-123" } + }); + }); + + it("should return null when artefact search not found", async () => { + vi.mocked(prisma.artefactSearch.findFirst).mockResolvedValue(null); + + const result = await findArtefactSearchByArtefactId("non-existent-artefact"); + + expect(result).toBeNull(); + expect(prisma.artefactSearch.findFirst).toHaveBeenCalledWith({ + where: { artefactId: "non-existent-artefact" } + }); + }); + + it("should find artefact search with null fields", async () => { + const mockArtefactSearch = { + artefactSearchId: "search-id-2", + artefactId: "artefact-456", + caseNumber: null, + caseName: null + }; + + vi.mocked(prisma.artefactSearch.findFirst).mockResolvedValue(mockArtefactSearch as any); + + const result = await findArtefactSearchByArtefactId("artefact-456"); + + expect(result).toEqual(mockArtefactSearch); + }); + + it("should handle different artefact ids", async () => { + const mockArtefactSearch = { + artefactSearchId: "search-id-3", + artefactId: "artefact-xyz-999", + caseNumber: "CASE-XYZ", + caseName: "Test Case" + }; + + vi.mocked(prisma.artefactSearch.findFirst).mockResolvedValue(mockArtefactSearch as any); + + const result = await findArtefactSearchByArtefactId("artefact-xyz-999"); + + expect(result).toEqual(mockArtefactSearch); + expect(result.artefactId).toBe("artefact-xyz-999"); + }); +}); + +describe("deleteArtefactSearchByArtefactId", () => { + it("should delete all artefact search entries for given artefact ID", async () => { + const mockDeleteResult = { count: 3 }; + + vi.mocked(prisma.artefactSearch.deleteMany).mockResolvedValue(mockDeleteResult); + + const result = await deleteArtefactSearchByArtefactId("artefact-123"); + + expect(prisma.artefactSearch.deleteMany).toHaveBeenCalledWith({ + where: { artefactId: "artefact-123" } + }); + expect(result).toEqual(mockDeleteResult); + }); + + it("should delete single artefact search entry", async () => { + const mockDeleteResult = { count: 1 }; + + vi.mocked(prisma.artefactSearch.deleteMany).mockResolvedValue(mockDeleteResult); + + const result = await deleteArtefactSearchByArtefactId("artefact-456"); + + expect(prisma.artefactSearch.deleteMany).toHaveBeenCalledWith({ + where: { artefactId: "artefact-456" } + }); + expect(result).toEqual(mockDeleteResult); + }); + + it("should return zero count when no entries exist for artefact ID", async () => { + const mockDeleteResult = { count: 0 }; + + vi.mocked(prisma.artefactSearch.deleteMany).mockResolvedValue(mockDeleteResult); + + const result = await deleteArtefactSearchByArtefactId("non-existent-artefact"); + + expect(prisma.artefactSearch.deleteMany).toHaveBeenCalledWith({ + where: { artefactId: "non-existent-artefact" } + }); + expect(result).toEqual(mockDeleteResult); + }); +}); diff --git a/libs/publication/src/repository/queries.ts b/libs/publication/src/repository/queries.ts index 43e07d26e..aefc70773 100644 --- a/libs/publication/src/repository/queries.ts +++ b/libs/publication/src/repository/queries.ts @@ -271,3 +271,13 @@ export async function getArtefactListTypeId(artefactId: string): Promise ({ + findByCaseName: vi.fn(), + findByCaseNumber: vi.fn() +})); + +const { findByCaseName, findByCaseNumber } = await import("@hmcts/publication"); + +describe("case-search-service", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("searchByCaseName", () => { + it("should return search results when cases are found", async () => { + const mockResults = [ + { + id: "1", + artefactId: "art-1", + caseNumber: "CASE123", + caseName: "Test Case Name", + createdAt: new Date("2024-01-01") + }, + { + id: "2", + artefactId: "art-2", + caseNumber: "CASE456", + caseName: "Another Test Case", + createdAt: new Date("2024-01-02") + } + ]; + + vi.mocked(findByCaseName).mockResolvedValue(mockResults); + + const result = await searchByCaseName("Test"); + + expect(findByCaseName).toHaveBeenCalledWith("Test"); + expect(result).toEqual([ + { + id: "1", + artefactId: "art-1", + caseNumber: "CASE123", + caseName: "Test Case Name" + }, + { + id: "2", + artefactId: "art-2", + caseNumber: "CASE456", + caseName: "Another Test Case" + } + ]); + }); + + it("should trim whitespace from case name", async () => { + vi.mocked(findByCaseName).mockResolvedValue([]); + + await searchByCaseName(" Test Case "); + + expect(findByCaseName).toHaveBeenCalledWith("Test Case"); + }); + + it("should throw error when case name is empty", async () => { + await expect(searchByCaseName("")).rejects.toThrow("Case name is required"); + }); + + it("should throw error when case name is whitespace only", async () => { + await expect(searchByCaseName(" ")).rejects.toThrow("Case name is required"); + }); + + it("should return empty array when no results found", async () => { + vi.mocked(findByCaseName).mockResolvedValue([]); + + const result = await searchByCaseName("Nonexistent"); + + expect(result).toEqual([]); + }); + }); + + describe("searchByCaseReference", () => { + it("should return search results when case reference is found", async () => { + const mockResults = [ + { + id: "1", + artefactId: "art-1", + caseNumber: "CASE123", + caseName: "Test Case", + createdAt: new Date("2024-01-01") + } + ]; + + vi.mocked(findByCaseNumber).mockResolvedValue(mockResults); + + const result = await searchByCaseReference("CASE123"); + + expect(findByCaseNumber).toHaveBeenCalledWith("CASE123"); + expect(result).toEqual([ + { + id: "1", + artefactId: "art-1", + caseNumber: "CASE123", + caseName: "Test Case" + } + ]); + }); + + it("should trim whitespace from case reference", async () => { + vi.mocked(findByCaseNumber).mockResolvedValue([]); + + await searchByCaseReference(" CASE123 "); + + expect(findByCaseNumber).toHaveBeenCalledWith("CASE123"); + }); + + it("should throw error when case reference is empty", async () => { + await expect(searchByCaseReference("")).rejects.toThrow("Case reference is required"); + }); + + it("should throw error when case reference is whitespace only", async () => { + await expect(searchByCaseReference(" ")).rejects.toThrow("Case reference is required"); + }); + + it("should return empty array when no results found", async () => { + vi.mocked(findByCaseNumber).mockResolvedValue([]); + + const result = await searchByCaseReference("NONEXISTENT"); + + expect(result).toEqual([]); + }); + + it("should handle null case name in results", async () => { + const mockResults = [ + { + id: "1", + artefactId: "art-1", + caseNumber: "CASE123", + caseName: null, + createdAt: new Date("2024-01-01") + } + ]; + + vi.mocked(findByCaseNumber).mockResolvedValue(mockResults); + + const result = await searchByCaseReference("CASE123"); + + expect(result).toEqual([ + { + id: "1", + artefactId: "art-1", + caseNumber: "CASE123", + caseName: null + } + ]); + }); + }); +}); diff --git a/libs/subscription/src/case-search-service.ts b/libs/subscription/src/case-search-service.ts new file mode 100644 index 000000000..d7ad18861 --- /dev/null +++ b/libs/subscription/src/case-search-service.ts @@ -0,0 +1,58 @@ +import { findByCaseName, findByCaseNumber } from "@hmcts/publication"; + +export type CaseSearchResult = { + id: string; + caseNumber: string | null; + caseName: string | null; + artefactId: string; +}; + +export async function searchByCaseName(caseName: string): Promise { + if (!caseName || caseName.trim().length === 0) { + throw new Error("Case name is required"); + } + + const results = await findByCaseName(caseName.trim()); + + // Deduplicate by case number and case name (keep most recent artefact) + const seen = new Map(); + + for (const result of results) { + const key = `${result.caseNumber || ""}|${result.caseName || ""}`; + if (!seen.has(key)) { + seen.set(key, { + id: result.id, + caseNumber: result.caseNumber, + caseName: result.caseName, + artefactId: result.artefactId + }); + } + } + + return Array.from(seen.values()); +} + +export async function searchByCaseReference(reference: string): Promise { + if (!reference || reference.trim().length === 0) { + throw new Error("Case reference is required"); + } + + const results = await findByCaseNumber(reference.trim()); + + // Deduplicate by case number and case name (keep most recent artefact) + const seen = new Map(); + + for (const result of results) { + const key = `${result.caseNumber || ""}|${result.caseName || ""}`; + if (!seen.has(key)) { + seen.set(key, { + id: result.id, + caseNumber: result.caseNumber, + caseName: result.caseName, + artefactId: result.artefactId + }); + } + } + + return Array.from(seen.values()); +} diff --git a/libs/subscriptions/src/config.ts b/libs/subscription/src/config.ts similarity index 100% rename from libs/subscriptions/src/config.ts rename to libs/subscription/src/config.ts diff --git a/libs/subscription/src/index.ts b/libs/subscription/src/index.ts new file mode 100644 index 000000000..b6f3625e1 --- /dev/null +++ b/libs/subscription/src/index.ts @@ -0,0 +1,9 @@ +export * from "./case-search-service.js"; +export { + findActiveSubscriptionsByCaseNames, + findActiveSubscriptionsByCaseNumbers, + findActiveSubscriptionsByLocation, + type SubscriptionWithUser +} from "./repository/queries.js"; +export * from "./repository/service.js"; +export * from "./validation/validation.js"; diff --git a/libs/subscriptions/src/locales/cy.ts b/libs/subscription/src/locales/cy.ts similarity index 100% rename from libs/subscriptions/src/locales/cy.ts rename to libs/subscription/src/locales/cy.ts diff --git a/libs/subscriptions/src/locales/en.ts b/libs/subscription/src/locales/en.ts similarity index 100% rename from libs/subscriptions/src/locales/en.ts rename to libs/subscription/src/locales/en.ts diff --git a/libs/subscription/src/repository/queries.test.ts b/libs/subscription/src/repository/queries.test.ts new file mode 100644 index 000000000..dcb348c70 --- /dev/null +++ b/libs/subscription/src/repository/queries.test.ts @@ -0,0 +1,1134 @@ +import { prisma } from "@hmcts/postgres"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + countSubscriptionsByUserId, + createSubscription, + createSubscriptionRecord, + deleteSubscription, + deleteSubscriptionRecord, + deleteSubscriptionsByIds, + findActiveSubscriptionsByCaseNames, + findActiveSubscriptionsByCaseNumbers, + findActiveSubscriptionsByLocation, + findByUserId, + findByUserIdAndType, + findSubscriptionById, + findSubscriptionByUserAndLocation, + findSubscriptionsByLocationId, + findSubscriptionsByUserId, + findSubscriptionsWithLocationByIds, + findSubscriptionsWithLocationByUserId +} from "./queries.js"; + +vi.mock("@hmcts/postgres", () => ({ + prisma: { + subscription: { + findMany: vi.fn(), + findUnique: vi.fn(), + findFirst: vi.fn(), + count: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + deleteMany: vi.fn() + }, + $transaction: vi.fn() + } +})); + +describe("Subscription Queries", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("findSubscriptionsByUserId", () => { + it("should find all subscriptions for a user", async () => { + const userId = "user123"; + const mockSubscriptions = [ + { + subscriptionId: "sub1", + userId, + searchType: "LOCATION_ID", + searchValue: "456", + dateAdded: new Date() + }, + { + subscriptionId: "sub2", + userId, + searchType: "LOCATION_ID", + searchValue: "789", + dateAdded: new Date() + } + ]; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions); + + const result = await findSubscriptionsByUserId(userId); + + expect(result).toEqual(mockSubscriptions); + expect(prisma.subscription.findMany).toHaveBeenCalledWith({ + where: { + userId + }, + orderBy: { + dateAdded: "desc" + } + }); + }); + + it("should return empty array when no subscriptions found", async () => { + const userId = "user123"; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue([]); + + const result = await findSubscriptionsByUserId(userId); + + expect(result).toEqual([]); + }); + }); + + describe("findSubscriptionsByLocationId", () => { + it("should find all subscriptions for a location", async () => { + const locationId = 456; + const mockSubscriptions = [ + { + subscriptionId: "sub1", + userId: "user123", + searchType: "LOCATION_ID", + searchValue: "456", + dateAdded: new Date() + }, + { + subscriptionId: "sub2", + userId: "user456", + searchType: "LOCATION_ID", + searchValue: "456", + dateAdded: new Date() + } + ]; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions); + + const result = await findSubscriptionsByLocationId(locationId); + + expect(result).toEqual(mockSubscriptions); + expect(prisma.subscription.findMany).toHaveBeenCalledWith({ + where: { + searchType: "LOCATION_ID", + searchValue: "456" + }, + orderBy: { + dateAdded: "desc" + } + }); + }); + + it("should return empty array when no subscriptions found", async () => { + const locationId = 456; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue([]); + + const result = await findSubscriptionsByLocationId(locationId); + + expect(result).toEqual([]); + }); + }); + + describe("findSubscriptionByUserAndLocation", () => { + it("should find subscription by user and location", async () => { + const userId = "user123"; + const locationId = 456; + const mockSubscription = { + subscriptionId: "sub1", + userId, + searchType: "LOCATION_ID", + searchValue: "456", + dateAdded: new Date() + }; + + vi.mocked(prisma.subscription.findUnique).mockResolvedValue(mockSubscription); + + const result = await findSubscriptionByUserAndLocation(userId, locationId); + + expect(result).toEqual(mockSubscription); + expect(prisma.subscription.findUnique).toHaveBeenCalledWith({ + where: { + unique_user_subscription: { + userId, + searchType: "LOCATION_ID", + searchValue: "456" + } + } + }); + }); + + it("should return null when subscription not found", async () => { + const userId = "user123"; + const locationId = 456; + + vi.mocked(prisma.subscription.findUnique).mockResolvedValue(null); + + const result = await findSubscriptionByUserAndLocation(userId, locationId); + + expect(result).toBeNull(); + }); + }); + + describe("findSubscriptionById", () => { + it("should find subscription by ID", async () => { + const subscriptionId = "sub123"; + const userId = "user123"; + const mockSubscription = { + subscriptionId, + userId: "user123", + searchType: "LOCATION_ID", + searchValue: "456", + dateAdded: new Date() + }; + + vi.mocked(prisma.subscription.findFirst).mockResolvedValue(mockSubscription); + + const result = await findSubscriptionById(subscriptionId, userId); + + expect(result).toEqual(mockSubscription); + expect(prisma.subscription.findFirst).toHaveBeenCalledWith({ + where: { subscriptionId, userId } + }); + }); + + it("should return null when subscription not found", async () => { + const subscriptionId = "sub123"; + const userId = "user123"; + + vi.mocked(prisma.subscription.findFirst).mockResolvedValue(null); + + const result = await findSubscriptionById(subscriptionId, userId); + + expect(result).toBeNull(); + }); + }); + + describe("countSubscriptionsByUserId", () => { + it("should count subscriptions for a user", async () => { + const userId = "user123"; + + vi.mocked(prisma.subscription.count).mockResolvedValue(5); + + const result = await countSubscriptionsByUserId(userId); + + expect(result).toBe(5); + expect(prisma.subscription.count).toHaveBeenCalledWith({ + where: { + userId + } + }); + }); + + it("should return 0 when no subscriptions found", async () => { + const userId = "user123"; + + vi.mocked(prisma.subscription.count).mockResolvedValue(0); + + const result = await countSubscriptionsByUserId(userId); + + expect(result).toBe(0); + }); + }); + + describe("createSubscriptionRecord", () => { + it("should create a new subscription", async () => { + const userId = "user123"; + const locationId = 456; + const mockSubscription = { + subscriptionId: "sub1", + userId, + searchType: "LOCATION_ID", + searchValue: "456", + dateAdded: new Date() + }; + + vi.mocked(prisma.subscription.create).mockResolvedValue(mockSubscription); + + const result = await createSubscriptionRecord(userId, locationId); + + expect(result).toEqual(mockSubscription); + expect(prisma.subscription.create).toHaveBeenCalledWith({ + data: { + userId, + searchType: "LOCATION_ID", + searchValue: "456" + } + }); + }); + }); + + describe("deleteSubscriptionRecord", () => { + it("should delete a subscription", async () => { + const subscriptionId = "sub1"; + const userId = "user123"; + + vi.mocked(prisma.subscription.deleteMany).mockResolvedValue({ count: 1 }); + + const result = await deleteSubscriptionRecord(subscriptionId, userId); + + expect(result).toBe(1); + expect(prisma.subscription.deleteMany).toHaveBeenCalledWith({ + where: { subscriptionId, userId } + }); + }); + + it("should return 0 when subscription not found or user does not own it", async () => { + const subscriptionId = "sub1"; + const userId = "user123"; + + vi.mocked(prisma.subscription.deleteMany).mockResolvedValue({ count: 0 }); + + const result = await deleteSubscriptionRecord(subscriptionId, userId); + + expect(result).toBe(0); + }); + }); + + describe("findSubscriptionsWithLocationByUserId", () => { + it("should find subscriptions with location details", async () => { + const userId = "user123"; + const mockSubscriptions = [ + { + subscriptionId: "sub1", + userId, + searchType: "LOCATION_ID", + searchValue: "1", + dateAdded: new Date("2024-01-01") + }, + { + subscriptionId: "sub2", + userId, + searchType: "LOCATION_ID", + searchValue: "2", + dateAdded: new Date("2024-01-02") + } + ]; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions); + + const result = await findSubscriptionsWithLocationByUserId(userId); + + expect(result).toEqual(mockSubscriptions); + expect(prisma.subscription.findMany).toHaveBeenCalledWith({ + where: { + userId, + searchType: "LOCATION_ID" + }, + orderBy: { dateAdded: "desc" } + }); + }); + + it("should return empty array when no subscriptions found", async () => { + const userId = "user123"; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue([]); + + const result = await findSubscriptionsWithLocationByUserId(userId); + + expect(result).toEqual([]); + }); + }); + + describe("findSubscriptionsWithLocationByIds", () => { + it("should find subscriptions with location details by IDs", async () => { + const userId = "user123"; + const subscriptionIds = ["sub-1", "sub-2"]; + const mockSubscriptions = [ + { + subscriptionId: "sub-1", + userId: "user123", + searchType: "LOCATION_ID", + searchValue: "1", + dateAdded: new Date("2024-01-01") + }, + { + subscriptionId: "sub-2", + userId: "user123", + searchType: "LOCATION_ID", + searchValue: "2", + dateAdded: new Date("2024-01-02") + } + ]; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions); + + const result = await findSubscriptionsWithLocationByIds(subscriptionIds, userId); + + expect(result).toEqual(mockSubscriptions); + expect(prisma.subscription.findMany).toHaveBeenCalledWith({ + where: { + subscriptionId: { in: subscriptionIds }, + userId, + searchType: "LOCATION_ID" + }, + orderBy: { dateAdded: "desc" } + }); + }); + + it("should return empty array when no subscriptions found", async () => { + vi.mocked(prisma.subscription.findMany).mockResolvedValue([]); + + const result = await findSubscriptionsWithLocationByIds(["sub-1"], "user123"); + + expect(result).toEqual([]); + }); + }); + + describe("deleteSubscriptionsByIds", () => { + it("should delete multiple subscriptions in a transaction", async () => { + const subscriptionIds = ["sub-1", "sub-2"]; + const userId = "user123"; + const mockDeleteResult = { count: 2 }; + + const mockTransaction = vi.fn(async (callback) => { + const tx = { + subscription: { + deleteMany: vi.fn().mockResolvedValue(mockDeleteResult) + } + }; + return callback(tx); + }); + + vi.mocked(prisma.$transaction).mockImplementation(mockTransaction); + + const result = await deleteSubscriptionsByIds(subscriptionIds, userId); + + expect(result).toBe(2); + expect(prisma.$transaction).toHaveBeenCalled(); + }); + + it("should return 0 when no subscriptions match deletion criteria", async () => { + const subscriptionIds = ["sub-1"]; + const userId = "user123"; + const mockDeleteResult = { count: 0 }; + + const mockTransaction = vi.fn(async (callback) => { + const tx = { + subscription: { + deleteMany: vi.fn().mockResolvedValue(mockDeleteResult) + } + }; + return callback(tx); + }); + + vi.mocked(prisma.$transaction).mockImplementation(mockTransaction); + + const result = await deleteSubscriptionsByIds(subscriptionIds, userId); + + expect(result).toBe(0); + }); + + it("should handle transaction errors", async () => { + const subscriptionIds = ["sub-1"]; + const userId = "user123"; + + const mockTransaction = vi.fn(async () => { + throw new Error("Database error"); + }); + + vi.mocked(prisma.$transaction).mockImplementation(mockTransaction); + + await expect(deleteSubscriptionsByIds(subscriptionIds, userId)).rejects.toThrow("Database error"); + }); + }); + + describe("findByUserId", () => { + it("should find all subscriptions for a user", async () => { + const userId = "user123"; + const mockSubscriptions = [ + { + subscriptionId: "sub1", + userId, + searchType: "LOCATION_ID", + searchValue: "456", + caseName: null, + caseNumber: null, + dateAdded: new Date() + }, + { + subscriptionId: "sub2", + userId, + searchType: "CASE_NUMBER", + searchValue: "CASE123", + caseName: "Test Case", + caseNumber: "CASE123", + dateAdded: new Date() + } + ]; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions); + + const result = await findByUserId(userId); + + expect(prisma.subscription.findMany).toHaveBeenCalledWith({ + where: { + userId + }, + orderBy: { + dateAdded: "desc" + } + }); + expect(result).toEqual(mockSubscriptions); + }); + }); + + describe("findByUserIdAndType", () => { + it("should find subscriptions for a user by type", async () => { + const userId = "user123"; + const searchType = "CASE_NUMBER"; + const mockSubscriptions = [ + { + subscriptionId: "sub1", + userId, + searchType, + searchValue: "CASE123", + caseName: "Test Case", + caseNumber: "CASE123", + dateAdded: new Date() + } + ]; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions); + + const result = await findByUserIdAndType(userId, searchType); + + expect(prisma.subscription.findMany).toHaveBeenCalledWith({ + where: { + userId, + searchType + }, + orderBy: { + dateAdded: "desc" + } + }); + expect(result).toEqual(mockSubscriptions); + }); + + it("should return empty array when no subscriptions found", async () => { + const userId = "user123"; + const searchType = "CASE_NUMBER"; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue([]); + + const result = await findByUserIdAndType(userId, searchType); + + expect(result).toEqual([]); + }); + }); + + describe("createSubscription", () => { + it("should create a case subscription with all fields", async () => { + const userId = "user123"; + const searchType = "CASE_NUMBER"; + const searchValue = "CASE123"; + const caseName = "Test Case"; + const caseNumber = "CASE123"; + + const mockSubscription = { + subscriptionId: "sub1", + userId, + searchType, + searchValue, + caseName, + caseNumber, + dateAdded: new Date() + }; + + vi.mocked(prisma.subscription.create).mockResolvedValue(mockSubscription); + + const result = await createSubscription(userId, searchType, searchValue, caseName, caseNumber); + + expect(prisma.subscription.create).toHaveBeenCalledWith({ + data: { + userId, + searchType, + searchValue, + caseName, + caseNumber + } + }); + expect(result).toEqual(mockSubscription); + }); + + it("should create a subscription with null case fields", async () => { + const userId = "user123"; + const searchType = "LOCATION_ID"; + const searchValue = "456"; + + const mockSubscription = { + subscriptionId: "sub1", + userId, + searchType, + searchValue, + caseName: null, + caseNumber: null, + dateAdded: new Date() + }; + + vi.mocked(prisma.subscription.create).mockResolvedValue(mockSubscription); + + const result = await createSubscription(userId, searchType, searchValue, null, null); + + expect(prisma.subscription.create).toHaveBeenCalledWith({ + data: { + userId, + searchType, + searchValue, + caseName: null, + caseNumber: null + } + }); + expect(result).toEqual(mockSubscription); + }); + }); + + describe("deleteSubscription", () => { + it("should delete a subscription by id", async () => { + const subscriptionId = "sub1"; + const mockSubscription = { + subscriptionId, + userId: "user123", + searchType: "CASE_NUMBER", + searchValue: "CASE123", + caseName: "Test Case", + caseNumber: "CASE123", + dateAdded: new Date() + }; + + vi.mocked(prisma.subscription.delete).mockResolvedValue(mockSubscription); + + const result = await deleteSubscription(subscriptionId); + + expect(prisma.subscription.delete).toHaveBeenCalledWith({ + where: { + subscriptionId + } + }); + expect(result).toEqual(mockSubscription); + }); + + it("should throw error when subscription not found", async () => { + const subscriptionId = "nonexistent"; + + vi.mocked(prisma.subscription.delete).mockRejectedValue(new Error("Record not found")); + + await expect(deleteSubscription(subscriptionId)).rejects.toThrow("Record not found"); + }); + }); + + describe("findActiveSubscriptionsByLocation", () => { + it("should find subscriptions by location", async () => { + const mockSubscriptions = [ + { + subscriptionId: "sub-1", + userId: "user-1", + searchType: "LOCATION_ID", + searchValue: "1", + caseName: null, + caseNumber: null, + user: { + email: "user1@example.com", + firstName: "John", + surname: "Doe" + } + }, + { + subscriptionId: "sub-2", + userId: "user-2", + searchType: "LOCATION_ID", + searchValue: "1", + caseName: null, + caseNumber: null, + user: { + email: "user2@example.com", + firstName: "Jane", + surname: "Smith" + } + } + ]; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions as never); + + const result = await findActiveSubscriptionsByLocation(1); + + expect(result).toHaveLength(2); + expect(result[0].user.email).toBe("user1@example.com"); + expect(prisma.subscription.findMany).toHaveBeenCalledWith({ + where: { + searchType: "LOCATION_ID", + searchValue: "1" + }, + select: { + subscriptionId: true, + userId: true, + searchType: true, + searchValue: true, + caseName: true, + caseNumber: true, + user: { + select: { + email: true, + firstName: true, + surname: true + } + } + } + }); + }); + + it("should find subscriptions by location with different location IDs", async () => { + const mockSubscriptions = [ + { + subscriptionId: "sub-3", + userId: "user-3", + searchType: "LOCATION_ID", + searchValue: "456", + caseName: null, + caseNumber: null, + user: { + email: "user3@example.com", + firstName: "Alice", + surname: "Johnson" + } + } + ]; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions as never); + + const result = await findActiveSubscriptionsByLocation(456); + + expect(result).toHaveLength(1); + expect(result[0].searchValue).toBe("456"); + expect(prisma.subscription.findMany).toHaveBeenCalledWith({ + where: { + searchType: "LOCATION_ID", + searchValue: "456" + }, + select: { + subscriptionId: true, + userId: true, + searchType: true, + searchValue: true, + caseName: true, + caseNumber: true, + user: { + select: { + email: true, + firstName: true, + surname: true + } + } + } + }); + }); + + it("should return empty array when no subscriptions found for location", async () => { + vi.mocked(prisma.subscription.findMany).mockResolvedValue([]); + + const result = await findActiveSubscriptionsByLocation(999); + + expect(result).toHaveLength(0); + }); + + it("should find subscriptions with users having null names", async () => { + const mockSubscriptions = [ + { + subscriptionId: "sub-4", + userId: "user-4", + searchType: "LOCATION_ID", + searchValue: "100", + caseName: null, + caseNumber: null, + user: { + email: "user4@example.com", + firstName: null, + surname: null + } + } + ]; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions as never); + + const result = await findActiveSubscriptionsByLocation(100); + + expect(result).toHaveLength(1); + expect(result[0].user.firstName).toBeNull(); + expect(result[0].user.surname).toBeNull(); + }); + + it("should find subscriptions with mixed user data completeness", async () => { + const mockSubscriptions = [ + { + subscriptionId: "sub-13", + userId: "user-13", + searchType: "LOCATION_ID", + searchValue: "200", + caseName: null, + caseNumber: null, + user: { + email: "user13@example.com", + firstName: "Jane", + surname: null + } + }, + { + subscriptionId: "sub-14", + userId: "user-14", + searchType: "LOCATION_ID", + searchValue: "200", + caseName: null, + caseNumber: null, + user: { + email: "user14@example.com", + firstName: null, + surname: "Brown" + } + } + ]; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions as never); + + const result = await findActiveSubscriptionsByLocation(200); + + expect(result).toHaveLength(2); + expect(result[0].user.firstName).toBe("Jane"); + expect(result[0].user.surname).toBeNull(); + expect(result[1].user.firstName).toBeNull(); + expect(result[1].user.surname).toBe("Brown"); + }); + }); + + describe("findActiveSubscriptionsByCaseNumbers", () => { + it("should find subscriptions by case numbers", async () => { + const mockSubscriptions = [ + { + subscriptionId: "sub-1", + userId: "user-1", + searchType: "CASE_NUMBER", + searchValue: "CASE-123", + caseName: "Test Case A", + caseNumber: "CASE-123", + user: { + email: "user1@example.com", + firstName: "John", + surname: "Doe" + } + }, + { + subscriptionId: "sub-2", + userId: "user-2", + searchType: "CASE_NUMBER", + searchValue: "CASE-456", + caseName: "Test Case B", + caseNumber: "CASE-456", + user: { + email: "user2@example.com", + firstName: "Jane", + surname: "Smith" + } + } + ]; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions as never); + + const result = await findActiveSubscriptionsByCaseNumbers(["CASE-123", "CASE-456"]); + + expect(result).toHaveLength(2); + expect(result[0].user.email).toBe("user1@example.com"); + expect(result[1].user.email).toBe("user2@example.com"); + expect(prisma.subscription.findMany).toHaveBeenCalledWith({ + where: { + searchType: "CASE_NUMBER", + searchValue: { + in: ["CASE-123", "CASE-456"] + } + }, + select: { + subscriptionId: true, + userId: true, + searchType: true, + searchValue: true, + caseName: true, + caseNumber: true, + user: { + select: { + email: true, + firstName: true, + surname: true + } + } + } + }); + }); + + it("should return empty array when no case numbers provided", async () => { + const result = await findActiveSubscriptionsByCaseNumbers([]); + + expect(result).toHaveLength(0); + }); + + it("should find subscriptions with single case number", async () => { + const mockSubscriptions = [ + { + subscriptionId: "sub-1", + userId: "user-1", + searchType: "CASE_NUMBER", + searchValue: "CASE-789", + caseName: "Test Case C", + caseNumber: "CASE-789", + user: { + email: "user1@example.com", + firstName: "John", + surname: "Doe" + } + } + ]; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions as never); + + const result = await findActiveSubscriptionsByCaseNumbers(["CASE-789"]); + + expect(result).toHaveLength(1); + expect(result[0].searchValue).toBe("CASE-789"); + }); + + it("should return empty array when no subscriptions found for case numbers", async () => { + vi.mocked(prisma.subscription.findMany).mockResolvedValue([]); + + const result = await findActiveSubscriptionsByCaseNumbers(["NON-EXISTENT"]); + + expect(result).toHaveLength(0); + }); + + it("should handle case numbers with various formats", async () => { + const mockSubscriptions = [ + { + subscriptionId: "sub-12", + userId: "user-12", + searchType: "CASE_NUMBER", + searchValue: "2024/ABC/12345", + caseName: "Complex Case", + caseNumber: "2024/ABC/12345", + user: { + email: "user12@example.com", + firstName: "Ian", + surname: "Roberts" + } + } + ]; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions as never); + + const result = await findActiveSubscriptionsByCaseNumbers(["2024/ABC/12345"]); + + expect(result).toHaveLength(1); + expect(result[0].caseNumber).toBe("2024/ABC/12345"); + }); + }); + + describe("findActiveSubscriptionsByCaseNames", () => { + it("should find subscriptions by case names", async () => { + const mockSubscriptions = [ + { + subscriptionId: "sub-5", + userId: "user-5", + searchType: "CASE_NAME", + searchValue: "Smith v Jones", + caseName: "Smith v Jones", + caseNumber: "CASE-100", + user: { + email: "user5@example.com", + firstName: "Bob", + surname: "Wilson" + } + }, + { + subscriptionId: "sub-6", + userId: "user-6", + searchType: "CASE_NAME", + searchValue: "Brown v Green", + caseName: "Brown v Green", + caseNumber: "CASE-200", + user: { + email: "user6@example.com", + firstName: "Carol", + surname: "Davis" + } + } + ]; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions as never); + + const result = await findActiveSubscriptionsByCaseNames(["Smith v Jones", "Brown v Green"]); + + expect(result).toHaveLength(2); + expect(result[0].user.email).toBe("user5@example.com"); + expect(result[1].user.email).toBe("user6@example.com"); + expect(result[0].caseName).toBe("Smith v Jones"); + expect(result[1].caseName).toBe("Brown v Green"); + expect(prisma.subscription.findMany).toHaveBeenCalledWith({ + where: { + searchType: "CASE_NAME", + searchValue: { + in: ["Smith v Jones", "Brown v Green"] + } + }, + select: { + subscriptionId: true, + userId: true, + searchType: true, + searchValue: true, + caseName: true, + caseNumber: true, + user: { + select: { + email: true, + firstName: true, + surname: true + } + } + } + }); + }); + + it("should return empty array when no case names provided", async () => { + const result = await findActiveSubscriptionsByCaseNames([]); + + expect(result).toHaveLength(0); + }); + + it("should find subscriptions with single case name", async () => { + const mockSubscriptions = [ + { + subscriptionId: "sub-7", + userId: "user-7", + searchType: "CASE_NAME", + searchValue: "White v Black", + caseName: "White v Black", + caseNumber: "CASE-300", + user: { + email: "user7@example.com", + firstName: "David", + surname: "Miller" + } + } + ]; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions as never); + + const result = await findActiveSubscriptionsByCaseNames(["White v Black"]); + + expect(result).toHaveLength(1); + expect(result[0].searchValue).toBe("White v Black"); + expect(result[0].caseName).toBe("White v Black"); + }); + + it("should return empty array when no subscriptions found for case names", async () => { + vi.mocked(prisma.subscription.findMany).mockResolvedValue([]); + + const result = await findActiveSubscriptionsByCaseNames(["Non Existent Case"]); + + expect(result).toHaveLength(0); + }); + + it("should handle case names with special characters", async () => { + const mockSubscriptions = [ + { + subscriptionId: "sub-8", + userId: "user-8", + searchType: "CASE_NAME", + searchValue: "R v O'Connor", + caseName: "R v O'Connor", + caseNumber: "CASE-400", + user: { + email: "user8@example.com", + firstName: "Emma", + surname: "Taylor" + } + } + ]; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions as never); + + const result = await findActiveSubscriptionsByCaseNames(["R v O'Connor"]); + + expect(result).toHaveLength(1); + expect(result[0].caseName).toBe("R v O'Connor"); + }); + + it("should find multiple subscriptions for the same case name", async () => { + const mockSubscriptions = [ + { + subscriptionId: "sub-9", + userId: "user-9", + searchType: "CASE_NAME", + searchValue: "Jones v Williams", + caseName: "Jones v Williams", + caseNumber: "CASE-500", + user: { + email: "user9@example.com", + firstName: "Frank", + surname: "Anderson" + } + }, + { + subscriptionId: "sub-10", + userId: "user-10", + searchType: "CASE_NAME", + searchValue: "Jones v Williams", + caseName: "Jones v Williams", + caseNumber: "CASE-500", + user: { + email: "user10@example.com", + firstName: "Grace", + surname: "Thomas" + } + } + ]; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions as never); + + const result = await findActiveSubscriptionsByCaseNames(["Jones v Williams"]); + + expect(result).toHaveLength(2); + expect(result[0].caseName).toBe("Jones v Williams"); + expect(result[1].caseName).toBe("Jones v Williams"); + expect(result[0].userId).toBe("user-9"); + expect(result[1].userId).toBe("user-10"); + }); + + it("should handle case names with long text", async () => { + const longCaseName = "The Queen on the Application of ABC Limited v The Secretary of State for Justice and Others"; + const mockSubscriptions = [ + { + subscriptionId: "sub-11", + userId: "user-11", + searchType: "CASE_NAME", + searchValue: longCaseName, + caseName: longCaseName, + caseNumber: "CASE-600", + user: { + email: "user11@example.com", + firstName: "Henry", + surname: "Moore" + } + } + ]; + + vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions as never); + + const result = await findActiveSubscriptionsByCaseNames([longCaseName]); + + expect(result).toHaveLength(1); + expect(result[0].caseName).toBe(longCaseName); + }); + }); +}); diff --git a/libs/subscription/src/repository/queries.ts b/libs/subscription/src/repository/queries.ts new file mode 100644 index 000000000..99de25517 --- /dev/null +++ b/libs/subscription/src/repository/queries.ts @@ -0,0 +1,268 @@ +import { prisma } from "@hmcts/postgres"; + +export interface SubscriptionWithUser { + subscriptionId: string; + userId: string; + searchType: string; + searchValue: string; + caseName: string | null; + caseNumber: string | null; + user: { + email: string; + firstName: string | null; + surname: string | null; + }; +} + +export async function findActiveSubscriptionsByLocation(locationId: number): Promise { + const subscriptions = await prisma.subscription.findMany({ + where: { + searchType: "LOCATION_ID", + searchValue: locationId.toString() + }, + select: { + subscriptionId: true, + userId: true, + searchType: true, + searchValue: true, + caseName: true, + caseNumber: true, + user: { + select: { + email: true, + firstName: true, + surname: true + } + } + } + }); + + return subscriptions; +} + +export async function findActiveSubscriptionsByCaseNumbers(caseNumbers: string[]): Promise { + if (caseNumbers.length === 0) { + return []; + } + + const subscriptions = await prisma.subscription.findMany({ + where: { + searchType: "CASE_NUMBER", + searchValue: { + in: caseNumbers + } + }, + select: { + subscriptionId: true, + userId: true, + searchType: true, + searchValue: true, + caseName: true, + caseNumber: true, + user: { + select: { + email: true, + firstName: true, + surname: true + } + } + } + }); + + return subscriptions; +} + +export async function findActiveSubscriptionsByCaseNames(caseNames: string[]): Promise { + if (caseNames.length === 0) { + return []; + } + + const subscriptions = await prisma.subscription.findMany({ + where: { + searchType: "CASE_NAME", + searchValue: { + in: caseNames + } + }, + select: { + subscriptionId: true, + userId: true, + searchType: true, + searchValue: true, + caseName: true, + caseNumber: true, + user: { + select: { + email: true, + firstName: true, + surname: true + } + } + } + }); + + return subscriptions; +} + +export async function findSubscriptionsByUserId(userId: string) { + return prisma.subscription.findMany({ + where: { + userId + }, + orderBy: { + dateAdded: "desc" + } + }); +} + +export async function findSubscriptionsByLocationId(locationId: number) { + return prisma.subscription.findMany({ + where: { + searchType: "LOCATION_ID", + searchValue: locationId.toString() + }, + orderBy: { + dateAdded: "desc" + } + }); +} + +export async function findSubscriptionByUserAndLocation(userId: string, locationId: number) { + return prisma.subscription.findUnique({ + where: { + unique_user_subscription: { + userId, + searchType: "LOCATION_ID", + searchValue: locationId.toString() + } + } + }); +} + +export async function countSubscriptionsByUserId(userId: string) { + return prisma.subscription.count({ + where: { + userId + } + }); +} + +export async function createSubscriptionRecord(userId: string, locationId: number) { + return prisma.subscription.create({ + data: { + userId, + searchType: "LOCATION_ID", + searchValue: locationId.toString() + } + }); +} + +export async function findSubscriptionById(subscriptionId: string, userId: string) { + return prisma.subscription.findFirst({ + where: { subscriptionId, userId } + }); +} + +export async function deleteSubscriptionRecord(subscriptionId: string, userId: string) { + const result = await prisma.subscription.deleteMany({ + where: { subscriptionId, userId } + }); + return result.count; +} + +export async function findSubscriptionsWithLocationByUserId(userId: string) { + return prisma.subscription.findMany({ + where: { + userId, + searchType: "LOCATION_ID" + }, + orderBy: { dateAdded: "desc" } + }); +} + +export async function findSubscriptionsWithLocationByIds(subscriptionIds: string[], userId: string) { + return prisma.subscription.findMany({ + where: { + subscriptionId: { in: subscriptionIds }, + userId, + searchType: "LOCATION_ID" + }, + orderBy: { dateAdded: "desc" } + }); +} + +export async function findSubscriptionsByIds(subscriptionIds: string[], userId: string) { + return prisma.subscription.findMany({ + where: { + subscriptionId: { in: subscriptionIds }, + userId + }, + orderBy: { dateAdded: "desc" } + }); +} + +export async function deleteSubscriptionsByIds(subscriptionIds: string[], userId: string) { + return prisma.$transaction(async (tx) => { + const deleteResult = await tx.subscription.deleteMany({ + where: { + subscriptionId: { in: subscriptionIds }, + userId + } + }); + + return deleteResult.count; + }); +} + +export async function findByUserId(userId: string) { + return prisma.subscription.findMany({ + where: { + userId + }, + orderBy: { + dateAdded: "desc" + } + }); +} + +export async function findByUserIdAndType(userId: string, searchType: string) { + return prisma.subscription.findMany({ + where: { + userId, + searchType + }, + orderBy: { + dateAdded: "desc" + } + }); +} + +export async function findByUserIdTypeAndValue(userId: string, searchType: string, searchValue: string) { + return prisma.subscription.findFirst({ + where: { + userId, + searchType, + searchValue + } + }); +} + +export async function createSubscription(userId: string, searchType: string, searchValue: string, caseName: string | null, caseNumber: string | null) { + return prisma.subscription.create({ + data: { + userId, + searchType, + searchValue, + caseName, + caseNumber + } + }); +} + +export async function deleteSubscription(id: string) { + return prisma.subscription.delete({ + where: { + subscriptionId: id + } + }); +} diff --git a/libs/subscriptions/src/repository/service.test.ts b/libs/subscription/src/repository/service.test.ts similarity index 59% rename from libs/subscriptions/src/repository/service.test.ts rename to libs/subscription/src/repository/service.test.ts index 23513b363..25cdbffe1 100644 --- a/libs/subscriptions/src/repository/service.test.ts +++ b/libs/subscription/src/repository/service.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as validation from "../validation/validation.js"; import * as queries from "./queries.js"; import { + createCaseSubscription, createMultipleSubscriptions, createSubscription, deleteSubscriptionsByIds, @@ -9,12 +10,20 @@ import { getCaseSubscriptionsByUserId, getCourtSubscriptionsByUserId, getSubscriptionDetailsForConfirmation, + getSubscriptionsByCase, + getSubscriptionsByLocation, + getUserSubscriptions, removeSubscription, replaceUserSubscriptions } from "./service.js"; vi.mock("./queries.js"); vi.mock("../validation/validation.js"); +vi.mock("@hmcts/location", () => ({ + getLocationById: vi.fn() +})); + +import { getLocationById } from "@hmcts/location"; describe("Subscription Service", () => { beforeEach(() => { @@ -36,7 +45,8 @@ describe("Subscription Service", () => { vi.mocked(queries.createSubscriptionRecord).mockResolvedValue({ subscriptionId: "sub123", userId, - locationId: 456, + searchType: "LOCATION_ID", + searchValue: "456", dateAdded: new Date() }); @@ -44,7 +54,7 @@ describe("Subscription Service", () => { expect(result).toBeDefined(); expect(result.userId).toBe(userId); - expect(result.locationId).toBe(456); + expect(result.searchValue).toBe("456"); expect(validation.validateLocationId).toHaveBeenCalledWith(locationId); expect(queries.createSubscriptionRecord).toHaveBeenCalledWith(userId, 456); }); @@ -60,7 +70,8 @@ describe("Subscription Service", () => { vi.mocked(queries.findSubscriptionByUserAndLocation).mockResolvedValue({ subscriptionId: "sub123", userId, - locationId: 456, + searchType: "LOCATION_ID", + searchValue: "456", dateAdded: new Date() }); @@ -84,7 +95,8 @@ describe("Subscription Service", () => { const subscription = { subscriptionId, userId, - locationId: 456, + searchType: "LOCATION_ID", + searchValue: "456", dateAdded: new Date() }; @@ -110,7 +122,8 @@ describe("Subscription Service", () => { const subscription = { subscriptionId, userId, - locationId: 456, + searchType: "LOCATION_ID", + searchValue: "456", dateAdded: new Date() }; @@ -133,7 +146,8 @@ describe("Subscription Service", () => { vi.mocked(queries.createSubscriptionRecord).mockResolvedValue({ subscriptionId: "sub123", userId, - locationId: 456, + searchType: "LOCATION_ID", + searchValue: "456", dateAdded: new Date() }); @@ -155,7 +169,8 @@ describe("Subscription Service", () => { vi.mocked(queries.createSubscriptionRecord).mockResolvedValue({ subscriptionId: "sub123", userId, - locationId: 456, + searchType: "LOCATION_ID", + searchValue: "456", dateAdded: new Date() }); @@ -173,8 +188,8 @@ describe("Subscription Service", () => { it("should replace subscriptions by adding new and removing old", async () => { const existingSubscriptions = [ - { subscriptionId: "sub1", userId, locationId: 456, dateAdded: new Date() }, - { subscriptionId: "sub2", userId, locationId: 789, dateAdded: new Date() } + { subscriptionId: "sub1", userId, searchType: "LOCATION_ID", searchValue: "456", dateAdded: new Date() }, + { subscriptionId: "sub2", userId, searchType: "LOCATION_ID", searchValue: "789", dateAdded: new Date() } ]; const newLocationIds = ["789", "101"]; @@ -184,7 +199,8 @@ describe("Subscription Service", () => { vi.mocked(queries.createSubscriptionRecord).mockResolvedValue({ subscriptionId: "sub3", userId, - locationId: 101, + searchType: "LOCATION_ID", + searchValue: "101", dateAdded: new Date() }); @@ -204,7 +220,8 @@ describe("Subscription Service", () => { vi.mocked(queries.createSubscriptionRecord).mockResolvedValue({ subscriptionId: "sub1", userId, - locationId: 456, + searchType: "LOCATION_ID", + searchValue: "456", dateAdded: new Date() }); @@ -217,8 +234,8 @@ describe("Subscription Service", () => { it("should only remove subscriptions when new list is empty", async () => { const existingSubscriptions = [ - { subscriptionId: "sub1", userId, locationId: 456, dateAdded: new Date() }, - { subscriptionId: "sub2", userId, locationId: 789, dateAdded: new Date() } + { subscriptionId: "sub1", userId, searchType: "LOCATION_ID", searchValue: "456", dateAdded: new Date() }, + { subscriptionId: "sub2", userId, searchType: "LOCATION_ID", searchValue: "789", dateAdded: new Date() } ]; vi.mocked(queries.findSubscriptionsByUserId).mockResolvedValue(existingSubscriptions); @@ -238,7 +255,8 @@ describe("Subscription Service", () => { const existingSubscriptions = Array.from({ length: 48 }, (_, i) => ({ subscriptionId: `sub${i}`, userId, - locationId: i, + searchType: "LOCATION_ID", + searchValue: `${i}`, dateAdded: new Date() })); // Keep all existing 48 and try to add 3 new ones = 51 total @@ -261,8 +279,8 @@ describe("Subscription Service", () => { it("should keep same subscriptions when lists match", async () => { const existingSubscriptions = [ - { subscriptionId: "sub1", userId, locationId: 456, dateAdded: new Date() }, - { subscriptionId: "sub2", userId, locationId: 789, dateAdded: new Date() } + { subscriptionId: "sub1", userId, searchType: "LOCATION_ID", searchValue: "456", dateAdded: new Date() }, + { subscriptionId: "sub2", userId, searchType: "LOCATION_ID", searchValue: "789", dateAdded: new Date() } ]; const newLocationIds = ["456", "789"]; @@ -283,29 +301,38 @@ describe("Subscription Service", () => { { subscriptionId: "sub-1", userId: mockUserId, - locationId: 1, - dateAdded: new Date("2024-01-01"), - location: { - locationId: 1, - name: "Birmingham Crown Court", - welshName: "Welsh Birmingham Crown Court" - } + searchType: "LOCATION_ID", + searchValue: "1", + dateAdded: new Date("2024-01-01") }, { subscriptionId: "sub-2", userId: mockUserId, - locationId: 2, - dateAdded: new Date("2024-01-02"), - location: { - locationId: 2, - name: "Manchester Crown Court", - welshName: null - } + searchType: "LOCATION_ID", + searchValue: "2", + dateAdded: new Date("2024-01-02") } ]; it("should return all subscriptions with location details in English", async () => { vi.mocked(queries.findSubscriptionsWithLocationByUserId).mockResolvedValue(mockSubscriptions); + vi.mocked(getLocationById).mockImplementation(async (id) => { + if (id === 1) { + return { + locationId: 1, + name: "Birmingham Crown Court", + welshName: "Welsh Birmingham Crown Court" + } as any; + } + if (id === 2) { + return { + locationId: 2, + name: "Manchester Crown Court", + welshName: null + } as any; + } + return null; + }); const result = await getAllSubscriptionsByUserId(mockUserId, "en"); @@ -331,6 +358,23 @@ describe("Subscription Service", () => { it("should return subscriptions with Welsh names when locale is cy", async () => { vi.mocked(queries.findSubscriptionsWithLocationByUserId).mockResolvedValue(mockSubscriptions); + vi.mocked(getLocationById).mockImplementation(async (id) => { + if (id === 1) { + return { + locationId: 1, + name: "Birmingham Crown Court", + welshName: "Welsh Birmingham Crown Court" + } as any; + } + if (id === 2) { + return { + locationId: 2, + name: "Manchester Crown Court", + welshName: null + } as any; + } + return null; + }); const result = await getAllSubscriptionsByUserId(mockUserId, "cy"); @@ -347,43 +391,44 @@ describe("Subscription Service", () => { }); }); - describe("getCaseSubscriptionsByUserId", () => { - it("should return empty array as case subscriptions are not yet implemented", async () => { - const result = await getCaseSubscriptionsByUserId("user-123"); - - expect(result).toEqual([]); - }); - }); - describe("getCourtSubscriptionsByUserId", () => { const mockUserId = "user-123"; const mockSubscriptions = [ { subscriptionId: "sub-1", userId: mockUserId, - locationId: 1, - dateAdded: new Date("2024-01-01"), - location: { - locationId: 1, - name: "Birmingham Crown Court", - welshName: "Welsh Birmingham Crown Court" - } + searchType: "LOCATION_ID", + searchValue: "1", + dateAdded: new Date("2024-01-01") }, { subscriptionId: "sub-2", userId: mockUserId, - locationId: 2, - dateAdded: new Date("2024-01-02"), - location: { - locationId: 2, - name: "Manchester Crown Court", - welshName: null - } + searchType: "LOCATION_ID", + searchValue: "2", + dateAdded: new Date("2024-01-02") } ]; it("should return court subscriptions with location details", async () => { vi.mocked(queries.findSubscriptionsWithLocationByUserId).mockResolvedValue(mockSubscriptions); + vi.mocked(getLocationById).mockImplementation(async (id) => { + if (id === 1) { + return { + locationId: 1, + name: "Birmingham Crown Court", + welshName: "Welsh Birmingham Crown Court" + } as any; + } + if (id === 2) { + return { + locationId: 2, + name: "Manchester Crown Court", + welshName: null + } as any; + } + return null; + }); const result = await getCourtSubscriptionsByUserId(mockUserId, "en"); @@ -407,6 +452,23 @@ describe("Subscription Service", () => { it("should use Welsh names when locale is cy", async () => { vi.mocked(queries.findSubscriptionsWithLocationByUserId).mockResolvedValue(mockSubscriptions); + vi.mocked(getLocationById).mockImplementation(async (id) => { + if (id === 1) { + return { + locationId: 1, + name: "Birmingham Crown Court", + welshName: "Welsh Birmingham Crown Court" + } as any; + } + if (id === 2) { + return { + locationId: 2, + name: "Manchester Crown Court", + welshName: null + } as any; + } + return null; + }); const result = await getCourtSubscriptionsByUserId(mockUserId, "cy"); @@ -419,29 +481,38 @@ describe("Subscription Service", () => { { subscriptionId: "sub-1", userId: "user-123", - locationId: 1, - dateAdded: new Date("2024-01-01"), - location: { - locationId: 1, - name: "Birmingham Crown Court", - welshName: "Welsh Birmingham Crown Court" - } + searchType: "LOCATION_ID", + searchValue: "1", + dateAdded: new Date("2024-01-01") }, { subscriptionId: "sub-2", userId: "user-123", - locationId: 2, - dateAdded: new Date("2024-01-02"), - location: { - locationId: 2, - name: "Manchester Crown Court", - welshName: null - } + searchType: "LOCATION_ID", + searchValue: "2", + dateAdded: new Date("2024-01-02") } ]; it("should return subscription details with location information", async () => { - vi.mocked(queries.findSubscriptionsWithLocationByIds).mockResolvedValue(mockSubscriptions); + vi.mocked(queries.findSubscriptionsByIds).mockResolvedValue(mockSubscriptions); + vi.mocked(getLocationById).mockImplementation(async (id) => { + if (id === 1) { + return { + locationId: 1, + name: "Birmingham Crown Court", + welshName: "Welsh Birmingham Crown Court" + } as any; + } + if (id === 2) { + return { + locationId: 2, + name: "Manchester Crown Court", + welshName: null + } as any; + } + return null; + }); const result = await getSubscriptionDetailsForConfirmation(["sub-1", "sub-2"], "user-123", "en"); @@ -467,11 +538,21 @@ describe("Subscription Service", () => { const result = await getSubscriptionDetailsForConfirmation([], "user-123"); expect(result).toEqual([]); - expect(queries.findSubscriptionsWithLocationByIds).not.toHaveBeenCalled(); + expect(queries.findSubscriptionsByIds).not.toHaveBeenCalled(); }); it("should use Welsh names when locale is cy", async () => { - vi.mocked(queries.findSubscriptionsWithLocationByIds).mockResolvedValue(mockSubscriptions); + vi.mocked(queries.findSubscriptionsByIds).mockResolvedValue([mockSubscriptions[0]]); + vi.mocked(getLocationById).mockImplementation(async (id) => { + if (id === 1) { + return { + locationId: 1, + name: "Birmingham Crown Court", + welshName: "Welsh Birmingham Crown Court" + } as any; + } + return null; + }); const result = await getSubscriptionDetailsForConfirmation(["sub-1"], "user-123", "cy"); @@ -523,4 +604,210 @@ describe("Subscription Service", () => { await expect(deleteSubscriptionsByIds(["sub-1"], mockUserId)).rejects.toThrow("Unauthorized: User does not own all selected subscriptions"); }); }); + + describe("getUserSubscriptions", () => { + it("should return all subscriptions for a user", async () => { + const userId = "user123"; + const mockSubscriptions = [ + { + subscriptionId: "sub1", + userId, + searchType: "LOCATION_ID", + searchValue: "456", + caseName: null, + caseNumber: null, + dateAdded: new Date() + }, + { + subscriptionId: "sub2", + userId, + searchType: "CASE_NUMBER", + searchValue: "CASE123", + caseName: "Test Case", + caseNumber: "CASE123", + dateAdded: new Date() + } + ]; + + vi.mocked(queries.findSubscriptionsByUserId).mockResolvedValue(mockSubscriptions); + + const result = await getUserSubscriptions(userId); + + expect(queries.findSubscriptionsByUserId).toHaveBeenCalledWith(userId); + expect(result).toEqual(mockSubscriptions); + }); + }); + + describe("getSubscriptionsByCase", () => { + it("should return only case subscriptions for a user", async () => { + const userId = "user123"; + const mockSubscriptions = [ + { + subscriptionId: "sub1", + userId, + searchType: "CASE_NUMBER", + searchValue: "CASE123", + caseName: "Test Case", + caseNumber: "CASE123", + dateAdded: new Date() + } + ]; + + vi.mocked(queries.findByUserIdAndType).mockResolvedValue(mockSubscriptions); + + const result = await getSubscriptionsByCase(userId); + + expect(queries.findByUserIdAndType).toHaveBeenCalledWith(userId, "CASE_NUMBER"); + expect(result).toEqual(mockSubscriptions); + }); + }); + + describe("getSubscriptionsByLocation", () => { + it("should return only location subscriptions for a user", async () => { + const userId = "user123"; + const mockSubscriptions = [ + { + subscriptionId: "sub1", + userId, + searchType: "LOCATION_ID", + searchValue: "456", + caseName: null, + caseNumber: null, + dateAdded: new Date() + } + ]; + + vi.mocked(queries.findByUserIdAndType).mockResolvedValue(mockSubscriptions); + + const result = await getSubscriptionsByLocation(userId); + + expect(queries.findByUserIdAndType).toHaveBeenCalledWith(userId, "LOCATION_ID"); + expect(result).toEqual(mockSubscriptions); + }); + }); + + describe("createCaseSubscription", () => { + it("should create a case subscription successfully", async () => { + const userId = "user123"; + const caseNumber = "CASE123"; + const caseName = "Test Case"; + + vi.mocked(queries.countSubscriptionsByUserId).mockResolvedValue(5); + vi.mocked(queries.createSubscription).mockResolvedValue({ + subscriptionId: "sub123", + userId, + searchType: "CASE_NUMBER", + searchValue: caseNumber, + caseName, + caseNumber, + dateAdded: new Date() + }); + + const result = await createCaseSubscription(userId, "CASE_NUMBER", caseNumber, caseNumber, caseName); + + expect(queries.countSubscriptionsByUserId).toHaveBeenCalledWith(userId); + expect(queries.createSubscription).toHaveBeenCalledWith(userId, "CASE_NUMBER", caseNumber, caseName, caseNumber); + expect(result).toEqual( + expect.objectContaining({ + subscriptionId: "sub123", + userId, + searchType: "CASE_NUMBER", + searchValue: caseNumber, + caseName, + caseNumber + }) + ); + }); + + it("should create a case subscription with null case name", async () => { + const userId = "user123"; + const caseNumber = "CASE123"; + + vi.mocked(queries.countSubscriptionsByUserId).mockResolvedValue(5); + vi.mocked(queries.createSubscription).mockResolvedValue({ + subscriptionId: "sub123", + userId, + searchType: "CASE_NUMBER", + searchValue: caseNumber, + caseName: null, + caseNumber, + dateAdded: new Date() + }); + + const result = await createCaseSubscription(userId, "CASE_NUMBER", caseNumber, caseNumber, null); + + expect(queries.createSubscription).toHaveBeenCalledWith(userId, "CASE_NUMBER", caseNumber, null, caseNumber); + expect(result.caseName).toBeNull(); + }); + + it("should throw error when max subscriptions exceeded", async () => { + const userId = "user123"; + const caseNumber = "CASE123"; + const caseName = "Test Case"; + + vi.mocked(queries.countSubscriptionsByUserId).mockResolvedValue(50); + + await expect(createCaseSubscription(userId, "CASE_NUMBER", caseNumber, caseNumber, caseName)).rejects.toThrow("Maximum 50 subscriptions allowed"); + expect(queries.createSubscription).not.toHaveBeenCalled(); + }); + }); + + describe("getCaseSubscriptionsByUserId", () => { + it("should return formatted case subscriptions", async () => { + const userId = "user123"; + const mockCaseNumberSubscriptions = [ + { + subscriptionId: "sub1", + userId, + searchType: "CASE_NUMBER", + searchValue: "CASE123", + caseName: "Test Case", + caseNumber: "CASE123", + dateAdded: new Date("2024-01-01") + }, + { + subscriptionId: "sub2", + userId, + searchType: "CASE_NUMBER", + searchValue: "CASE456", + caseName: "Another Case", + caseNumber: "CASE456", + dateAdded: new Date("2024-01-02") + } + ]; + + vi.mocked(queries.findByUserIdAndType).mockResolvedValueOnce(mockCaseNumberSubscriptions).mockResolvedValueOnce([]); + + const result = await getCaseSubscriptionsByUserId(userId); + + expect(queries.findByUserIdAndType).toHaveBeenCalledWith(userId, "CASE_NUMBER"); + expect(queries.findByUserIdAndType).toHaveBeenCalledWith(userId, "CASE_NAME"); + expect(result).toEqual([ + { + subscriptionId: "sub1", + type: "case", + caseName: "Test Case", + caseNumber: "CASE123", + dateAdded: new Date("2024-01-01") + }, + { + subscriptionId: "sub2", + type: "case", + caseName: "Another Case", + caseNumber: "CASE456", + dateAdded: new Date("2024-01-02") + } + ]); + }); + + it("should return empty array when no case subscriptions exist", async () => { + const userId = "user123"; + + vi.mocked(queries.findByUserIdAndType).mockResolvedValue([]); + + const result = await getCaseSubscriptionsByUserId(userId); + + expect(result).toEqual([]); + }); + }); }); diff --git a/libs/subscriptions/src/repository/service.ts b/libs/subscription/src/repository/service.ts similarity index 55% rename from libs/subscriptions/src/repository/service.ts rename to libs/subscription/src/repository/service.ts index 98706240a..7b6fef59f 100644 --- a/libs/subscriptions/src/repository/service.ts +++ b/libs/subscription/src/repository/service.ts @@ -1,11 +1,16 @@ +import { getLocationById } from "@hmcts/location"; import { validateLocationId } from "../validation/validation.js"; import { countSubscriptionsByUserId, + createSubscription as createSubscriptionQuery, createSubscriptionRecord, deleteSubscriptionRecord, deleteSubscriptionsByIds as deleteSubscriptionsByIdsQuery, + findByUserIdAndType, + findByUserIdTypeAndValue, findSubscriptionById, findSubscriptionByUserAndLocation, + findSubscriptionsByIds, findSubscriptionsByUserId, findSubscriptionsWithLocationByIds, findSubscriptionsWithLocationByUserId @@ -16,20 +21,40 @@ const MAX_SUBSCRIPTIONS = 50; interface SubscriptionDto { subscriptionId: string; type: "court" | "case"; - courtOrTribunalName: string; - locationId: number; + courtOrTribunalName?: string; + locationId?: number; + caseName?: string | null; + caseNumber?: string | null; + partyName?: string; + referenceNumber?: string | null; dateAdded: Date; } function mapSubscriptionToDto( - sub: { subscriptionId: string; locationId: number; dateAdded: Date; location: { name: string; welshName: string | null } }, + sub: { + subscriptionId: string; + searchValue: string; + dateAdded: Date; + }, + location: { name: string; welshName: string; locationId: number }, locale: string ): SubscriptionDto { return { subscriptionId: sub.subscriptionId, type: "court", - courtOrTribunalName: locale === "cy" && sub.location.welshName ? sub.location.welshName : sub.location.name, - locationId: sub.locationId, + courtOrTribunalName: locale === "cy" && location.welshName ? location.welshName : location.name, + locationId: location.locationId, + dateAdded: sub.dateAdded + }; +} + +function mapCaseSubscriptionToDto(sub: { subscriptionId: string; caseName: string | null; caseNumber: string | null; dateAdded: Date }): SubscriptionDto { + return { + subscriptionId: sub.subscriptionId, + type: "case", + caseName: sub.caseName, + caseNumber: sub.caseNumber, + referenceNumber: sub.caseNumber, dateAdded: sub.dateAdded }; } @@ -89,13 +114,16 @@ export async function createMultipleSubscriptions(userId: string, locationIds: s export async function replaceUserSubscriptions(userId: string, newLocationIds: string[]) { const existingSubscriptions = await findSubscriptionsByUserId(userId); - const existingLocationIds = existingSubscriptions.map((sub) => sub.locationId); + const existingLocationIds = existingSubscriptions + .filter((sub) => sub.searchType === "LOCATION_ID") + .map((sub) => Number.parseInt(sub.searchValue, 10)) + .filter((id) => !Number.isNaN(id)); const newLocationIdNumbers = newLocationIds.map((id) => Number.parseInt(id, 10)); const newLocationIdSet = new Set(newLocationIdNumbers); const existingLocationIdSet = new Set(existingLocationIds); - const toDelete = existingSubscriptions.filter((sub) => !newLocationIdSet.has(sub.locationId)); + const toDelete = existingSubscriptions.filter((sub) => sub.searchType === "LOCATION_ID" && !newLocationIdSet.has(Number.parseInt(sub.searchValue, 10))); const toAdd = newLocationIdNumbers.filter((locId) => !existingLocationIdSet.has(locId)); if (toAdd.length > 0) { @@ -127,13 +155,33 @@ export async function replaceUserSubscriptions(userId: string, newLocationIds: s export async function getAllSubscriptionsByUserId(userId: string, locale = "en") { const subscriptions = await findSubscriptionsWithLocationByUserId(userId); - return subscriptions.map((sub) => mapSubscriptionToDto(sub, locale)); + + const dtos = await Promise.all( + subscriptions.map(async (sub) => { + const locationId = Number.parseInt(sub.searchValue, 10); + const location = await getLocationById(locationId); + if (!location) { + return null; + } + return mapSubscriptionToDto(sub, location, locale); + }) + ); + + return dtos.filter((dto): dto is SubscriptionDto => dto !== null); } -export async function getCaseSubscriptionsByUserId(userId: string, locale = "en") { - // Case subscriptions not yet implemented (VIBE-300) - // When implemented, this will query a case_subscription table - return []; +export async function getCaseSubscriptionsByUserId(userId: string) { + const caseNumberSubs = await findByUserIdAndType(userId, "CASE_NUMBER"); + const caseNameSubs = await findByUserIdAndType(userId, "CASE_NAME"); + const subscriptions = [...caseNumberSubs, ...caseNameSubs]; + + return subscriptions.map((sub) => ({ + subscriptionId: sub.subscriptionId, + type: "case" as const, + caseName: sub.caseName, + caseNumber: sub.caseNumber, + dateAdded: sub.dateAdded + })); } export async function getCourtSubscriptionsByUserId(userId: string, locale = "en") { @@ -145,8 +193,28 @@ export async function getSubscriptionDetailsForConfirmation(subscriptionIds: str return []; } - const subscriptions = await findSubscriptionsWithLocationByIds(subscriptionIds, userId); - return subscriptions.map((sub) => mapSubscriptionToDto(sub, locale)); + const subscriptions = await findSubscriptionsByIds(subscriptionIds, userId); + + const dtos = await Promise.all( + subscriptions.map(async (sub) => { + if (sub.searchType === "LOCATION_ID") { + const locationId = Number.parseInt(sub.searchValue, 10); + const location = await getLocationById(locationId); + if (!location) { + return null; + } + return mapSubscriptionToDto(sub, location, locale); + } + + if (sub.searchType === "CASE_NUMBER" || sub.searchType === "CASE_NAME") { + return mapCaseSubscriptionToDto(sub); + } + + return null; + }) + ); + + return dtos.filter((dto): dto is SubscriptionDto => dto !== null); } export async function deleteSubscriptionsByIds(subscriptionIds: string[], userId: string) { @@ -162,3 +230,36 @@ export async function deleteSubscriptionsByIds(subscriptionIds: string[], userId return count; } + +export async function getUserSubscriptions(userId: string) { + return findSubscriptionsByUserId(userId); +} + +export async function getSubscriptionsByCase(userId: string) { + return findByUserIdAndType(userId, "CASE_NUMBER"); +} + +export async function getSubscriptionsByLocation(userId: string) { + return findByUserIdAndType(userId, "LOCATION_ID"); +} + +export async function createCaseSubscription( + userId: string, + searchType: "CASE_NUMBER" | "CASE_NAME", + searchValue: string, + caseNumber: string | null, + caseName: string | null +) { + const count = await countSubscriptionsByUserId(userId); + if (count >= MAX_SUBSCRIPTIONS) { + throw new Error(`Maximum ${MAX_SUBSCRIPTIONS} subscriptions allowed`); + } + + // Check if subscription already exists for this search value and type + const existing = await findByUserIdTypeAndValue(userId, searchType, searchValue); + if (existing) { + return existing; // Return existing subscription instead of creating duplicate + } + + return createSubscriptionQuery(userId, searchType, searchValue, caseName, caseNumber); +} diff --git a/libs/subscriptions/src/validation/validation.test.ts b/libs/subscription/src/validation/validation.test.ts similarity index 100% rename from libs/subscriptions/src/validation/validation.test.ts rename to libs/subscription/src/validation/validation.test.ts diff --git a/libs/subscriptions/src/validation/validation.ts b/libs/subscription/src/validation/validation.ts similarity index 100% rename from libs/subscriptions/src/validation/validation.ts rename to libs/subscription/src/validation/validation.ts diff --git a/libs/subscription/tsconfig.json b/libs/subscription/tsconfig.json new file mode 100644 index 000000000..bc2be746b --- /dev/null +++ b/libs/subscription/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"] +} diff --git a/libs/subscriptions/prisma/schema.prisma b/libs/subscriptions/prisma/schema.prisma deleted file mode 100644 index 27a9398f3..000000000 --- a/libs/subscriptions/prisma/schema.prisma +++ /dev/null @@ -1,25 +0,0 @@ -generator client { - provider = "prisma-client-js" - output = "../../../node_modules/.prisma/client" -} - -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -model Subscription { - subscriptionId String @id @default(uuid()) @map("subscription_id") @db.Uuid - userId String @map("user_id") @db.Uuid - locationId Int @map("location_id") @db.Integer - dateAdded DateTime @default(now()) @map("date_added") - - user User @relation(fields: [userId], references: [userId], onDelete: Cascade, onUpdate: Cascade) - location Location @relation(fields: [locationId], references: [locationId]) - notificationAuditLogs NotificationAuditLog[] - - @@unique([userId, locationId], name: "unique_user_location") - @@index([userId], name: "idx_subscription_user") - @@index([locationId], name: "idx_subscription_location") - @@map("subscription") -} diff --git a/libs/subscriptions/src/index.ts b/libs/subscriptions/src/index.ts deleted file mode 100644 index a5f61f42a..000000000 --- a/libs/subscriptions/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./repository/service.js"; -export * from "./validation/validation.js"; diff --git a/libs/subscriptions/src/repository/queries.test.ts b/libs/subscriptions/src/repository/queries.test.ts deleted file mode 100644 index 5686a1470..000000000 --- a/libs/subscriptions/src/repository/queries.test.ts +++ /dev/null @@ -1,436 +0,0 @@ -import { prisma } from "@hmcts/postgres"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - countSubscriptionsByUserId, - createSubscriptionRecord, - deleteSubscriptionRecord, - deleteSubscriptionsByIds, - findSubscriptionById, - findSubscriptionByUserAndLocation, - findSubscriptionsByLocationId, - findSubscriptionsByUserId, - findSubscriptionsWithLocationByIds, - findSubscriptionsWithLocationByUserId -} from "./queries.js"; - -vi.mock("@hmcts/postgres", () => ({ - prisma: { - subscription: { - findMany: vi.fn(), - findUnique: vi.fn(), - findFirst: vi.fn(), - count: vi.fn(), - create: vi.fn(), - delete: vi.fn(), - deleteMany: vi.fn() - }, - $transaction: vi.fn() - } -})); - -describe("Subscription Queries", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe("findSubscriptionsByUserId", () => { - it("should find all subscriptions for a user", async () => { - const userId = "user123"; - const mockSubscriptions = [ - { - subscriptionId: "sub1", - userId, - locationId: 456, - dateAdded: new Date() - }, - { - subscriptionId: "sub2", - userId, - locationId: 789, - dateAdded: new Date() - } - ]; - - vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions); - - const result = await findSubscriptionsByUserId(userId); - - expect(result).toEqual(mockSubscriptions); - expect(prisma.subscription.findMany).toHaveBeenCalledWith({ - where: { - userId - }, - orderBy: { - dateAdded: "desc" - } - }); - }); - - it("should return empty array when no subscriptions found", async () => { - const userId = "user123"; - - vi.mocked(prisma.subscription.findMany).mockResolvedValue([]); - - const result = await findSubscriptionsByUserId(userId); - - expect(result).toEqual([]); - }); - }); - - describe("findSubscriptionsByLocationId", () => { - it("should find all subscriptions for a location", async () => { - const locationId = 456; - const mockSubscriptions = [ - { - subscriptionId: "sub1", - userId: "user123", - locationId, - dateAdded: new Date() - }, - { - subscriptionId: "sub2", - userId: "user456", - locationId, - dateAdded: new Date() - } - ]; - - vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions); - - const result = await findSubscriptionsByLocationId(locationId); - - expect(result).toEqual(mockSubscriptions); - expect(prisma.subscription.findMany).toHaveBeenCalledWith({ - where: { - locationId - }, - orderBy: { - dateAdded: "desc" - } - }); - }); - - it("should return empty array when no subscriptions found", async () => { - const locationId = 456; - - vi.mocked(prisma.subscription.findMany).mockResolvedValue([]); - - const result = await findSubscriptionsByLocationId(locationId); - - expect(result).toEqual([]); - }); - }); - - describe("findSubscriptionByUserAndLocation", () => { - it("should find subscription by user and location", async () => { - const userId = "user123"; - const locationId = 456; - const mockSubscription = { - subscriptionId: "sub1", - userId, - locationId, - dateAdded: new Date() - }; - - vi.mocked(prisma.subscription.findUnique).mockResolvedValue(mockSubscription); - - const result = await findSubscriptionByUserAndLocation(userId, locationId); - - expect(result).toEqual(mockSubscription); - expect(prisma.subscription.findUnique).toHaveBeenCalledWith({ - where: { - unique_user_location: { - userId, - locationId - } - } - }); - }); - - it("should return null when subscription not found", async () => { - const userId = "user123"; - const locationId = 456; - - vi.mocked(prisma.subscription.findUnique).mockResolvedValue(null); - - const result = await findSubscriptionByUserAndLocation(userId, locationId); - - expect(result).toBeNull(); - }); - }); - - describe("findSubscriptionById", () => { - it("should find subscription by ID", async () => { - const subscriptionId = "sub123"; - const userId = "user123"; - const mockSubscription = { - subscriptionId, - userId: "user123", - locationId: 456, - dateAdded: new Date() - }; - - vi.mocked(prisma.subscription.findFirst).mockResolvedValue(mockSubscription); - - const result = await findSubscriptionById(subscriptionId, userId); - - expect(result).toEqual(mockSubscription); - expect(prisma.subscription.findFirst).toHaveBeenCalledWith({ - where: { subscriptionId, userId } - }); - }); - - it("should return null when subscription not found", async () => { - const subscriptionId = "sub123"; - const userId = "user123"; - - vi.mocked(prisma.subscription.findFirst).mockResolvedValue(null); - - const result = await findSubscriptionById(subscriptionId, userId); - - expect(result).toBeNull(); - }); - }); - - describe("countSubscriptionsByUserId", () => { - it("should count subscriptions for a user", async () => { - const userId = "user123"; - - vi.mocked(prisma.subscription.count).mockResolvedValue(5); - - const result = await countSubscriptionsByUserId(userId); - - expect(result).toBe(5); - expect(prisma.subscription.count).toHaveBeenCalledWith({ - where: { - userId - } - }); - }); - - it("should return 0 when no subscriptions found", async () => { - const userId = "user123"; - - vi.mocked(prisma.subscription.count).mockResolvedValue(0); - - const result = await countSubscriptionsByUserId(userId); - - expect(result).toBe(0); - }); - }); - - describe("createSubscriptionRecord", () => { - it("should create a new subscription", async () => { - const userId = "user123"; - const locationId = 456; - const mockSubscription = { - subscriptionId: "sub1", - userId, - locationId, - dateAdded: new Date() - }; - - vi.mocked(prisma.subscription.create).mockResolvedValue(mockSubscription); - - const result = await createSubscriptionRecord(userId, locationId); - - expect(result).toEqual(mockSubscription); - expect(prisma.subscription.create).toHaveBeenCalledWith({ - data: { - userId, - locationId - } - }); - }); - }); - - describe("deleteSubscriptionRecord", () => { - it("should delete a subscription", async () => { - const subscriptionId = "sub1"; - const userId = "user123"; - - vi.mocked(prisma.subscription.deleteMany).mockResolvedValue({ count: 1 }); - - const result = await deleteSubscriptionRecord(subscriptionId, userId); - - expect(result).toBe(1); - expect(prisma.subscription.deleteMany).toHaveBeenCalledWith({ - where: { subscriptionId, userId } - }); - }); - - it("should return 0 when subscription not found or user does not own it", async () => { - const subscriptionId = "sub1"; - const userId = "user123"; - - vi.mocked(prisma.subscription.deleteMany).mockResolvedValue({ count: 0 }); - - const result = await deleteSubscriptionRecord(subscriptionId, userId); - - expect(result).toBe(0); - }); - }); - - describe("findSubscriptionsWithLocationByUserId", () => { - it("should find subscriptions with location details", async () => { - const userId = "user123"; - const mockSubscriptions = [ - { - subscriptionId: "sub1", - userId, - locationId: 1, - dateAdded: new Date("2024-01-01"), - location: { - locationId: 1, - name: "Birmingham Crown Court", - welshName: "Welsh Birmingham Crown Court" - } - }, - { - subscriptionId: "sub2", - userId, - locationId: 2, - dateAdded: new Date("2024-01-02"), - location: { - locationId: 2, - name: "Manchester Crown Court", - welshName: null - } - } - ]; - - vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions); - - const result = await findSubscriptionsWithLocationByUserId(userId); - - expect(result).toEqual(mockSubscriptions); - expect(prisma.subscription.findMany).toHaveBeenCalledWith({ - where: { userId }, - orderBy: { dateAdded: "desc" }, - include: { location: true } - }); - }); - - it("should return empty array when no subscriptions found", async () => { - const userId = "user123"; - - vi.mocked(prisma.subscription.findMany).mockResolvedValue([]); - - const result = await findSubscriptionsWithLocationByUserId(userId); - - expect(result).toEqual([]); - }); - }); - - describe("findSubscriptionsWithLocationByIds", () => { - it("should find subscriptions with location details by IDs", async () => { - const userId = "user123"; - const subscriptionIds = ["sub-1", "sub-2"]; - const mockSubscriptions = [ - { - subscriptionId: "sub-1", - userId: "user123", - locationId: 1, - dateAdded: new Date("2024-01-01"), - location: { - locationId: 1, - name: "Birmingham Crown Court", - welshName: "Welsh Birmingham Crown Court" - } - }, - { - subscriptionId: "sub-2", - userId: "user123", - locationId: 2, - dateAdded: new Date("2024-01-02"), - location: { - locationId: 2, - name: "Manchester Crown Court", - welshName: null - } - } - ]; - - vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions); - - const result = await findSubscriptionsWithLocationByIds(subscriptionIds, userId); - - expect(result).toEqual(mockSubscriptions); - expect(prisma.subscription.findMany).toHaveBeenCalledWith({ - where: { subscriptionId: { in: subscriptionIds }, userId }, - orderBy: { dateAdded: "desc" }, - include: { location: true } - }); - }); - - it("should return empty array when no subscriptions found", async () => { - vi.mocked(prisma.subscription.findMany).mockResolvedValue([]); - - const result = await findSubscriptionsWithLocationByIds(["sub-1"], "user123"); - - expect(result).toEqual([]); - }); - }); - - describe("deleteSubscriptionsByIds", () => { - it("should delete multiple subscriptions in a transaction", async () => { - const subscriptionIds = ["sub-1", "sub-2"]; - const userId = "user123"; - const mockDeleteResult = { count: 2 }; - - const mockTransaction = vi.fn(async (callback) => { - const tx = { - subscription: { - deleteMany: vi.fn().mockResolvedValue(mockDeleteResult) - } - }; - return callback(tx); - }); - - vi.mocked(prisma.$transaction).mockImplementation(mockTransaction); - - const result = await deleteSubscriptionsByIds(subscriptionIds, userId); - - expect(result).toBe(2); - expect(prisma.$transaction).toHaveBeenCalled(); - }); - - it("should return 0 when no subscriptions match deletion criteria", async () => { - const subscriptionIds = ["sub-1"]; - const userId = "user123"; - const mockDeleteResult = { count: 0 }; - - const mockTransaction = vi.fn(async (callback) => { - const tx = { - subscription: { - deleteMany: vi.fn().mockResolvedValue(mockDeleteResult) - } - }; - return callback(tx); - }); - - vi.mocked(prisma.$transaction).mockImplementation(mockTransaction); - - const result = await deleteSubscriptionsByIds(subscriptionIds, userId); - - expect(result).toBe(0); - }); - - it("should handle transaction errors", async () => { - const subscriptionIds = ["sub-1"]; - const userId = "user123"; - - const mockTransaction = vi.fn(async () => { - throw new Error("Database error"); - }); - - vi.mocked(prisma.$transaction).mockImplementation(mockTransaction); - - await expect(deleteSubscriptionsByIds(subscriptionIds, userId)).rejects.toThrow("Database error"); - }); - }); -}); diff --git a/libs/subscriptions/src/repository/queries.ts b/libs/subscriptions/src/repository/queries.ts deleted file mode 100644 index a52e38b80..000000000 --- a/libs/subscriptions/src/repository/queries.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { prisma } from "@hmcts/postgres"; - -export async function findSubscriptionsByUserId(userId: string) { - return prisma.subscription.findMany({ - where: { - userId - }, - orderBy: { - dateAdded: "desc" - } - }); -} - -export async function findSubscriptionsByLocationId(locationId: number) { - return prisma.subscription.findMany({ - where: { - locationId - }, - orderBy: { - dateAdded: "desc" - } - }); -} - -export async function findSubscriptionByUserAndLocation(userId: string, locationId: number) { - return prisma.subscription.findUnique({ - where: { - unique_user_location: { - userId, - locationId - } - } - }); -} - -export async function countSubscriptionsByUserId(userId: string) { - return prisma.subscription.count({ - where: { - userId - } - }); -} - -export async function createSubscriptionRecord(userId: string, locationId: number) { - return prisma.subscription.create({ - data: { - userId, - locationId - } - }); -} - -export async function findSubscriptionById(subscriptionId: string, userId: string) { - return prisma.subscription.findFirst({ - where: { subscriptionId, userId } - }); -} - -export async function deleteSubscriptionRecord(subscriptionId: string, userId: string) { - const result = await prisma.subscription.deleteMany({ - where: { subscriptionId, userId } - }); - return result.count; -} - -export async function findSubscriptionsWithLocationByUserId(userId: string) { - return prisma.subscription.findMany({ - where: { userId }, - orderBy: { dateAdded: "desc" }, - include: { - location: true - } - }); -} - -export async function findSubscriptionsWithLocationByIds(subscriptionIds: string[], userId: string) { - return prisma.subscription.findMany({ - where: { - subscriptionId: { in: subscriptionIds }, - userId - }, - orderBy: { dateAdded: "desc" }, - include: { - location: true - } - }); -} - -export async function deleteSubscriptionsByIds(subscriptionIds: string[], userId: string) { - return prisma.$transaction(async (tx) => { - const deleteResult = await tx.subscription.deleteMany({ - where: { - subscriptionId: { in: subscriptionIds }, - userId - } - }); - - return deleteResult.count; - }); -} diff --git a/libs/system-admin-pages/src/pages/list-search-config-success/cy.ts b/libs/system-admin-pages/src/pages/list-search-config-success/cy.ts new file mode 100644 index 000000000..4dfb584d6 --- /dev/null +++ b/libs/system-admin-pages/src/pages/list-search-config-success/cy.ts @@ -0,0 +1,6 @@ +export const cy = { + pageTitle: "Ffurfweddiad chwilio math rhestr wedi'i ddiweddaru", + heading: "Ffurfweddiad chwilio math rhestr wedi'i ddiweddaru", + body: "Beth hoffech chi ei wneud nesaf?", + returnLink: "Rheoli mathau rhestr" +}; diff --git a/libs/system-admin-pages/src/pages/list-search-config-success/en.ts b/libs/system-admin-pages/src/pages/list-search-config-success/en.ts new file mode 100644 index 000000000..b95a23508 --- /dev/null +++ b/libs/system-admin-pages/src/pages/list-search-config-success/en.ts @@ -0,0 +1,6 @@ +export const en = { + pageTitle: "List type search configuration updated", + heading: "List type search configuration updated", + body: "What do you want to do next?", + returnLink: "Manage list types" +}; diff --git a/libs/system-admin-pages/src/pages/list-search-config-success/index.njk b/libs/system-admin-pages/src/pages/list-search-config-success/index.njk new file mode 100644 index 000000000..008e48cf1 --- /dev/null +++ b/libs/system-admin-pages/src/pages/list-search-config-success/index.njk @@ -0,0 +1,27 @@ +{% extends "layouts/base-template.njk" %} + +{% block page_content %} +
+
+ +
+

+ {{ heading }} +

+
+ +
+
+ +
+
+ +

{{ body }}

+ +

+ {{ returnLink }} +

+ +
+
+{% endblock %} diff --git a/libs/system-admin-pages/src/pages/list-search-config-success/index.test.ts b/libs/system-admin-pages/src/pages/list-search-config-success/index.test.ts new file mode 100644 index 000000000..00b3a6d39 --- /dev/null +++ b/libs/system-admin-pages/src/pages/list-search-config-success/index.test.ts @@ -0,0 +1,52 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getHandler } from "./index.js"; + +describe("list-search-config-success page", () => { + let req: Partial; + let res: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + + req = { + query: {} + }; + + res = { + render: vi.fn() + }; + }); + + describe("GET", () => { + it("should render success page in English", async () => { + await getHandler(req as Request, res as Response); + + expect(res.render).toHaveBeenCalledWith( + "list-search-config-success/index", + expect.objectContaining({ + pageTitle: "List type search configuration updated", + heading: "List type search configuration updated", + body: "What do you want to do next?", + returnLink: "Manage list types" + }) + ); + }); + + it("should render success page in Welsh", async () => { + req.query = { lng: "cy" }; + + await getHandler(req as Request, res as Response); + + expect(res.render).toHaveBeenCalledWith( + "list-search-config-success/index", + expect.objectContaining({ + pageTitle: "Ffurfweddiad chwilio math rhestr wedi'i ddiweddaru", + heading: "Ffurfweddiad chwilio math rhestr wedi'i ddiweddaru", + body: "Beth hoffech chi ei wneud nesaf?", + returnLink: "Rheoli mathau rhestr" + }) + ); + }); + }); +}); diff --git a/libs/system-admin-pages/src/pages/list-search-config-success/index.ts b/libs/system-admin-pages/src/pages/list-search-config-success/index.ts new file mode 100644 index 000000000..257250080 --- /dev/null +++ b/libs/system-admin-pages/src/pages/list-search-config-success/index.ts @@ -0,0 +1,17 @@ +import { requireRole, USER_ROLES } from "@hmcts/auth"; +import type { Request, RequestHandler, Response } from "express"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +export const getHandler = async (req: Request, res: Response) => { + const lang = req.query.lng === "cy" ? cy : en; + + res.render("list-search-config-success/index", { + pageTitle: lang.pageTitle, + heading: lang.heading, + body: lang.body, + returnLink: lang.returnLink + }); +}; + +export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler]; diff --git a/libs/system-admin-pages/src/pages/list-search-config/[listTypeId].ts b/libs/system-admin-pages/src/pages/list-search-config/[listTypeId].ts new file mode 100644 index 000000000..e074dbfdc --- /dev/null +++ b/libs/system-admin-pages/src/pages/list-search-config/[listTypeId].ts @@ -0,0 +1,83 @@ +import { requireRole, USER_ROLES } from "@hmcts/auth"; +import * as service from "@hmcts/list-search-config"; +import type { Request, RequestHandler, Response } from "express"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +export const getHandler = async (req: Request, res: Response) => { + const lang = req.query.lng === "cy" ? cy : en; + const listTypeId = Number.parseInt(req.params.listTypeId, 10); + + if (Number.isNaN(listTypeId)) { + return res.status(400).send("Invalid list type ID"); + } + + const existingConfig = await service.getConfigForListType(listTypeId); + + res.render("list-search-config/index", { + pageTitle: lang.pageTitle, + heading: lang.heading, + body: lang.body, + caseNumberFieldLabel: lang.caseNumberFieldLabel, + caseNameFieldLabel: lang.caseNameFieldLabel, + saveButton: lang.saveButton, + errorSummaryTitle: lang.errorSummaryTitle, + data: { + caseNumberFieldName: existingConfig?.caseNumberFieldName || "", + caseNameFieldName: existingConfig?.caseNameFieldName || "" + }, + listTypeId + }); +}; + +export const postHandler = async (req: Request, res: Response) => { + const lang = req.query.lng === "cy" ? cy : en; + const listTypeId = Number.parseInt(req.params.listTypeId, 10); + + if (Number.isNaN(listTypeId)) { + return res.status(400).send("Invalid list type ID"); + } + + const { caseNumberFieldName, caseNameFieldName } = req.body; + + const result = await service.saveConfig(listTypeId, caseNumberFieldName, caseNameFieldName); + + if (!result.success && result.errors) { + const errors = result.errors.map((error) => ({ + text: error.message, + href: error.field ? `#${error.field.toLowerCase().replace(/\s+/g, "-")}` : undefined + })); + + const fieldErrors: Record = {}; + for (const error of result.errors) { + if (error.field === "Case number field name") { + fieldErrors.caseNumberFieldName = { text: error.message }; + } else if (error.field === "Case name field name") { + fieldErrors.caseNameFieldName = { text: error.message }; + } + } + + return res.render("list-search-config/index", { + pageTitle: lang.pageTitle, + heading: lang.heading, + body: lang.body, + caseNumberFieldLabel: lang.caseNumberFieldLabel, + caseNameFieldLabel: lang.caseNameFieldLabel, + saveButton: lang.saveButton, + errorSummaryTitle: lang.errorSummaryTitle, + data: { + caseNumberFieldName: caseNumberFieldName || "", + caseNameFieldName: caseNameFieldName || "" + }, + errors, + fieldErrors, + listTypeId + }); + } + + const lng = req.query.lng === "cy" ? "?lng=cy" : ""; + res.redirect(`/list-search-config-success${lng}`); +}; + +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/list-search-config/cy.ts b/libs/system-admin-pages/src/pages/list-search-config/cy.ts new file mode 100644 index 000000000..9bc28b50b --- /dev/null +++ b/libs/system-admin-pages/src/pages/list-search-config/cy.ts @@ -0,0 +1,14 @@ +export const cy = { + pageTitle: "Ffurfweddu meysydd chwilio math rhestr", + heading: "Ffurfweddu meysydd chwilio math rhestr", + body: "Rhowch yr enwau meysydd JSON a ddefnyddir i dynnu manylion achosion ar gyfer y math o restr hwn.", + caseNumberFieldLabel: "Enw maes JSON rhif achos", + caseNameFieldLabel: "Enw maes JSON enw achos", + saveButton: "Cadarnhau", + errorSummaryTitle: "Mae problem wedi codi", + errorCaseNumberRequired: "Rhowch enw maes JSON rhif yr achos", + errorCaseNumberInvalid: "Rhaid i enw maes rhif yr achos gynnwys llythrennau, rhifau a thanlinellau yn unig", + errorCaseNameRequired: "Rhowch enw maes JSON enw'r achos", + errorCaseNameInvalid: "Rhaid i enw maes enw'r achos gynnwys llythrennau, rhifau a thanlinellau yn unig", + errorAtLeastOneFieldRequired: "Rhowch o leiaf un enw maes" +}; diff --git a/libs/system-admin-pages/src/pages/list-search-config/en.ts b/libs/system-admin-pages/src/pages/list-search-config/en.ts new file mode 100644 index 000000000..a6f6b452a --- /dev/null +++ b/libs/system-admin-pages/src/pages/list-search-config/en.ts @@ -0,0 +1,14 @@ +export const en = { + pageTitle: "Configure list type search fields", + heading: "Configure list type search fields", + body: "Enter the JSON field names used to extract case details for this list type.", + caseNumberFieldLabel: "Case number JSON field name", + caseNameFieldLabel: "Case name JSON field name", + saveButton: "Confirm", + errorSummaryTitle: "There is a problem", + errorCaseNumberRequired: "Enter the case number JSON field name", + errorCaseNumberInvalid: "Case number field name must contain only letters, numbers and underscores", + errorCaseNameRequired: "Enter the case name JSON field name", + errorCaseNameInvalid: "Case name field name must contain only letters, numbers and underscores", + errorAtLeastOneFieldRequired: "Enter at least one field name" +}; diff --git a/libs/system-admin-pages/src/pages/list-search-config/index.njk b/libs/system-admin-pages/src/pages/list-search-config/index.njk new file mode 100644 index 000000000..d1d9bb238 --- /dev/null +++ b/libs/system-admin-pages/src/pages/list-search-config/index.njk @@ -0,0 +1,51 @@ +{% 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 %} + +{% block page_content %} +
+
+ + {% if errors %} + {{ govukErrorSummary({ + titleText: errorSummaryTitle, + errorList: errors + }) }} + {% endif %} + +

{{ heading }}

+ +

{{ body }}

+ +
+ {{ govukInput({ + id: "caseNumberFieldName", + name: "caseNumberFieldName", + type: "text", + label: { + text: caseNumberFieldLabel + }, + errorMessage: fieldErrors.caseNumberFieldName if fieldErrors and fieldErrors.caseNumberFieldName, + value: data.caseNumberFieldName + }) }} + + {{ govukInput({ + id: "caseNameFieldName", + name: "caseNameFieldName", + type: "text", + label: { + text: caseNameFieldLabel + }, + errorMessage: fieldErrors.caseNameFieldName if fieldErrors and fieldErrors.caseNameFieldName, + value: data.caseNameFieldName + }) }} + + {{ govukButton({ + text: saveButton + }) }} +
+ +
+
+{% endblock %} diff --git a/libs/system-admin-pages/src/pages/list-search-config/index.test.ts b/libs/system-admin-pages/src/pages/list-search-config/index.test.ts new file mode 100644 index 000000000..fb4142e2b --- /dev/null +++ b/libs/system-admin-pages/src/pages/list-search-config/index.test.ts @@ -0,0 +1,177 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getHandler, postHandler } from "./[listTypeId].js"; + +// Mock dependencies +vi.mock("@hmcts/list-search-config", () => ({ + getConfigForListType: vi.fn(), + saveConfig: vi.fn() +})); + +import * as service from "@hmcts/list-search-config"; + +describe("list-search-config page", () => { + let req: Partial; + let res: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + + req = { + query: {}, + body: {}, + params: { listTypeId: "5" } + }; + + res = { + render: vi.fn(), + redirect: vi.fn(), + status: vi.fn().mockReturnThis(), + send: vi.fn() + }; + }); + + describe("GET", () => { + it("should render list-search-config page in English", async () => { + (service.getConfigForListType as any).mockResolvedValue(null); + + await getHandler(req as Request, res as Response); + + expect(res.render).toHaveBeenCalledWith( + "list-search-config/index", + expect.objectContaining({ + pageTitle: "Configure list type search fields", + heading: "Configure list type search fields", + data: { + caseNumberFieldName: "", + caseNameFieldName: "" + } + }) + ); + }); + + it("should render list-search-config page in Welsh", async () => { + req.query = { lng: "cy" }; + (service.getConfigForListType as any).mockResolvedValue(null); + + await getHandler(req as Request, res as Response); + + expect(res.render).toHaveBeenCalledWith( + "list-search-config/index", + expect.objectContaining({ + pageTitle: "Ffurfweddu meysydd chwilio math rhestr", + heading: "Ffurfweddu meysydd chwilio math rhestr" + }) + ); + }); + + it("should load existing configuration", async () => { + (service.getConfigForListType as any).mockResolvedValue({ + caseNumberFieldName: "caseRef", + caseNameFieldName: "caseName" + }); + + await getHandler(req as Request, res as Response); + + expect(service.getConfigForListType).toHaveBeenCalledWith(5); + expect(res.render).toHaveBeenCalledWith( + "list-search-config/index", + expect.objectContaining({ + data: { + caseNumberFieldName: "caseRef", + caseNameFieldName: "caseName" + } + }) + ); + }); + + it("should return 400 for invalid list type ID", async () => { + req.params = { listTypeId: "abc" }; + + await getHandler(req as Request, res as Response); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith("Invalid list type ID"); + }); + }); + + describe("POST", () => { + it("should save configuration and redirect to success page", async () => { + req.body = { + caseNumberFieldName: "caseRef", + caseNameFieldName: "caseName" + }; + (service.saveConfig as any).mockResolvedValue({ success: true }); + + await postHandler(req as Request, res as Response); + + expect(service.saveConfig).toHaveBeenCalledWith(5, "caseRef", "caseName"); + expect(res.redirect).toHaveBeenCalledWith("/list-search-config-success"); + }); + + it("should redirect with Welsh locale", async () => { + req.query = { lng: "cy" }; + req.body = { + caseNumberFieldName: "caseRef", + caseNameFieldName: "caseName" + }; + (service.saveConfig as any).mockResolvedValue({ success: true }); + + await postHandler(req as Request, res as Response); + + expect(res.redirect).toHaveBeenCalledWith("/list-search-config-success?lng=cy"); + }); + + it("should allow saving with blank fields", async () => { + req.body = { + caseNumberFieldName: "", + caseNameFieldName: "" + }; + (service.saveConfig as any).mockResolvedValue({ success: true }); + + await postHandler(req as Request, res as Response); + + expect(service.saveConfig).toHaveBeenCalledWith(5, "", ""); + expect(res.redirect).toHaveBeenCalledWith("/list-search-config-success"); + }); + + it("should show validation errors when fields have invalid characters", async () => { + req.body = { + caseNumberFieldName: "case-number", + caseNameFieldName: "case@name" + }; + (service.saveConfig as any).mockResolvedValue({ + success: false, + errors: [ + { field: "Case number field name", message: "Case number field name must contain only letters, numbers and underscores" }, + { field: "Case name field name", message: "Case name field name must contain only letters, numbers and underscores" } + ] + }); + + await postHandler(req as Request, res as Response); + + expect(res.render).toHaveBeenCalledWith( + "list-search-config/index", + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ text: "Case number field name must contain only letters, numbers and underscores" }), + expect.objectContaining({ text: "Case name field name must contain only letters, numbers and underscores" }) + ]), + fieldErrors: expect.objectContaining({ + caseNumberFieldName: { text: "Case number field name must contain only letters, numbers and underscores" }, + caseNameFieldName: { text: "Case name field name must contain only letters, numbers and underscores" } + }) + }) + ); + }); + + it("should return 400 for invalid list type ID", async () => { + req.params = { listTypeId: "invalid" }; + + await postHandler(req as Request, res as Response); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith("Invalid list type ID"); + }); + }); +}); diff --git a/libs/system-admin-pages/src/pages/manage-list-types/cy.ts b/libs/system-admin-pages/src/pages/manage-list-types/cy.ts new file mode 100644 index 000000000..dbe317c99 --- /dev/null +++ b/libs/system-admin-pages/src/pages/manage-list-types/cy.ts @@ -0,0 +1,7 @@ +export const cy = { + pageTitle: "Rheoli mathau rhestr", + heading: "Rheoli mathau rhestr", + tableCaption: "Mathau rhestr", + nameColumnHeading: "Enw", + configureLink: "Rheoli" +}; diff --git a/libs/system-admin-pages/src/pages/manage-list-types/en.ts b/libs/system-admin-pages/src/pages/manage-list-types/en.ts new file mode 100644 index 000000000..1050b8dc1 --- /dev/null +++ b/libs/system-admin-pages/src/pages/manage-list-types/en.ts @@ -0,0 +1,7 @@ +export const en = { + pageTitle: "Manage list types", + heading: "Manage list types", + tableCaption: "List types", + nameColumnHeading: "Name", + configureLink: "Manage" +}; diff --git a/libs/system-admin-pages/src/pages/manage-list-types/index.njk b/libs/system-admin-pages/src/pages/manage-list-types/index.njk new file mode 100644 index 000000000..c895c4c26 --- /dev/null +++ b/libs/system-admin-pages/src/pages/manage-list-types/index.njk @@ -0,0 +1,31 @@ +{% extends "layouts/base-template.njk" %} + +{% block page_content %} +
+
+ +

{{ heading }}

+ + + + + + + + + + + {% for listType in listTypes %} + + + + + {% endfor %} + +
{{ tableCaption }}
{{ nameColumnHeading }}
{{ listType.name }} + {{ configureLink }} +
+ +
+
+{% endblock %} diff --git a/libs/system-admin-pages/src/pages/manage-list-types/index.test.ts b/libs/system-admin-pages/src/pages/manage-list-types/index.test.ts new file mode 100644 index 000000000..82583d9f2 --- /dev/null +++ b/libs/system-admin-pages/src/pages/manage-list-types/index.test.ts @@ -0,0 +1,96 @@ +import type { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getHandler } from "./index.js"; + +describe("manage-list-types page", () => { + let req: Partial; + let res: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + + req = { + query: {} + }; + + res = { + render: vi.fn() + }; + }); + + describe("GET", () => { + it("should render manage-list-types page in English", async () => { + await getHandler(req as Request, res as Response); + + expect(res.render).toHaveBeenCalledWith( + "manage-list-types/index", + expect.objectContaining({ + pageTitle: "Manage list types", + heading: "Manage list types", + tableCaption: "List types", + nameColumnHeading: "Name", + configureLink: "Manage" + }) + ); + }); + + it("should render manage-list-types page in Welsh", async () => { + req.query = { lng: "cy" }; + + await getHandler(req as Request, res as Response); + + expect(res.render).toHaveBeenCalledWith( + "manage-list-types/index", + expect.objectContaining({ + pageTitle: "Rheoli mathau rhestr", + heading: "Rheoli mathau rhestr", + tableCaption: "Mathau rhestr", + nameColumnHeading: "Enw", + configureLink: "Rheoli" + }) + ); + }); + + it("should render list types in alphabetical order", async () => { + await getHandler(req as Request, res as Response); + + const renderCall = (res.render as any).mock.calls[0]; + const listTypes = renderCall[1].listTypes; + + expect(listTypes).toBeDefined(); + expect(listTypes.length).toBeGreaterThan(0); + + // Verify alphabetical order + for (let i = 0; i < listTypes.length - 1; i++) { + expect(listTypes[i].name.localeCompare(listTypes[i + 1].name)).toBeLessThanOrEqual(0); + } + }); + + it("should include list type IDs and configure URLs", async () => { + await getHandler(req as Request, res as Response); + + const renderCall = (res.render as any).mock.calls[0]; + const listTypes = renderCall[1].listTypes; + + expect(listTypes).toBeDefined(); + expect(listTypes.length).toBeGreaterThan(0); + + listTypes.forEach((listType: any) => { + expect(listType).toHaveProperty("id"); + expect(listType).toHaveProperty("name"); + expect(listType).toHaveProperty("configureUrl"); + expect(listType.configureUrl).toMatch(/^\/list-search-config\/\d+$/); + }); + }); + + it("should map all mock list types", async () => { + await getHandler(req as Request, res as Response); + + const renderCall = (res.render as any).mock.calls[0]; + const listTypes = renderCall[1].listTypes; + + // We expect 23 list types from mock-list-types.ts + expect(listTypes.length).toBe(23); + }); + }); +}); diff --git a/libs/system-admin-pages/src/pages/manage-list-types/index.ts b/libs/system-admin-pages/src/pages/manage-list-types/index.ts new file mode 100644 index 000000000..8d395657f --- /dev/null +++ b/libs/system-admin-pages/src/pages/manage-list-types/index.ts @@ -0,0 +1,25 @@ +import { requireRole, USER_ROLES } from "@hmcts/auth"; +import { mockListTypes } from "@hmcts/list-types-common"; +import type { Request, RequestHandler, Response } from "express"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +export const getHandler = async (req: Request, res: Response) => { + const language = req.query.lng === "cy" ? "cy" : "en"; + const content = language === "cy" ? cy : en; + + const listTypes = mockListTypes + .map((listType) => ({ + id: listType.id, + name: listType.englishFriendlyName, + configureUrl: `/list-search-config/${listType.id}` + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + res.render("manage-list-types/index", { + ...content, + listTypes + }); +}; + +export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler]; diff --git a/libs/system-admin-pages/src/pages/system-admin-dashboard/cy.ts b/libs/system-admin-pages/src/pages/system-admin-dashboard/cy.ts index f84e470cc..47979af9a 100644 --- a/libs/system-admin-pages/src/pages/system-admin-dashboard/cy.ts +++ b/libs/system-admin-pages/src/pages/system-admin-dashboard/cy.ts @@ -40,6 +40,11 @@ export const cy = { title: "Rheoli Metadata Lleoliad", description: "Gweld, diweddaru a dileu metadata lleoliad", href: "/location-metadata-search" + }, + { + title: "Rheoli Mathau Rhestr", + description: "Gweld, creu a diweddaru ffurfweddiad math rhestr", + href: "/manage-list-types" } ] }; diff --git a/libs/system-admin-pages/src/pages/system-admin-dashboard/en.ts b/libs/system-admin-pages/src/pages/system-admin-dashboard/en.ts index c6f95e763..e74ec56cf 100644 --- a/libs/system-admin-pages/src/pages/system-admin-dashboard/en.ts +++ b/libs/system-admin-pages/src/pages/system-admin-dashboard/en.ts @@ -40,6 +40,11 @@ export const en = { title: "Manage Location Metadata", description: "View, update and remove location metadata", href: "/location-metadata-search" + }, + { + title: "Manage List Types", + description: "View, create and update list type configuration", + href: "/manage-list-types" } ] }; diff --git a/libs/system-admin-pages/src/pages/system-admin-dashboard/index.njk.test.ts b/libs/system-admin-pages/src/pages/system-admin-dashboard/index.njk.test.ts index b86edcb16..f3fc776fa 100644 --- a/libs/system-admin-pages/src/pages/system-admin-dashboard/index.njk.test.ts +++ b/libs/system-admin-pages/src/pages/system-admin-dashboard/index.njk.test.ts @@ -25,8 +25,8 @@ describe("system-admin-dashboard template", () => { expect(en.title).toBe("System Admin Dashboard"); }); - it("should have 8 tiles", () => { - expect(en.tiles).toHaveLength(8); + it("should have 9 tiles", () => { + expect(en.tiles).toHaveLength(9); }); it("should have tiles with required properties", () => { @@ -49,6 +49,7 @@ describe("system-admin-dashboard template", () => { expect(en.tiles[5].title).toBe("Bulk Create Media Accounts"); expect(en.tiles[6].title).toBe("Audit Log Viewer"); expect(en.tiles[7].title).toBe("Manage Location Metadata"); + expect(en.tiles[8].title).toBe("Manage List Types"); }); it("should have correct href for each tile", () => { @@ -60,6 +61,7 @@ describe("system-admin-dashboard template", () => { expect(en.tiles[5].href).toBe("/bulk-media-accounts"); expect(en.tiles[6].href).toBe("/audit-log-viewer"); expect(en.tiles[7].href).toBe("/location-metadata-search"); + expect(en.tiles[8].href).toBe("/manage-list-types"); }); it("should have descriptions for all tiles", () => { @@ -71,6 +73,7 @@ describe("system-admin-dashboard template", () => { expect(en.tiles[5].description).toBe("Upload a CSV file for bulk creation of media accounts"); expect(en.tiles[6].description).toBe("View audit logs on system admin actions"); expect(en.tiles[7].description).toBe("View, update and remove location metadata"); + expect(en.tiles[8].description).toBe("View, create and update list type configuration"); }); }); }); diff --git a/libs/verified-pages/package.json b/libs/verified-pages/package.json index 3054b71ff..adef321da 100644 --- a/libs/verified-pages/package.json +++ b/libs/verified-pages/package.json @@ -26,7 +26,7 @@ "lint:fix": "biome check --write ." }, "dependencies": { - "@hmcts/subscriptions": "workspace:*" + "@hmcts/subscription": "workspace:*" }, "peerDependencies": { "express": "^5.2.0" diff --git a/libs/verified-pages/src/assets/css/verified-pages.scss b/libs/verified-pages/src/assets/css/verified-pages.scss index 26c5caf8f..81d132309 100644 --- a/libs/verified-pages/src/assets/css/verified-pages.scss +++ b/libs/verified-pages/src/assets/css/verified-pages.scss @@ -41,3 +41,87 @@ color: #0b0c0c; margin-bottom: 1em; } + +// MOJ Sub-navigation component +.moj-sub-navigation { + margin-bottom: 30px; + border-bottom: 1px solid #b1b4b6; +} + +.moj-sub-navigation__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + gap: 0; +} + +.moj-sub-navigation__item { + margin: 0; + padding: 0; +} + +.moj-sub-navigation__link { + display: inline-block; + padding: 12px 20px; + text-decoration: none; + color: #1d70b8; + font-family: "GDS Transport", arial, sans-serif; + font-size: 19px; + font-weight: 700; + border-bottom: 4px solid transparent; + + &:hover { + color: #003078; + text-decoration: underline; + } + + &:focus { + outline: 3px solid #ffdd00; + outline-offset: 0; + background-color: #ffdd00; + color: #0b0c0c; + box-shadow: 0 -2px #ffdd00, 0 4px #0b0c0c; + text-decoration: none; + } + + &[aria-current="page"] { + color: #0b0c0c; + font-weight: 700; + border-bottom-color: #1d70b8; + text-decoration: none; + + &:hover { + color: #0b0c0c; + text-decoration: none; + } + } +} + +// Responsive: stack on mobile +@media (max-width: 640px) { + .moj-sub-navigation__list { + flex-direction: column; + border-bottom: 1px solid #b1b4b6; + } + + .moj-sub-navigation__item { + border-top: 1px solid #b1b4b6; + + &:first-child { + border-top: none; + } + } + + .moj-sub-navigation__link { + display: block; + padding: 15px 0; + border-bottom: none; + + &[aria-current="page"] { + border-left: 4px solid #1d70b8; + padding-left: 16px; + border-bottom: none; + } + } +} diff --git a/libs/verified-pages/src/pages/bulk-unsubscribe/cy.ts b/libs/verified-pages/src/pages/bulk-unsubscribe/cy.ts index 5272941fa..03da57abd 100644 --- a/libs/verified-pages/src/pages/bulk-unsubscribe/cy.ts +++ b/libs/verified-pages/src/pages/bulk-unsubscribe/cy.ts @@ -11,6 +11,9 @@ export const cy = { tableHeaderCourtName: "Enw'r llys neu'r tribiwnlys", bulkUnsubscribeButton: "Dileu swmp o danysgrifiadau", emptyStateMessage: "Nid oes gennych unrhyw danysgrifiadau yn y categori hwn.", + noCaseSubscriptions: "Nid oes gennych unrhyw danysgrifiadau achos.", + noCourtSubscriptions: "Nid oes gennych unrhyw danysgrifiadau llys neu dribiwnlys.", + notAvailable: "Ddim ar gael", errorSummaryTitle: "Mae yna broblem", errorNoSelectionMessage: "Mae angen o leiaf un tanysgrifiad", errorNoSelectionHref: "#subscriptions" diff --git a/libs/verified-pages/src/pages/bulk-unsubscribe/en.ts b/libs/verified-pages/src/pages/bulk-unsubscribe/en.ts index 9ab926d54..b81a889ef 100644 --- a/libs/verified-pages/src/pages/bulk-unsubscribe/en.ts +++ b/libs/verified-pages/src/pages/bulk-unsubscribe/en.ts @@ -11,6 +11,9 @@ export const en = { tableHeaderCourtName: "Court or tribunal name", bulkUnsubscribeButton: "Bulk unsubscribe", emptyStateMessage: "You do not have any subscriptions in this category.", + noCaseSubscriptions: "You do not have any case subscriptions.", + noCourtSubscriptions: "You do not have any court or tribunal subscriptions.", + notAvailable: "Not available", errorSummaryTitle: "There is a problem", errorNoSelectionMessage: "At least one subscription must be selected", errorNoSelectionHref: "#subscriptions" diff --git a/libs/verified-pages/src/pages/bulk-unsubscribe/index.njk b/libs/verified-pages/src/pages/bulk-unsubscribe/index.njk index 572b8406f..cb807342d 100644 --- a/libs/verified-pages/src/pages/bulk-unsubscribe/index.njk +++ b/libs/verified-pages/src/pages/bulk-unsubscribe/index.njk @@ -2,7 +2,6 @@ {% from "govuk/components/button/macro.njk" import govukButton %} {% from "govuk/components/checkboxes/macro.njk" import govukCheckboxes %} {% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} -{% from "govuk/components/tabs/macro.njk" import govukTabs %} {% block pageTitle %} {{ bulkUnsubscribeTitle }} - {{ serviceName }} - {{ govUk }} @@ -23,20 +22,33 @@ {% if showEmptyState %}

{{ emptyStateMessage }}

{% else %} + +
+ - {% set allTabHtml %} + {% if currentView == 'all' %} {% if hasCaseSubscriptions %} -

{{ tabSubscriptionsByCase }}

- - - - - + + + - - - - - + + + @@ -80,15 +91,12 @@ {% endif %} {% if hasCourtSubscriptions %} - {% if hasCaseSubscriptions %} -

{{ tabSubscriptionsByCourt }}

- {% endif %}
{{ tableHeaderCaseName }}{{ tableHeaderPartyName }}{{ tableHeaderReferenceNumber }}{{ tableHeaderDateAdded }} + {{ tableHeaderCaseName }}{{ tableHeaderReferenceNumber }}{{ tableHeaderDateAdded }}
{% for subscription in caseSubscriptions %}
{{ subscription.caseName }}{{ subscription.partyName }}{{ subscription.referenceNumber }}{{ subscription.dateAdded | date('D MMMM YYYY') }} + {{ subscription.caseName or notAvailable }}{{ subscription.caseNumber or notAvailable }}{{ subscription.dateAdded | date('D MMMM YYYY') }}
- - - + + - - +
{{ tableHeaderCourtName }}{{ tableHeaderDateAdded }} + {{ tableHeaderCourtName }}{{ tableHeaderDateAdded }}
{{ subscription.courtOrTribunalName }}{{ subscription.dateAdded | date('D MMMM YYYY') }} + {{ subscription.dateAdded | date('D MMMM YYYY') }}
{% endif %} - {% endset %} + {% endif %} - {% set caseTabHtml %} + {% if currentView == 'case' %} {% if hasCaseSubscriptions %} - - - - - + + + - - - - - + + + @@ -181,18 +187,18 @@
{{ tableHeaderCaseName }}{{ tableHeaderPartyName }}{{ tableHeaderReferenceNumber }}{{ tableHeaderDateAdded }} + {{ tableHeaderCaseName }}{{ tableHeaderReferenceNumber }}{{ tableHeaderDateAdded }}
{% for subscription in caseSubscriptions %}
{{ subscription.caseName }}{{ subscription.partyName }}{{ subscription.referenceNumber }}{{ subscription.dateAdded | date('D MMMM YYYY') }} + {{ subscription.caseName or notAvailable }}{{ subscription.caseNumber or notAvailable }}{{ subscription.dateAdded | date('D MMMM YYYY') }}
{% else %} -

{{ emptyStateMessage }}

+

{{ noCaseSubscriptions }}

{% endif %} - {% endset %} + {% endif %} - {% set courtTabHtml %} + {% if currentView == 'location' %} {% if hasCourtSubscriptions %} - - - + + - - +
{{ tableHeaderCourtName }}{{ tableHeaderDateAdded }} + {{ tableHeaderCourtName }}{{ tableHeaderDateAdded }}
{{ subscription.courtOrTribunalName }}{{ subscription.dateAdded | date('D MMMM YYYY') }} + {{ subscription.dateAdded | date('D MMMM YYYY') }}
{% else %} -

{{ emptyStateMessage }}

+

{{ noCourtSubscriptions }}

{% endif %} - {% endset %} - - {{ govukTabs({ - items: [ - { - label: tabAllSubscriptions + " (" + allSubscriptionsCount + ")", - id: "all-subscriptions", - panel: { - html: allTabHtml - } - }, - { - label: tabSubscriptionsByCase + " (" + caseSubscriptionsCount + ")", - id: "case-subscriptions", - panel: { - html: caseTabHtml - } - }, - { - label: tabSubscriptionsByCourt + " (" + courtSubscriptionsCount + ")", - id: "court-subscriptions", - panel: { - html: courtTabHtml - } - } - ] - }) }} + {% endif %}
{{ govukButton({ diff --git a/libs/verified-pages/src/pages/bulk-unsubscribe/index.test.ts b/libs/verified-pages/src/pages/bulk-unsubscribe/index.test.ts index ec6667426..a9429a375 100644 --- a/libs/verified-pages/src/pages/bulk-unsubscribe/index.test.ts +++ b/libs/verified-pages/src/pages/bulk-unsubscribe/index.test.ts @@ -1,6 +1,8 @@ -import * as subscriptionService from "@hmcts/subscriptions"; +import * as subscriptionService from "@hmcts/subscription"; import type { Request, Response } from "express"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; import { GET, POST } from "./index.js"; vi.mock("@hmcts/auth", () => ({ @@ -9,7 +11,7 @@ vi.mock("@hmcts/auth", () => ({ blockUserAccess: vi.fn(() => (_req: any, _res: any, next: any) => next()) })); -vi.mock("@hmcts/subscriptions", () => ({ +vi.mock("@hmcts/subscription", () => ({ getCaseSubscriptionsByUserId: vi.fn(), getCourtSubscriptionsByUserId: vi.fn() })); @@ -26,7 +28,8 @@ describe("bulk-unsubscribe", () => { body: {}, session: { save: vi.fn((callback: (err?: any) => void) => callback()) - } as any + } as any, + csrfToken: vi.fn(() => "mock-csrf-token") }; mockRes = { render: vi.fn(), @@ -39,6 +42,40 @@ describe("bulk-unsubscribe", () => { vi.clearAllMocks(); }); + describe("translations", () => { + describe("en", () => { + it("should have all required translation keys", () => { + expect(en.bulkUnsubscribeTitle).toBeDefined(); + expect(en.bulkUnsubscribeHeading).toBeDefined(); + expect(en.tabAllSubscriptions).toBeDefined(); + expect(en.tabSubscriptionsByCase).toBeDefined(); + expect(en.tabSubscriptionsByCourt).toBeDefined(); + expect(en.tableHeaderCaseName).toBeDefined(); + expect(en.tableHeaderPartyName).toBeDefined(); + expect(en.tableHeaderReferenceNumber).toBeDefined(); + expect(en.tableHeaderDateAdded).toBeDefined(); + expect(en.tableHeaderCourtName).toBeDefined(); + expect(en.bulkUnsubscribeButton).toBeDefined(); + expect(en.emptyStateMessage).toBeDefined(); + expect(en.noCaseSubscriptions).toBeDefined(); + expect(en.noCourtSubscriptions).toBeDefined(); + expect(en.notAvailable).toBeDefined(); + expect(en.errorSummaryTitle).toBeDefined(); + expect(en.errorNoSelectionMessage).toBeDefined(); + expect(en.errorNoSelectionHref).toBeDefined(); + }); + }); + + describe("cy", () => { + it("should have all required translation keys matching en", () => { + const enKeys = Object.keys(en); + const cyKeys = Object.keys(cy); + + expect(cyKeys).toEqual(enKeys); + }); + }); + }); + describe("GET", () => { it("should render page with both case and court subscriptions", async () => { const mockCaseSubscriptions = [ @@ -145,6 +182,88 @@ describe("bulk-unsubscribe", () => { }) ); }); + + it("should render page with Welsh translations when locale is cy", async () => { + mockRes.locals = { locale: "cy" }; + + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getCourtSubscriptionsByUserId).mockResolvedValue([]); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "bulk-unsubscribe/index", + expect.objectContaining({ + bulkUnsubscribeTitle: cy.bulkUnsubscribeTitle, + bulkUnsubscribeHeading: cy.bulkUnsubscribeHeading + }) + ); + }); + + it("should handle different view parameter values", async () => { + mockReq.query = { view: "case" }; + + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getCourtSubscriptionsByUserId).mockResolvedValue([]); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "bulk-unsubscribe/index", + expect.objectContaining({ + currentView: "case" + }) + ); + }); + + it("should default to 'all' view when no view parameter provided", async () => { + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getCourtSubscriptionsByUserId).mockResolvedValue([]); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "bulk-unsubscribe/index", + expect.objectContaining({ + currentView: "all" + }) + ); + }); + + it("should set navigation items", async () => { + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getCourtSubscriptionsByUserId).mockResolvedValue([]); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.locals?.navigation).toBeDefined(); + expect(mockRes.locals?.navigation?.verifiedItems).toBeDefined(); + }); + + it("should include CSRF token in render data", async () => { + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getCourtSubscriptionsByUserId).mockResolvedValue([]); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "bulk-unsubscribe/index", + expect.objectContaining({ + csrfToken: "mock-csrf-token" + }) + ); + }); + + it("should pass locale to getCourtSubscriptionsByUserId", async () => { + mockRes.locals = { locale: "cy" }; + + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getCourtSubscriptionsByUserId).mockResolvedValue([]); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(subscriptionService.getCourtSubscriptionsByUserId).toHaveBeenCalledWith("user123", "cy"); + }); }); describe("POST", () => { @@ -203,5 +322,119 @@ describe("bulk-unsubscribe", () => { expect(mockRes.redirect).toHaveBeenCalledWith("/sign-in"); }); + + it("should deduplicate selected subscription IDs", async () => { + mockReq.body = { + subscriptions: ["sub-1", "sub-2", "sub-1", "sub-2"] + }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session.bulkUnsubscribe?.selectedIds).toEqual(["sub-1", "sub-2"]); + }); + + it("should show Welsh validation error when locale is cy and no subscriptions selected", async () => { + mockRes.locals = { locale: "cy" }; + mockReq.body = {}; + + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getCourtSubscriptionsByUserId).mockResolvedValue([]); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "bulk-unsubscribe/index", + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + text: cy.errorNoSelectionMessage, + href: cy.errorNoSelectionHref + }) + ]) + }) + ); + }); + + it("should preserve view parameter when redirecting on session save error", async () => { + mockReq.body = { + subscriptions: ["sub-1"], + view: "case" + }; + mockReq.session.save = vi.fn((callback: (err?: any) => void) => callback(new Error("Session save error"))); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/bulk-unsubscribe?view=case"); + }); + + it("should redirect to bulk-unsubscribe on error during validation error rendering", async () => { + mockReq.body = { view: "court" }; + mockRes.locals = { locale: "en" }; + + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockRejectedValue(new Error("Database error")); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/bulk-unsubscribe?view=court"); + }); + + it("should use default view when view parameter not provided in POST", async () => { + mockReq.body = { + subscriptions: ["sub-1"] + }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session.bulkUnsubscribe?.selectedIds).toEqual(["sub-1"]); + expect(mockRes.redirect).toHaveBeenCalledWith("/confirm-bulk-unsubscribe"); + }); + + it("should initialize session.bulkUnsubscribe if not present", async () => { + mockReq.session = { + save: vi.fn((callback: (err?: any) => void) => callback()) + } as any; + mockReq.body = { + subscriptions: ["sub-1"] + }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session.bulkUnsubscribe).toBeDefined(); + expect(mockReq.session.bulkUnsubscribe?.selectedIds).toEqual(["sub-1"]); + }); + + it("should include CSRF token in validation error render", async () => { + mockReq.body = {}; + mockRes.locals = { locale: "en" }; + + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getCourtSubscriptionsByUserId).mockResolvedValue([]); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "bulk-unsubscribe/index", + expect.objectContaining({ + csrfToken: "mock-csrf-token" + }) + ); + }); + + it("should pass currentView when rendering validation error", async () => { + mockReq.body = { view: "case" }; + mockRes.locals = { locale: "en" }; + + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getCourtSubscriptionsByUserId).mockResolvedValue([]); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "bulk-unsubscribe/index", + expect.objectContaining({ + currentView: "case" + }) + ); + }); }); }); diff --git a/libs/verified-pages/src/pages/bulk-unsubscribe/index.ts b/libs/verified-pages/src/pages/bulk-unsubscribe/index.ts index f2a0b65a0..cbd09a553 100644 --- a/libs/verified-pages/src/pages/bulk-unsubscribe/index.ts +++ b/libs/verified-pages/src/pages/bulk-unsubscribe/index.ts @@ -1,5 +1,5 @@ import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth"; -import { getCaseSubscriptionsByUserId, getCourtSubscriptionsByUserId } from "@hmcts/subscriptions"; +import { getCaseSubscriptionsByUserId, getCourtSubscriptionsByUserId } from "@hmcts/subscription"; import type { Request, RequestHandler, Response } from "express"; import { cy } from "./cy.js"; import { en } from "./en.js"; @@ -23,9 +23,10 @@ const getHandler = async (req: Request, res: Response) => { } const userId = req.user.id; + const currentView = (req.query.view as string) || "all"; try { - const caseSubscriptions = await getCaseSubscriptionsByUserId(userId, locale); + const caseSubscriptions = await getCaseSubscriptionsByUserId(userId); const courtSubscriptions = await getCourtSubscriptionsByUserId(userId, locale); if (!res.locals.navigation) { @@ -40,6 +41,7 @@ const getHandler = async (req: Request, res: Response) => { caseSubscriptions, courtSubscriptions, previouslySelected, + currentView, hasCaseSubscriptions: caseSubscriptions.length > 0, hasCourtSubscriptions: courtSubscriptions.length > 0, showEmptyState: caseSubscriptions.length === 0 && courtSubscriptions.length === 0, @@ -61,6 +63,7 @@ const getHandler = async (req: Request, res: Response) => { caseSubscriptions: [], courtSubscriptions: [], previouslySelected: [], + currentView, hasCaseSubscriptions: false, hasCourtSubscriptions: false, showEmptyState: true, @@ -80,6 +83,8 @@ const postHandler = async (req: Request, res: Response) => { return res.redirect("/sign-in"); } + const currentView = req.body.view || "all"; + let selectedIds: string[] = []; if (Array.isArray(req.body.subscriptions)) { selectedIds = [...new Set(req.body.subscriptions as string[])]; @@ -91,7 +96,7 @@ const postHandler = async (req: Request, res: Response) => { const userId = req.user.id; try { - const caseSubscriptions = await getCaseSubscriptionsByUserId(userId, locale); + const caseSubscriptions = await getCaseSubscriptionsByUserId(userId); const courtSubscriptions = await getCourtSubscriptionsByUserId(userId, locale); if (!res.locals.navigation) { @@ -104,6 +109,7 @@ const postHandler = async (req: Request, res: Response) => { caseSubscriptions, courtSubscriptions, previouslySelected: [], + currentView, hasCaseSubscriptions: caseSubscriptions.length > 0, hasCourtSubscriptions: courtSubscriptions.length > 0, showEmptyState: caseSubscriptions.length === 0 && courtSubscriptions.length === 0, @@ -120,7 +126,7 @@ const postHandler = async (req: Request, res: Response) => { }); } catch (error) { console.error("Error rendering validation error:", error); - return res.redirect("/bulk-unsubscribe"); + return res.redirect(`/bulk-unsubscribe?view=${currentView}`); } } @@ -131,7 +137,7 @@ const postHandler = async (req: Request, res: Response) => { req.session.save((err?: any) => { if (err) { - return res.redirect("/bulk-unsubscribe"); + return res.redirect(`/bulk-unsubscribe?view=${currentView}`); } res.redirect("/confirm-bulk-unsubscribe"); }); diff --git a/libs/verified-pages/src/pages/case-name-search-results/cy.ts b/libs/verified-pages/src/pages/case-name-search-results/cy.ts new file mode 100644 index 000000000..33a162b9f --- /dev/null +++ b/libs/verified-pages/src/pages/case-name-search-results/cy.ts @@ -0,0 +1,15 @@ +export const cy = { + title: "Canlyniadau’r chwiliad yn ôl tanysgrifiad i restr achosion", + heading: "Canlyniadau’r chwiliad yn ôl tanysgrifiad i restr achosion", + foundText: "wedi dod i hyd i ganlyniad(au)", + tableHeaderSelect: "Dewiswch ganlyniad", + tableHeaderCaseName: "Enw'r achos", + tableHeaderCaseNumber: "Cyfeirnod", + selectLabel: "Dewis", + notAvailable: "Ddim ar gael", + continueButton: "Parhau", + back: "Yn ôl", + errorPrefix: "Gwall", + errorSummaryTitle: "Mae problem wedi codi", + errorNoSelection: "Dewiswch o leiaf un achos i barhau" +}; diff --git a/libs/verified-pages/src/pages/case-name-search-results/en.ts b/libs/verified-pages/src/pages/case-name-search-results/en.ts new file mode 100644 index 000000000..42aab0d2d --- /dev/null +++ b/libs/verified-pages/src/pages/case-name-search-results/en.ts @@ -0,0 +1,15 @@ +export const en = { + title: "Subscription case search results", + heading: "Subscription case search results", + foundText: "found", + tableHeaderSelect: "Select a result", + tableHeaderCaseName: "Case name", + tableHeaderCaseNumber: "Reference number", + selectLabel: "Select", + notAvailable: "Not available", + continueButton: "Continue", + back: "Back", + errorPrefix: "Error", + errorSummaryTitle: "There is a problem", + errorNoSelection: "Select at least one case to continue" +}; diff --git a/libs/verified-pages/src/pages/case-name-search-results/index.njk b/libs/verified-pages/src/pages/case-name-search-results/index.njk new file mode 100644 index 000000000..fac9a7d2c --- /dev/null +++ b/libs/verified-pages/src/pages/case-name-search-results/index.njk @@ -0,0 +1,68 @@ +{% extends "layouts/base-template.njk" %} +{% from "govuk/components/checkboxes/macro.njk" import govukCheckboxes %} +{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} +{% from "govuk/components/button/macro.njk" import govukButton %} +{% from "govuk/components/back-link/macro.njk" import govukBackLink %} + +{% block pageTitle %} + {% if errors %} + {{ errorPrefix }} - {{ title }} - {{ serviceName }} - {{ govUk }} + {% else %} + {{ title }} - {{ serviceName }} - {{ govUk }} + {% endif %} +{% endblock %} + +{% block page_content %} +
+
+
+ + {% if errors %} + {{ govukErrorSummary({ + titleText: errors.titleText, + errorList: errors.errorList + }) }} + {% endif %} + +

{{ heading }}

+ +

{{ results.length }} {{ foundText }}

+ + + + + + + + + + + + + + {% for result in results %} + + + + + + {% endfor %} + +
{{ tableHeaderSelect }}{{ tableHeaderCaseName }}{{ tableHeaderCaseNumber }}
+
+ + +
+
{{ result.caseName or notAvailable }}{{ result.caseNumber or notAvailable }}
+ + {{ govukButton({ + text: continueButton + }) }} + + +
+
+
+{% endblock %} diff --git a/libs/verified-pages/src/pages/case-name-search-results/index.test.ts b/libs/verified-pages/src/pages/case-name-search-results/index.test.ts new file mode 100644 index 000000000..23643feb2 --- /dev/null +++ b/libs/verified-pages/src/pages/case-name-search-results/index.test.ts @@ -0,0 +1,207 @@ +import type { Request, Response } from "express"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; +import { GET, POST } from "./index.js"; + +vi.mock("@hmcts/auth", () => ({ + buildVerifiedUserNavigation: vi.fn(() => []), + requireAuth: vi.fn(() => (_req: any, _res: any, next: any) => next()), + blockUserAccess: vi.fn(() => (_req: any, _res: any, next: any) => next()) +})); + +describe("case-name-search-results", () => { + let mockReq: Partial; + let mockRes: Partial; + + beforeEach(() => { + mockReq = { + body: {}, + session: { + caseSearch: { + nameResults: [ + { id: "1", caseNumber: "12341234", caseName: "Beta Case", artefactId: "art1" }, + { id: "2", caseNumber: "56785678", caseName: "Alpha Case", artefactId: "art2" } + ] + }, + emailSubscriptions: { + pendingCaseSubscriptions: [] + } + } as any, + path: "/case-name-search-results", + csrfToken: vi.fn(() => "mock-csrf-token") + }; + mockRes = { + render: vi.fn(), + redirect: vi.fn(), + locals: {} + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + console.log = vi.fn(); + }); + + describe("translations", () => { + describe("en", () => { + it("should have all required translation keys", () => { + expect(en.title).toBeDefined(); + expect(en.heading).toBeDefined(); + expect(en.foundText).toBeDefined(); + expect(en.tableHeaderCaseName).toBeDefined(); + expect(en.tableHeaderCaseNumber).toBeDefined(); + expect(en.continueButton).toBeDefined(); + expect(en.errorNoSelection).toBeDefined(); + expect(en.errorSummaryTitle).toBeDefined(); + }); + }); + + describe("cy", () => { + it("should have all required translation keys matching en", () => { + const enKeys = Object.keys(en); + const cyKeys = Object.keys(cy); + + expect(cyKeys).toEqual(enKeys); + }); + }); + }); + + describe("GET", () => { + it("should redirect to search page when no results in session", async () => { + mockReq.session = { caseSearch: { nameResults: [] } } as any; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/case-name-search"); + }); + + it("should render results page with sorted cases", async () => { + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-name-search-results/index", + expect.objectContaining({ + results: [expect.objectContaining({ caseName: "Alpha Case" }), expect.objectContaining({ caseName: "Beta Case" })] + }) + ); + }); + + it("should render page with English translations", async () => { + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-name-search-results/index", + expect.objectContaining({ + title: en.title, + heading: en.heading, + csrfToken: "mock-csrf-token" + }) + ); + }); + + it("should render page with Welsh translations when locale is cy", async () => { + mockRes.locals = { locale: "cy" }; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-name-search-results/index", + expect.objectContaining({ + title: cy.title, + heading: cy.heading + }) + ); + }); + }); + + describe("POST", () => { + it("should show validation error when no cases are selected", async () => { + mockReq.body = { selectedCases: [] }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-name-search-results/index", + expect.objectContaining({ + errors: expect.objectContaining({ + errorList: expect.arrayContaining([ + expect.objectContaining({ + text: en.errorNoSelection, + href: "#selectedCases" + }) + ]) + }) + }) + ); + }); + + it("should add selected cases to pending subscriptions and redirect", async () => { + mockReq.body = { selectedCases: ["1", "2"] }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session.emailSubscriptions?.pendingCaseSubscriptions).toHaveLength(2); + expect(mockReq.session.emailSubscriptions?.pendingCaseSubscriptions).toEqual([ + expect.objectContaining({ id: "1", searchType: "CASE_NAME" }), + expect.objectContaining({ id: "2", searchType: "CASE_NAME" }) + ]); + expect(mockRes.redirect).toHaveBeenCalledWith("/pending-subscriptions"); + }); + + it("should handle single case selection", async () => { + mockReq.body = { selectedCases: "1" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session.emailSubscriptions?.pendingCaseSubscriptions).toHaveLength(1); + expect(mockReq.session.emailSubscriptions?.pendingCaseSubscriptions[0]).toEqual(expect.objectContaining({ id: "1", searchType: "CASE_NAME" })); + expect(mockRes.redirect).toHaveBeenCalledWith("/pending-subscriptions"); + }); + + it("should deduplicate already pending cases", async () => { + mockReq.session.emailSubscriptions = { + pendingCaseSubscriptions: [{ id: "1", caseNumber: "12341234", caseName: "Beta Case", artefactId: "art1", searchType: "CASE_NAME" }] + } as any; + mockReq.body = { selectedCases: ["1", "2"] }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session.emailSubscriptions?.pendingCaseSubscriptions).toHaveLength(2); + }); + + it("should initialize emailSubscriptions if not present", async () => { + mockReq.session = { + caseSearch: { + nameResults: [{ id: "1", caseNumber: "12341234", caseName: "Test", artefactId: "art1" }] + } + } as any; + mockReq.body = { selectedCases: "1" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session.emailSubscriptions).toBeDefined(); + expect(mockReq.session.emailSubscriptions?.pendingCaseSubscriptions).toBeDefined(); + }); + + it("should show Welsh error messages when locale is cy", async () => { + mockRes.locals = { locale: "cy" }; + mockReq.body = { selectedCases: [] }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-name-search-results/index", + expect.objectContaining({ + errors: expect.objectContaining({ + errorList: expect.arrayContaining([ + expect.objectContaining({ + text: cy.errorNoSelection + }) + ]) + }) + }) + ); + }); + }); +}); diff --git a/libs/verified-pages/src/pages/case-name-search-results/index.ts b/libs/verified-pages/src/pages/case-name-search-results/index.ts new file mode 100644 index 000000000..11c0a3f39 --- /dev/null +++ b/libs/verified-pages/src/pages/case-name-search-results/index.ts @@ -0,0 +1,83 @@ +import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth"; +import type { Request, RequestHandler, Response } from "express"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +const getHandler = async (req: Request, res: Response) => { + const locale = res.locals.locale || "en"; + const t = locale === "cy" ? cy : en; + + const searchResults = req.session.caseSearch?.nameResults || []; + + if (searchResults.length === 0) { + return res.redirect("/case-name-search"); + } + + // Sort results by case name + const sortedResults = [...searchResults].sort((a: any, b: any) => (a.caseName || "").localeCompare(b.caseName || "")); + + if (!res.locals.navigation) { + res.locals.navigation = {}; + } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + + res.render("case-name-search-results/index", { + ...t, + results: sortedResults, + csrfToken: (req as any).csrfToken?.() || "" + }); +}; + +const postHandler = async (req: Request, res: Response) => { + const locale = res.locals.locale || "en"; + const t = locale === "cy" ? cy : en; + + const selectedCaseIds = req.body.selectedCases; + + if (!selectedCaseIds || (Array.isArray(selectedCaseIds) && selectedCaseIds.length === 0)) { + const searchResults = req.session.caseSearch?.nameResults || []; + + if (!res.locals.navigation) { + res.locals.navigation = {}; + } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + + return res.render("case-name-search-results/index", { + ...t, + results: searchResults, + errors: { + titleText: t.errorSummaryTitle, + errorList: [{ text: t.errorNoSelection, href: "#selectedCases" }] + }, + csrfToken: (req as any).csrfToken?.() || "" + }); + } + + const selectedIds = Array.isArray(selectedCaseIds) ? selectedCaseIds : [selectedCaseIds]; + const searchResults = req.session.caseSearch?.nameResults || []; + const selectedCases = searchResults.filter((result: any) => selectedIds.includes(result.id)); + + if (!req.session.emailSubscriptions) { + req.session.emailSubscriptions = {}; + } + + if (!req.session.emailSubscriptions.pendingCaseSubscriptions) { + req.session.emailSubscriptions.pendingCaseSubscriptions = []; + } + + // Add searchType to indicate this was found by case name + const casesWithSearchType = selectedCases.map((c: any) => ({ + ...c, + searchType: "CASE_NAME" + })); + + // Add only cases that don't already exist (deduplicate by id) + const existingIds = req.session.emailSubscriptions.pendingCaseSubscriptions.map((c: any) => c.id); + const newCases = casesWithSearchType.filter((c: any) => !existingIds.includes(c.id)); + req.session.emailSubscriptions.pendingCaseSubscriptions.push(...newCases); + + res.redirect("/pending-subscriptions"); +}; + +export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler]; +export const POST: RequestHandler[] = [requireAuth(), blockUserAccess(), postHandler]; diff --git a/libs/verified-pages/src/pages/case-name-search/cy.ts b/libs/verified-pages/src/pages/case-name-search/cy.ts new file mode 100644 index 000000000..1837c8d74 --- /dev/null +++ b/libs/verified-pages/src/pages/case-name-search/cy.ts @@ -0,0 +1,11 @@ +export const cy = { + title: "Beth yw enw'r achos?", + heading: "Beth yw enw'r achos?", + caseNameLabel: "Beth yw enw'r achos?", + caseNameHint: "Er enghraifft, Smith.", + continueButton: "Parhau", + errorRequired: "Nodwch o leiaf 3 nod", + errorNoResults: "Does yna ddim yn cyfateb i'r meini prawf", + errorNoResultsField: "Darparwch enw cywir ar gyfer yr achos", + errorSummaryTitle: "Mae problem wedi codi" +}; diff --git a/libs/verified-pages/src/pages/case-name-search/en.ts b/libs/verified-pages/src/pages/case-name-search/en.ts new file mode 100644 index 000000000..fae0812cc --- /dev/null +++ b/libs/verified-pages/src/pages/case-name-search/en.ts @@ -0,0 +1,11 @@ +export const en = { + title: "What is the name of the case?", + heading: "What is the name of the case?", + caseNameLabel: "What is the name of the case?", + caseNameHint: "For example, Smith.", + continueButton: "Continue", + errorRequired: "Please enter a minimum of 3 characters", + errorNoResults: "There is nothing matching your criteria", + errorNoResultsField: "Please provide a correct case name", + errorSummaryTitle: "There is a problem" +}; diff --git a/libs/verified-pages/src/pages/case-name-search/index.njk b/libs/verified-pages/src/pages/case-name-search/index.njk new file mode 100644 index 000000000..98a330c32 --- /dev/null +++ b/libs/verified-pages/src/pages/case-name-search/index.njk @@ -0,0 +1,41 @@ +{% 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 %} + +{% block content %} +
+
+ + {% if errorSummary %} + {{ govukErrorSummary(errorSummary) }} + {% endif %} + +

{{ heading }}

+ +
+ + + {{ govukInput({ + id: "caseName", + name: "caseName", + classes: "govuk-!-width-one-half", + label: { + text: caseNameLabel, + classes: "govuk-visually-hidden" + }, + hint: { + text: caseNameHint + }, + errorMessage: fieldErrors.caseName if fieldErrors and fieldErrors.caseName, + value: data.caseName if data + }) }} + + {{ govukButton({ + text: continueButton + }) }} +
+ +
+
+{% endblock %} diff --git a/libs/verified-pages/src/pages/case-name-search/index.test.ts b/libs/verified-pages/src/pages/case-name-search/index.test.ts new file mode 100644 index 000000000..bfc565867 --- /dev/null +++ b/libs/verified-pages/src/pages/case-name-search/index.test.ts @@ -0,0 +1,224 @@ +import { searchByCaseName } from "@hmcts/subscription"; +import type { Request, Response } from "express"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; +import { GET, POST } from "./index.js"; + +vi.mock("@hmcts/auth", () => ({ + buildVerifiedUserNavigation: vi.fn(() => []), + requireAuth: vi.fn(() => (_req: any, _res: any, next: any) => next()), + blockUserAccess: vi.fn(() => (_req: any, _res: any, next: any) => next()) +})); + +vi.mock("@hmcts/subscription", () => ({ + searchByCaseName: vi.fn() +})); + +describe("case-name-search", () => { + let mockReq: Partial; + let mockRes: Partial; + + beforeEach(() => { + mockReq = { + body: {}, + session: {} as any, + path: "/case-name-search", + csrfToken: vi.fn(() => "mock-csrf-token") + }; + mockRes = { + render: vi.fn(), + redirect: vi.fn(), + locals: {} + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + console.log = vi.fn(); + }); + + describe("translations", () => { + describe("en", () => { + it("should have all required translation keys", () => { + expect(en.title).toBeDefined(); + expect(en.heading).toBeDefined(); + expect(en.caseNameLabel).toBeDefined(); + expect(en.caseNameHint).toBeDefined(); + expect(en.continueButton).toBeDefined(); + expect(en.errorRequired).toBeDefined(); + expect(en.errorNoResults).toBeDefined(); + expect(en.errorSummaryTitle).toBeDefined(); + }); + + it("should have correct title", () => { + expect(en.title).toBe("What is the name of the case?"); + }); + }); + + describe("cy", () => { + it("should have all required translation keys matching en", () => { + const enKeys = Object.keys(en); + const cyKeys = Object.keys(cy); + + expect(cyKeys).toEqual(enKeys); + }); + + it("should have Welsh translations", () => { + expect(cy.title).toBe("Beth yw enw'r achos?"); + expect(cy.continueButton).toBe("Parhau"); + }); + }); + }); + + describe("GET", () => { + it("should render page with English translations", async () => { + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-name-search/index", + expect.objectContaining({ + title: en.title, + heading: en.heading, + csrfToken: "mock-csrf-token" + }) + ); + }); + + it("should render page with Welsh translations when locale is cy", async () => { + mockRes.locals = { locale: "cy" }; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-name-search/index", + expect.objectContaining({ + title: cy.title, + heading: cy.heading + }) + ); + }); + + it("should set navigation items", async () => { + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.locals?.navigation).toBeDefined(); + expect(mockRes.locals?.navigation?.verifiedItems).toBeDefined(); + }); + }); + + describe("POST", () => { + it("should show validation error when case name is empty", async () => { + mockReq.body = { caseName: "" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-name-search/index", + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + text: en.errorRequired, + href: "#caseName" + }) + ]), + fieldErrors: expect.objectContaining({ + caseName: expect.objectContaining({ text: en.errorRequired }) + }) + }) + ); + }); + + it("should show validation error when case name is less than 3 characters", async () => { + mockReq.body = { caseName: "ab" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-name-search/index", + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + text: en.errorRequired + }) + ]) + }) + ); + }); + + it("should redirect to results page when cases are found", async () => { + const mockResults = [ + { id: "1", caseNumber: "12341234", caseName: "Test Case", artefactId: "art1" }, + { id: "2", caseNumber: "56785678", caseName: "Another Case", artefactId: "art2" } + ]; + vi.mocked(searchByCaseName).mockResolvedValue(mockResults); + mockReq.body = { caseName: "Test" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(searchByCaseName).toHaveBeenCalledWith("Test"); + expect(mockReq.session.caseSearch?.nameResults).toEqual(mockResults); + expect(mockRes.redirect).toHaveBeenCalledWith("/case-name-search-results"); + }); + + it("should show no results error when no cases are found", async () => { + vi.mocked(searchByCaseName).mockResolvedValue([]); + mockReq.body = { caseName: "NonExistent" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-name-search/index", + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + text: en.errorNoResults, + href: "#caseName" + }) + ]), + fieldErrors: expect.objectContaining({ + caseName: expect.objectContaining({ text: en.errorNoResultsField }) + }), + data: expect.objectContaining({ caseName: "NonExistent" }) + }) + ); + }); + + it("should handle search errors", async () => { + vi.mocked(searchByCaseName).mockRejectedValue(new Error("Database error")); + mockReq.body = { caseName: "Test" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-name-search/index", + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + text: "An error occurred while searching" + }) + ]), + data: expect.objectContaining({ caseName: "Test" }) + }) + ); + }); + + it("should show Welsh error messages when locale is cy", async () => { + mockRes.locals = { locale: "cy" }; + mockReq.body = { caseName: "" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-name-search/index", + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + text: cy.errorRequired + }) + ]) + }) + ); + }); + }); +}); diff --git a/libs/verified-pages/src/pages/case-name-search/index.ts b/libs/verified-pages/src/pages/case-name-search/index.ts new file mode 100644 index 000000000..05ac34d49 --- /dev/null +++ b/libs/verified-pages/src/pages/case-name-search/index.ts @@ -0,0 +1,115 @@ +import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth"; +import { searchByCaseName } from "@hmcts/subscription"; +import type { Request, RequestHandler, Response } from "express"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +interface SessionData { + caseSearch?: { + nameResults?: Array<{ + id: string; + caseNumber: string | null; + caseName: string | null; + artefactId: string; + }>; + }; +} + +const getHandler = async (req: Request, res: Response) => { + const locale = res.locals.locale || "en"; + const t = locale === "cy" ? cy : en; + + if (!res.locals.navigation) { + res.locals.navigation = {}; + } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + + res.render("case-name-search/index", { + ...t, + csrfToken: (req as any).csrfToken?.() || "" + }); +}; + +const postHandler = async (req: Request, res: Response) => { + const locale = res.locals.locale || "en"; + const t = locale === "cy" ? cy : en; + const { caseName } = req.body; + + if (!caseName || caseName.trim().length < 3) { + if (!res.locals.navigation) { + res.locals.navigation = {}; + } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + + return res.render("case-name-search/index", { + ...t, + errors: [{ text: t.errorRequired, href: "#caseName" }], + errorSummary: { + titleText: t.errorSummaryTitle, + errorList: [{ text: t.errorRequired, href: "#caseName" }] + }, + fieldErrors: { + caseName: { text: t.errorRequired } + }, + data: { caseName }, + csrfToken: (req as any).csrfToken?.() || "" + }); + } + + try { + console.log(`[case-name-search] Searching for case name: "${caseName}"`); + const results = await searchByCaseName(caseName); + console.log(`[case-name-search] Found ${results.length} results`); + + if (results.length === 0) { + if (!res.locals.navigation) { + res.locals.navigation = {}; + } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + + return res.render("case-name-search/index", { + ...t, + errors: [{ text: t.errorNoResults, href: "#caseName" }], + errorSummary: { + titleText: t.errorSummaryTitle, + errorList: [{ text: t.errorNoResults, href: "#caseName" }] + }, + fieldErrors: { + caseName: { text: t.errorNoResultsField } + }, + data: { caseName }, + csrfToken: (req as any).csrfToken?.() || "" + }); + } + + // Store results in session + const session = req.session as SessionData; + if (!session.caseSearch) { + session.caseSearch = {}; + } + session.caseSearch.nameResults = results; + + return res.redirect("/case-name-search-results"); + } catch (error) { + console.error(`[case-name-search] Error searching for case name "${caseName}":`, error); + + if (!res.locals.navigation) { + res.locals.navigation = {}; + } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + + return res.render("case-name-search/index", { + ...t, + errors: [{ text: "An error occurred while searching", href: "#caseName" }], + errorSummary: { + titleText: t.errorSummaryTitle, + errorList: [{ text: "An error occurred while searching", href: "#caseName" }] + }, + data: { caseName }, + csrfToken: (req as any).csrfToken?.() || "" + }); + } +}; + +export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler]; +export const POST: RequestHandler[] = [requireAuth(), blockUserAccess(), postHandler]; diff --git a/libs/verified-pages/src/pages/case-number-search-results/cy.ts b/libs/verified-pages/src/pages/case-number-search-results/cy.ts new file mode 100644 index 000000000..032a94d1b --- /dev/null +++ b/libs/verified-pages/src/pages/case-number-search-results/cy.ts @@ -0,0 +1,9 @@ +export const cy = { + title: "Canlyniadau’r chwiliad yn ôl tanysgrifiad i restr achosion", + heading: "Canlyniadau’r chwiliad yn ôl tanysgrifiad i restr achosion", + foundText: "wedi dod i hyd i ganlyniad(au)", + tableHeaderCaseName: "Enw'r achos", + tableHeaderCaseNumber: "Cyfeirnod", + notAvailable: "Ddim ar gael", + continueButton: "Parhau" +}; diff --git a/libs/verified-pages/src/pages/case-number-search-results/en.ts b/libs/verified-pages/src/pages/case-number-search-results/en.ts new file mode 100644 index 000000000..64aacc73f --- /dev/null +++ b/libs/verified-pages/src/pages/case-number-search-results/en.ts @@ -0,0 +1,9 @@ +export const en = { + title: "Subscription case search results", + heading: "Subscription case search results", + foundText: "found", + tableHeaderCaseName: "Case name", + tableHeaderCaseNumber: "Reference number", + notAvailable: "Not available", + continueButton: "Continue" +}; diff --git a/libs/verified-pages/src/pages/case-number-search-results/index.njk b/libs/verified-pages/src/pages/case-number-search-results/index.njk new file mode 100644 index 000000000..c3b633c7f --- /dev/null +++ b/libs/verified-pages/src/pages/case-number-search-results/index.njk @@ -0,0 +1,44 @@ +{% extends "layouts/base-template.njk" %} +{% from "govuk/components/button/macro.njk" import govukButton %} + +{% block pageTitle %} + {{ title }} - {{ serviceName }} - {{ govUk }} +{% endblock %} + +{% block page_content %} +
+
+
+ +

{{ heading }}

+ +

{{ results.length }} {{ foundText }}

+ + + + + + + + + + {% for result in results %} + + + + + {% endfor %} + +
{{ tableHeaderCaseName }}{{ tableHeaderCaseNumber }}
{{ result.caseName or notAvailable }}{{ result.caseNumber or notAvailable }}
+ +
+ + {{ govukButton({ + text: continueButton + }) }} +
+ +
+
+
+{% endblock %} diff --git a/libs/verified-pages/src/pages/case-number-search-results/index.test.ts b/libs/verified-pages/src/pages/case-number-search-results/index.test.ts new file mode 100644 index 000000000..47b396674 --- /dev/null +++ b/libs/verified-pages/src/pages/case-number-search-results/index.test.ts @@ -0,0 +1,173 @@ +import type { Request, Response } from "express"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; +import { GET, POST } from "./index.js"; + +vi.mock("@hmcts/auth", () => ({ + buildVerifiedUserNavigation: vi.fn(() => []), + requireAuth: vi.fn(() => (_req: any, _res: any, next: any) => next()), + blockUserAccess: vi.fn(() => (_req: any, _res: any, next: any) => next()) +})); + +describe("case-number-search-results", () => { + let mockReq: Partial; + let mockRes: Partial; + + beforeEach(() => { + mockReq = { + body: {}, + session: { + caseSearch: { + numberResults: [ + { id: "1", caseNumber: "12341234", caseName: "Test Case 1", artefactId: "art1" }, + { id: "2", caseNumber: "12341234", caseName: "Test Case 2", artefactId: "art2" } + ] + }, + emailSubscriptions: { + pendingCaseSubscriptions: [] + } + } as any, + path: "/case-number-search-results", + csrfToken: vi.fn(() => "mock-csrf-token") + }; + mockRes = { + render: vi.fn(), + redirect: vi.fn(), + locals: {} + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("translations", () => { + describe("en", () => { + it("should have all required translation keys", () => { + expect(en.title).toBeDefined(); + expect(en.heading).toBeDefined(); + expect(en.foundText).toBeDefined(); + expect(en.tableHeaderCaseName).toBeDefined(); + expect(en.tableHeaderCaseNumber).toBeDefined(); + expect(en.continueButton).toBeDefined(); + }); + }); + + describe("cy", () => { + it("should have all required translation keys matching en", () => { + const enKeys = Object.keys(en); + const cyKeys = Object.keys(cy); + + expect(cyKeys).toEqual(enKeys); + }); + }); + }); + + describe("GET", () => { + it("should redirect to search page when no results in session", async () => { + mockReq.session = { caseSearch: { numberResults: [] } } as any; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/case-number-search"); + }); + + it("should render results page with search results", async () => { + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-number-search-results/index", + expect.objectContaining({ + results: expect.arrayContaining([expect.objectContaining({ id: "1" }), expect.objectContaining({ id: "2" })]) + }) + ); + }); + + it("should render page with English translations", async () => { + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-number-search-results/index", + expect.objectContaining({ + title: en.title, + heading: en.heading, + csrfToken: "mock-csrf-token" + }) + ); + }); + + it("should render page with Welsh translations when locale is cy", async () => { + mockRes.locals = { locale: "cy" }; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-number-search-results/index", + expect.objectContaining({ + title: cy.title, + heading: cy.heading + }) + ); + }); + + it("should set navigation items", async () => { + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.locals?.navigation).toBeDefined(); + expect(mockRes.locals?.navigation?.verifiedItems).toBeDefined(); + }); + }); + + describe("POST", () => { + it("should add all results to pending subscriptions and redirect", async () => { + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session.emailSubscriptions?.pendingCaseSubscriptions).toHaveLength(2); + expect(mockReq.session.emailSubscriptions?.pendingCaseSubscriptions).toEqual([ + expect.objectContaining({ id: "1", searchType: "CASE_NUMBER" }), + expect.objectContaining({ id: "2", searchType: "CASE_NUMBER" }) + ]); + expect(mockRes.redirect).toHaveBeenCalledWith("/pending-subscriptions"); + }); + + it("should deduplicate already pending cases", async () => { + mockReq.session.emailSubscriptions = { + pendingCaseSubscriptions: [{ id: "1", caseNumber: "12341234", caseName: "Test Case 1", artefactId: "art1", searchType: "CASE_NUMBER" }] + } as any; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session.emailSubscriptions?.pendingCaseSubscriptions).toHaveLength(2); + }); + + it("should initialize emailSubscriptions if not present", async () => { + mockReq.session = { + caseSearch: { + numberResults: [{ id: "1", caseNumber: "12341234", caseName: "Test", artefactId: "art1" }] + } + } as any; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session.emailSubscriptions).toBeDefined(); + expect(mockReq.session.emailSubscriptions?.pendingCaseSubscriptions).toBeDefined(); + }); + + it("should handle empty search results", async () => { + mockReq.session = { + caseSearch: { + numberResults: [] + }, + emailSubscriptions: { + pendingCaseSubscriptions: [] + } + } as any; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session.emailSubscriptions?.pendingCaseSubscriptions).toHaveLength(0); + expect(mockRes.redirect).toHaveBeenCalledWith("/pending-subscriptions"); + }); + }); +}); diff --git a/libs/verified-pages/src/pages/case-number-search-results/index.ts b/libs/verified-pages/src/pages/case-number-search-results/index.ts new file mode 100644 index 000000000..b6cf50ffa --- /dev/null +++ b/libs/verified-pages/src/pages/case-number-search-results/index.ts @@ -0,0 +1,54 @@ +import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth"; +import type { Request, RequestHandler, Response } from "express"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +const getHandler = async (req: Request, res: Response) => { + const locale = res.locals.locale || "en"; + const t = locale === "cy" ? cy : en; + + const searchResults = req.session.caseSearch?.numberResults || []; + + if (searchResults.length === 0) { + return res.redirect("/case-number-search"); + } + + if (!res.locals.navigation) { + res.locals.navigation = {}; + } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + + res.render("case-number-search-results/index", { + ...t, + results: searchResults, + csrfToken: (req as any).csrfToken?.() || "" + }); +}; + +const postHandler = async (req: Request, res: Response) => { + const searchResults = req.session.caseSearch?.numberResults || []; + + if (!req.session.emailSubscriptions) { + req.session.emailSubscriptions = {}; + } + + if (!req.session.emailSubscriptions.pendingCaseSubscriptions) { + req.session.emailSubscriptions.pendingCaseSubscriptions = []; + } + + // Add searchType to indicate this was found by case number + const casesWithSearchType = searchResults.map((c: any) => ({ + ...c, + searchType: "CASE_NUMBER" + })); + + // Add all results from search (deduplicate by id) + const existingIds = req.session.emailSubscriptions.pendingCaseSubscriptions.map((c: any) => c.id); + const newCases = casesWithSearchType.filter((c: any) => !existingIds.includes(c.id)); + req.session.emailSubscriptions.pendingCaseSubscriptions.push(...newCases); + + res.redirect("/pending-subscriptions"); +}; + +export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler]; +export const POST: RequestHandler[] = [requireAuth(), blockUserAccess(), postHandler]; diff --git a/libs/verified-pages/src/pages/case-number-search/cy.ts b/libs/verified-pages/src/pages/case-number-search/cy.ts new file mode 100644 index 000000000..a457d88dd --- /dev/null +++ b/libs/verified-pages/src/pages/case-number-search/cy.ts @@ -0,0 +1,11 @@ +export const cy = { + title: "Beth yw’r cyfeirnod?", + heading: "Beth yw’r cyfeirnod?", + insetText: "Rhowch gyfeirnod yr achos, ID yr achos neu gyfeirnod unigryw (URN). Mae’n rhaid ichi nodi’r union gyfeirnod neu ID.", + referenceNumberLabel: "Rhif cyfeirnod", + continueButton: "Parhau", + errorRequired: "Rhowch rif cyfeirnod", + errorNoResults: "Does yna ddim yn cyfateb i'r meini prawf", + errorNoResultsField: "Rhowch gyfeirnod achos dilys", + errorSummaryTitle: "Mae problem wedi codi" +}; diff --git a/libs/verified-pages/src/pages/case-number-search/en.ts b/libs/verified-pages/src/pages/case-number-search/en.ts new file mode 100644 index 000000000..63190ceea --- /dev/null +++ b/libs/verified-pages/src/pages/case-number-search/en.ts @@ -0,0 +1,11 @@ +export const en = { + title: "What is the reference number?", + heading: "What is the reference number?", + insetText: "Please enter either a case reference number, case ID or unique reference number (URN). You must enter an exact match.", + referenceNumberLabel: "Reference number", + continueButton: "Continue", + errorRequired: "Enter a reference number", + errorNoResults: "There is nothing matching your criteria", + errorNoResultsField: "Enter a valid case reference number", + errorSummaryTitle: "There is a problem" +}; diff --git a/libs/verified-pages/src/pages/case-number-search/index.njk b/libs/verified-pages/src/pages/case-number-search/index.njk new file mode 100644 index 000000000..33837d8f1 --- /dev/null +++ b/libs/verified-pages/src/pages/case-number-search/index.njk @@ -0,0 +1,43 @@ +{% 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/inset-text/macro.njk" import govukInsetText %} + +{% block content %} +
+
+ + {% if errorSummary %} + {{ govukErrorSummary(errorSummary) }} + {% endif %} + +

{{ heading }}

+ + {{ govukInsetText({ + text: insetText + }) }} + +
+ + + {{ govukInput({ + id: "referenceNumber", + name: "referenceNumber", + classes: "govuk-!-width-one-half", + label: { + text: referenceNumberLabel, + classes: "govuk-visually-hidden" + }, + errorMessage: fieldErrors.referenceNumber if fieldErrors and fieldErrors.referenceNumber, + value: data.referenceNumber if data + }) }} + + {{ govukButton({ + text: continueButton + }) }} +
+ +
+
+{% endblock %} diff --git a/libs/verified-pages/src/pages/case-number-search/index.test.ts b/libs/verified-pages/src/pages/case-number-search/index.test.ts new file mode 100644 index 000000000..956d899a9 --- /dev/null +++ b/libs/verified-pages/src/pages/case-number-search/index.test.ts @@ -0,0 +1,235 @@ +import { searchByCaseReference } from "@hmcts/subscription"; +import type { Request, Response } from "express"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; +import { GET, POST } from "./index.js"; + +vi.mock("@hmcts/auth", () => ({ + buildVerifiedUserNavigation: vi.fn(() => []), + requireAuth: vi.fn(() => (_req: any, _res: any, next: any) => next()), + blockUserAccess: vi.fn(() => (_req: any, _res: any, next: any) => next()) +})); + +vi.mock("@hmcts/subscription", () => ({ + searchByCaseReference: vi.fn() +})); + +describe("case-number-search", () => { + let mockReq: Partial; + let mockRes: Partial; + + beforeEach(() => { + mockReq = { + body: {}, + session: {} as any, + path: "/case-number-search", + csrfToken: vi.fn(() => "mock-csrf-token") + }; + mockRes = { + render: vi.fn(), + redirect: vi.fn(), + locals: {} + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + console.error = vi.fn(); + }); + + describe("translations", () => { + describe("en", () => { + it("should have all required translation keys", () => { + expect(en.title).toBeDefined(); + expect(en.heading).toBeDefined(); + expect(en.referenceNumberLabel).toBeDefined(); + expect(en.continueButton).toBeDefined(); + expect(en.errorRequired).toBeDefined(); + expect(en.errorNoResults).toBeDefined(); + expect(en.errorSummaryTitle).toBeDefined(); + }); + + it("should have correct title", () => { + expect(en.title).toBe("What is the reference number?"); + }); + }); + + describe("cy", () => { + it("should have all required translation keys matching en", () => { + const enKeys = Object.keys(en); + const cyKeys = Object.keys(cy); + + expect(cyKeys).toEqual(enKeys); + }); + + it("should have Welsh translations", () => { + expect(cy.title).toBe("Beth yw’r cyfeirnod?"); + expect(cy.continueButton).toBe("Parhau"); + }); + }); + }); + + describe("GET", () => { + it("should render page with English translations", async () => { + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-number-search/index", + expect.objectContaining({ + title: en.title, + heading: en.heading, + csrfToken: "mock-csrf-token" + }) + ); + }); + + it("should render page with Welsh translations when locale is cy", async () => { + mockRes.locals = { locale: "cy" }; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-number-search/index", + expect.objectContaining({ + title: cy.title, + heading: cy.heading + }) + ); + }); + + it("should set navigation items", async () => { + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.locals?.navigation).toBeDefined(); + expect(mockRes.locals?.navigation?.verifiedItems).toBeDefined(); + }); + }); + + describe("POST", () => { + it("should show validation error when reference number is empty", async () => { + mockReq.body = { referenceNumber: "" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-number-search/index", + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + text: en.errorNoResults, + href: "#referenceNumber" + }) + ]), + fieldErrors: expect.objectContaining({ + referenceNumber: expect.objectContaining({ text: en.errorNoResultsField }) + }) + }) + ); + }); + + it("should show validation error when reference number is whitespace only", async () => { + mockReq.body = { referenceNumber: " " }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-number-search/index", + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + text: en.errorNoResults + }) + ]) + }) + ); + }); + + it("should redirect to results page when cases are found", async () => { + const mockResults = [ + { id: "1", caseNumber: "12341234", caseName: "Test Case", artefactId: "art1" }, + { id: "2", caseNumber: "12341234", caseName: "Another Case", artefactId: "art2" } + ]; + vi.mocked(searchByCaseReference).mockResolvedValue(mockResults); + mockReq.body = { referenceNumber: "12341234" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(searchByCaseReference).toHaveBeenCalledWith("12341234"); + expect(mockReq.session.caseSearch?.numberResults).toEqual(mockResults); + expect(mockRes.redirect).toHaveBeenCalledWith("/case-number-search-results"); + }); + + it("should show no results error when no cases are found", async () => { + vi.mocked(searchByCaseReference).mockResolvedValue([]); + mockReq.body = { referenceNumber: "NONEXISTENT" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-number-search/index", + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + text: en.errorNoResults, + href: "#referenceNumber" + }) + ]), + fieldErrors: expect.objectContaining({ + referenceNumber: expect.objectContaining({ text: en.errorNoResultsField }) + }), + data: expect.objectContaining({ referenceNumber: "NONEXISTENT" }) + }) + ); + }); + + it("should handle search errors", async () => { + vi.mocked(searchByCaseReference).mockRejectedValue(new Error("Database error")); + mockReq.body = { referenceNumber: "12341234" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-number-search/index", + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + text: "An error occurred while searching" + }) + ]), + data: expect.objectContaining({ referenceNumber: "12341234" }) + }) + ); + }); + + it("should initialize caseSearch in session if not present", async () => { + const mockResults = [{ id: "1", caseNumber: "12341234", caseName: "Test", artefactId: "art1" }]; + vi.mocked(searchByCaseReference).mockResolvedValue(mockResults); + mockReq.session = {} as any; + mockReq.body = { referenceNumber: "12341234" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session.caseSearch).toBeDefined(); + expect(mockReq.session.caseSearch?.numberResults).toEqual(mockResults); + }); + + it("should show Welsh error messages when locale is cy", async () => { + mockRes.locals = { locale: "cy" }; + mockReq.body = { referenceNumber: "" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "case-number-search/index", + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + text: cy.errorNoResults + }) + ]) + }) + ); + }); + }); +}); diff --git a/libs/verified-pages/src/pages/case-number-search/index.ts b/libs/verified-pages/src/pages/case-number-search/index.ts new file mode 100644 index 000000000..3b0ba122e --- /dev/null +++ b/libs/verified-pages/src/pages/case-number-search/index.ts @@ -0,0 +1,101 @@ +import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth"; +import { searchByCaseReference } from "@hmcts/subscription"; +import type { Request, RequestHandler, Response } from "express"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +const getHandler = async (req: Request, res: Response) => { + const locale = res.locals.locale || "en"; + const t = locale === "cy" ? cy : en; + + if (!res.locals.navigation) { + res.locals.navigation = {}; + } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + + res.render("case-number-search/index", { + ...t, + csrfToken: (req as any).csrfToken?.() || "" + }); +}; + +const postHandler = async (req: Request, res: Response) => { + const locale = res.locals.locale || "en"; + const t = locale === "cy" ? cy : en; + const { referenceNumber } = req.body; + + if (!referenceNumber || referenceNumber.trim().length === 0) { + if (!res.locals.navigation) { + res.locals.navigation = {}; + } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + + return res.render("case-number-search/index", { + ...t, + errors: [{ text: t.errorNoResults, href: "#referenceNumber" }], + errorSummary: { + titleText: t.errorSummaryTitle, + errorList: [{ text: t.errorNoResults, href: "#referenceNumber" }] + }, + fieldErrors: { + referenceNumber: { text: t.errorNoResultsField } + }, + data: { referenceNumber }, + csrfToken: (req as any).csrfToken?.() || "" + }); + } + + try { + const results = await searchByCaseReference(referenceNumber); + + if (results.length === 0) { + if (!res.locals.navigation) { + res.locals.navigation = {}; + } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + + return res.render("case-number-search/index", { + ...t, + errors: [{ text: t.errorNoResults, href: "#referenceNumber" }], + errorSummary: { + titleText: t.errorSummaryTitle, + errorList: [{ text: t.errorNoResults, href: "#referenceNumber" }] + }, + fieldErrors: { + referenceNumber: { text: t.errorNoResultsField } + }, + data: { referenceNumber }, + csrfToken: (req as any).csrfToken?.() || "" + }); + } + + // Store results in session + if (!req.session.caseSearch) { + req.session.caseSearch = {}; + } + req.session.caseSearch.numberResults = results; + + return res.redirect("/case-number-search-results"); + } catch (error) { + console.error("Error searching for case by reference:", error); + + if (!res.locals.navigation) { + res.locals.navigation = {}; + } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + + return res.render("case-number-search/index", { + ...t, + errors: [{ text: "An error occurred while searching", href: "#referenceNumber" }], + errorSummary: { + titleText: t.errorSummaryTitle, + errorList: [{ text: "An error occurred while searching", href: "#referenceNumber" }] + }, + data: { referenceNumber }, + csrfToken: (req as any).csrfToken?.() || "" + }); + } +}; + +export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler]; +export const POST: RequestHandler[] = [requireAuth(), blockUserAccess(), postHandler]; diff --git a/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/cy.ts b/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/cy.ts index 8e7c52634..e8fb9f0f5 100644 --- a/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/cy.ts +++ b/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/cy.ts @@ -1,13 +1,14 @@ export const cy = { confirmTitle: "Ydych chi'n siŵr eich bod eisiau dileu'r tanysgrifiadau hyn?", - confirmHeading: "Ydych chi’n siŵr eich bod eisiau dileu’r tanysgrifiadau hyn?", + confirmHeading: "Ydych chi'n siŵr eich bod eisiau dileu'r tanysgrifiadau hyn?", tabSubscriptionsByCase: "Tanysgrifiadau yn ôl achos", tabSubscriptionsByCourt: "Tanysgrifiadau yn ôl llys neu dribiwnlys", tableHeaderCaseName: "Enw'r achos", tableHeaderPartyName: "Enw'r parti (partïon)", tableHeaderReferenceNumber: "Cyfeirnod", tableHeaderDateAdded: "Dyddiad wedi'i ychwanegu", - tableHeaderCourtName: "Enw’r llys neu’r tribiwnlys", + tableHeaderCourtName: "Enw'r llys neu'r tribiwnlys", + notAvailable: "Ddim ar gael", radioYes: "Ydw", radioNo: "Nac ydw", continueButton: "Parhau", diff --git a/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/en.ts b/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/en.ts index 8ee086269..ea79f22fc 100644 --- a/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/en.ts +++ b/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/en.ts @@ -8,6 +8,7 @@ export const en = { tableHeaderReferenceNumber: "Reference number", tableHeaderDateAdded: "Date added", tableHeaderCourtName: "Court or tribunal name", + notAvailable: "Not available", radioYes: "Yes", radioNo: "No", continueButton: "Continue", diff --git a/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/index.njk b/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/index.njk index 0965c0b8b..fa076b59c 100644 --- a/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/index.njk +++ b/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/index.njk @@ -24,19 +24,17 @@ - - - - + + + {% for subscription in caseSubscriptions %} - - - - + + + {% endfor %} @@ -50,15 +48,15 @@
{{ tableHeaderCaseName }}{{ tableHeaderPartyName }}{{ tableHeaderReferenceNumber }}{{ tableHeaderDateAdded }}{{ tableHeaderCaseName }}{{ tableHeaderReferenceNumber }}{{ tableHeaderDateAdded }}
{{ subscription.caseName }}{{ subscription.partyName }}{{ subscription.referenceNumber }}{{ subscription.dateAdded | date('D MMMM YYYY') }}{{ subscription.caseName or notAvailable }}{{ subscription.referenceNumber or notAvailable }}{{ subscription.dateAdded | date('D MMMM YYYY') }}
- - + + {% for subscription in courtSubscriptions %} - + {% endfor %} diff --git a/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/index.test.ts b/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/index.test.ts index ed93da207..736c31890 100644 --- a/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/index.test.ts +++ b/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/index.test.ts @@ -1,6 +1,8 @@ -import * as subscriptionService from "@hmcts/subscriptions"; +import * as subscriptionService from "@hmcts/subscription"; import type { Request, Response } from "express"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; import { GET, POST } from "./index.js"; vi.mock("@hmcts/auth", () => ({ @@ -9,7 +11,7 @@ vi.mock("@hmcts/auth", () => ({ blockUserAccess: vi.fn(() => (_req: any, _res: any, next: any) => next()) })); -vi.mock("@hmcts/subscriptions", () => ({ +vi.mock("@hmcts/subscription", () => ({ getSubscriptionDetailsForConfirmation: vi.fn(), deleteSubscriptionsByIds: vi.fn() })); @@ -28,7 +30,8 @@ describe("confirm-bulk-unsubscribe", () => { selectedIds: ["sub-1", "sub-2"] }, save: vi.fn((callback: (err?: any) => void) => callback()) - } as any + } as any, + csrfToken: vi.fn(() => "mock-csrf-token") }; mockRes = { render: vi.fn(), @@ -41,6 +44,38 @@ describe("confirm-bulk-unsubscribe", () => { vi.clearAllMocks(); }); + describe("translations", () => { + describe("en", () => { + it("should have all required translation keys", () => { + expect(en.confirmTitle).toBeDefined(); + expect(en.confirmHeading).toBeDefined(); + expect(en.tabSubscriptionsByCase).toBeDefined(); + expect(en.tabSubscriptionsByCourt).toBeDefined(); + expect(en.tableHeaderCaseName).toBeDefined(); + expect(en.tableHeaderPartyName).toBeDefined(); + expect(en.tableHeaderReferenceNumber).toBeDefined(); + expect(en.tableHeaderDateAdded).toBeDefined(); + expect(en.tableHeaderCourtName).toBeDefined(); + expect(en.notAvailable).toBeDefined(); + expect(en.radioYes).toBeDefined(); + expect(en.radioNo).toBeDefined(); + expect(en.continueButton).toBeDefined(); + expect(en.errorSummaryTitle).toBeDefined(); + expect(en.errorNoRadioMessage).toBeDefined(); + expect(en.errorNoRadioHref).toBeDefined(); + }); + }); + + describe("cy", () => { + it("should have all required translation keys matching en", () => { + const enKeys = Object.keys(en); + const cyKeys = Object.keys(cy); + + expect(cyKeys).toEqual(enKeys); + }); + }); + }); + describe("GET", () => { it("should render confirmation page with court subscriptions only", async () => { const mockSubscriptions = [ @@ -144,6 +179,119 @@ describe("confirm-bulk-unsubscribe", () => { expect(mockRes.redirect).toHaveBeenCalledWith("/bulk-unsubscribe"); }); + + it("should render page with Welsh translations when locale is cy", async () => { + mockRes.locals = { locale: "cy" }; + const mockSubscriptions = [ + { + subscriptionId: "sub-1", + type: "court" as const, + courtOrTribunalName: "Birmingham Crown Court", + locationId: 1, + dateAdded: new Date() + } + ]; + + vi.mocked(subscriptionService.getSubscriptionDetailsForConfirmation).mockResolvedValue(mockSubscriptions); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "confirm-bulk-unsubscribe/index", + expect.objectContaining({ + confirmTitle: cy.confirmTitle, + confirmHeading: cy.confirmHeading + }) + ); + }); + + it("should set navigation items", async () => { + const mockSubscriptions = [ + { + subscriptionId: "sub-1", + type: "court" as const, + courtOrTribunalName: "Birmingham Crown Court", + locationId: 1, + dateAdded: new Date() + } + ]; + + vi.mocked(subscriptionService.getSubscriptionDetailsForConfirmation).mockResolvedValue(mockSubscriptions); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.locals?.navigation).toBeDefined(); + expect(mockRes.locals?.navigation?.verifiedItems).toBeDefined(); + }); + + it("should include CSRF token in render data", async () => { + const mockSubscriptions = [ + { + subscriptionId: "sub-1", + type: "court" as const, + courtOrTribunalName: "Birmingham Crown Court", + locationId: 1, + dateAdded: new Date() + } + ]; + + vi.mocked(subscriptionService.getSubscriptionDetailsForConfirmation).mockResolvedValue(mockSubscriptions); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "confirm-bulk-unsubscribe/index", + expect.objectContaining({ + csrfToken: "mock-csrf-token" + }) + ); + }); + + it("should pass locale to getSubscriptionDetailsForConfirmation", async () => { + mockRes.locals = { locale: "cy" }; + const mockSubscriptions = [ + { + subscriptionId: "sub-1", + type: "court" as const, + courtOrTribunalName: "Birmingham Crown Court", + locationId: 1, + dateAdded: new Date() + } + ]; + + vi.mocked(subscriptionService.getSubscriptionDetailsForConfirmation).mockResolvedValue(mockSubscriptions); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(subscriptionService.getSubscriptionDetailsForConfirmation).toHaveBeenCalledWith(["sub-1", "sub-2"], "user123", "cy"); + }); + + it("should render page with case subscriptions only", async () => { + const mockSubscriptions = [ + { + subscriptionId: "sub-1", + type: "case" as const, + caseName: "Test Case", + partyName: "John Doe", + referenceNumber: "REF123", + dateAdded: new Date("2024-01-01") + } + ]; + + vi.mocked(subscriptionService.getSubscriptionDetailsForConfirmation).mockResolvedValue(mockSubscriptions); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "confirm-bulk-unsubscribe/index", + expect.objectContaining({ + caseSubscriptions: mockSubscriptions, + courtSubscriptions: [], + hasCaseSubscriptions: true, + hasCourtSubscriptions: false + }) + ); + }); }); describe("POST", () => { @@ -265,5 +413,132 @@ describe("confirm-bulk-unsubscribe", () => { expect(mockRes.redirect).toHaveBeenCalledWith("/bulk-unsubscribe"); }); + + it("should redirect to confirm-bulk-unsubscribe on session save error when confirm is yes", async () => { + mockReq.body = { confirm: "yes" }; + mockReq.session.save = vi.fn((callback: (err?: any) => void) => callback(new Error("Session save error"))); + vi.mocked(subscriptionService.deleteSubscriptionsByIds).mockResolvedValue(2); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/confirm-bulk-unsubscribe"); + }); + + it("should redirect to confirm-bulk-unsubscribe on session save error when confirm is no", async () => { + mockReq.body = { confirm: "no" }; + mockReq.session.save = vi.fn((callback: (err?: any) => void) => callback(new Error("Session save error"))); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/confirm-bulk-unsubscribe"); + }); + + it("should show Welsh validation error when locale is cy and confirm not provided", async () => { + mockRes.locals = { locale: "cy" }; + mockReq.body = {}; + const mockSubscriptions = [ + { + subscriptionId: "sub-1", + type: "court" as const, + courtOrTribunalName: "Birmingham Crown Court", + locationId: 1, + dateAdded: new Date() + } + ]; + + vi.mocked(subscriptionService.getSubscriptionDetailsForConfirmation).mockResolvedValue(mockSubscriptions); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "confirm-bulk-unsubscribe/index", + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + text: cy.errorNoRadioMessage, + href: cy.errorNoRadioHref + }) + ]) + }) + ); + }); + + it("should set navigation items when rendering validation error", async () => { + mockReq.body = {}; + const mockSubscriptions = [ + { + subscriptionId: "sub-1", + type: "court" as const, + courtOrTribunalName: "Birmingham Crown Court", + locationId: 1, + dateAdded: new Date() + } + ]; + + vi.mocked(subscriptionService.getSubscriptionDetailsForConfirmation).mockResolvedValue(mockSubscriptions); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.locals?.navigation).toBeDefined(); + expect(mockRes.locals?.navigation?.verifiedItems).toBeDefined(); + }); + + it("should include CSRF token in validation error render", async () => { + mockReq.body = {}; + const mockSubscriptions = [ + { + subscriptionId: "sub-1", + type: "court" as const, + courtOrTribunalName: "Birmingham Crown Court", + locationId: 1, + dateAdded: new Date() + } + ]; + + vi.mocked(subscriptionService.getSubscriptionDetailsForConfirmation).mockResolvedValue(mockSubscriptions); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "confirm-bulk-unsubscribe/index", + expect.objectContaining({ + csrfToken: "mock-csrf-token" + }) + ); + }); + + it("should redirect to bulk-unsubscribe on error during validation error rendering", async () => { + mockReq.body = {}; + vi.mocked(subscriptionService.getSubscriptionDetailsForConfirmation).mockRejectedValue(new Error("Database error")); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/bulk-unsubscribe"); + }); + + it("should redirect to bulk-unsubscribe when confirm has invalid value", async () => { + mockReq.body = { confirm: "invalid" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/bulk-unsubscribe"); + }); + + it("should clear session bulkUnsubscribe after successful deletion", async () => { + mockReq.body = { confirm: "yes" }; + vi.mocked(subscriptionService.deleteSubscriptionsByIds).mockResolvedValue(2); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session.bulkUnsubscribe).toEqual({}); + }); + + it("should clear session bulkUnsubscribe when user selects no", async () => { + mockReq.body = { confirm: "no" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session.bulkUnsubscribe).toEqual({}); + }); }); }); diff --git a/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/index.ts b/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/index.ts index 42835d522..d621ce2cc 100644 --- a/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/index.ts +++ b/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/index.ts @@ -1,5 +1,5 @@ import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth"; -import { deleteSubscriptionsByIds, getSubscriptionDetailsForConfirmation } from "@hmcts/subscriptions"; +import { deleteSubscriptionsByIds, getSubscriptionDetailsForConfirmation } from "@hmcts/subscription"; import type { Request, RequestHandler, Response } from "express"; import { cy } from "./cy.js"; import { en } from "./en.js"; diff --git a/libs/verified-pages/src/pages/delete-subscription/index.test.ts b/libs/verified-pages/src/pages/delete-subscription/index.test.ts index 4fc8dd2af..bfc000d4b 100644 --- a/libs/verified-pages/src/pages/delete-subscription/index.test.ts +++ b/libs/verified-pages/src/pages/delete-subscription/index.test.ts @@ -1,4 +1,4 @@ -import * as subscriptionService from "@hmcts/subscriptions"; +import * as subscriptionService from "@hmcts/subscription"; import type { Request, Response } from "express"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GET, POST } from "./index.js"; @@ -9,7 +9,7 @@ vi.mock("@hmcts/auth", () => ({ blockUserAccess: vi.fn(() => (_req: any, _res: any, next: any) => next()) })); -vi.mock("@hmcts/subscriptions", () => ({ +vi.mock("@hmcts/subscription", () => ({ getSubscriptionById: vi.fn() })); @@ -92,6 +92,67 @@ describe("delete-subscription", () => { expect(consoleErrorSpy).toHaveBeenCalledWith("Error fetching subscription:", expect.any(Error)); expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management"); }); + + it("should handle comma-separated subscription IDs for deduplicated subscriptions", async () => { + const id1 = "550e8400-e29b-41d4-a716-446655440000"; + const id2 = "550e8400-e29b-41d4-a716-446655440001"; + mockReq.query = { subscriptionId: `${id1},${id2}` }; + + vi.mocked(subscriptionService.getSubscriptionById) + .mockResolvedValueOnce({ + subscriptionId: id1, + userId: "user123", + locationId: 456, + dateAdded: new Date() + }) + .mockResolvedValueOnce({ + subscriptionId: id2, + userId: "user123", + locationId: 456, + dateAdded: new Date() + }); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(subscriptionService.getSubscriptionById).toHaveBeenCalledWith(id1, "user123"); + expect(subscriptionService.getSubscriptionById).toHaveBeenCalledWith(id2, "user123"); + expect(mockRes.render).toHaveBeenCalledWith( + "delete-subscription/index", + expect.objectContaining({ + subscriptionId: `${id1},${id2}` + }) + ); + }); + + it("should redirect if any of the comma-separated IDs is invalid", async () => { + mockReq.query = { subscriptionId: "550e8400-e29b-41d4-a716-446655440000,invalid-id" }; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management"); + expect(subscriptionService.getSubscriptionById).not.toHaveBeenCalled(); + }); + + it("should redirect if user does not own all comma-separated subscriptions", async () => { + const id1 = "550e8400-e29b-41d4-a716-446655440000"; + const id2 = "550e8400-e29b-41d4-a716-446655440001"; + mockReq.query = { subscriptionId: `${id1},${id2}` }; + + vi.mocked(subscriptionService.getSubscriptionById) + .mockResolvedValueOnce({ + subscriptionId: id1, + userId: "user123", + locationId: 456, + dateAdded: new Date() + }) + .mockResolvedValueOnce(null); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(subscriptionService.getSubscriptionById).toHaveBeenCalledWith(id1, "user123"); + expect(subscriptionService.getSubscriptionById).toHaveBeenCalledWith(id2, "user123"); + expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management"); + }); }); describe("POST", () => { @@ -152,5 +213,86 @@ describe("delete-subscription", () => { expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management"); }); + + it("should handle comma-separated subscription IDs when user confirms", async () => { + const id1 = "550e8400-e29b-41d4-a716-446655440000"; + const id2 = "550e8400-e29b-41d4-a716-446655440001"; + mockReq.body = { subscription: `${id1},${id2}`, "unsubscribe-confirm": "yes" }; + mockReq.session = {} as any; + + vi.mocked(subscriptionService.getSubscriptionById) + .mockResolvedValueOnce({ + subscriptionId: id1, + userId: "user123", + locationId: 456, + dateAdded: new Date() + }) + .mockResolvedValueOnce({ + subscriptionId: id2, + userId: "user123", + locationId: 456, + dateAdded: new Date() + }); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(subscriptionService.getSubscriptionById).toHaveBeenCalledWith(id1, "user123"); + expect(subscriptionService.getSubscriptionById).toHaveBeenCalledWith(id2, "user123"); + expect(mockReq.session.emailSubscriptions?.subscriptionToRemove).toBe(`${id1},${id2}`); + expect(mockRes.redirect).toHaveBeenCalledWith("/unsubscribe-confirmation"); + }); + + it("should redirect to GET with comma-separated IDs when no confirmation", async () => { + const id1 = "550e8400-e29b-41d4-a716-446655440000"; + const id2 = "550e8400-e29b-41d4-a716-446655440001"; + mockReq.body = { subscriptionId: `${id1},${id2}` }; + + vi.mocked(subscriptionService.getSubscriptionById) + .mockResolvedValueOnce({ + subscriptionId: id1, + userId: "user123", + locationId: 456, + dateAdded: new Date() + }) + .mockResolvedValueOnce({ + subscriptionId: id2, + userId: "user123", + locationId: 456, + dateAdded: new Date() + }); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith(`/delete-subscription?subscriptionId=${id1},${id2}`); + }); + + it("should redirect if any comma-separated ID is invalid format", async () => { + mockReq.body = { subscription: "550e8400-e29b-41d4-a716-446655440000,invalid-uuid" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management"); + expect(subscriptionService.getSubscriptionById).not.toHaveBeenCalled(); + }); + + it("should redirect if user does not own all comma-separated subscriptions in POST", async () => { + const id1 = "550e8400-e29b-41d4-a716-446655440000"; + const id2 = "550e8400-e29b-41d4-a716-446655440001"; + mockReq.body = { subscription: `${id1},${id2}`, "unsubscribe-confirm": "yes" }; + mockReq.session = {} as any; + + vi.mocked(subscriptionService.getSubscriptionById) + .mockResolvedValueOnce({ + subscriptionId: id1, + userId: "user123", + locationId: 456, + dateAdded: new Date() + }) + .mockResolvedValueOnce(null); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management"); + }); }); }); diff --git a/libs/verified-pages/src/pages/delete-subscription/index.ts b/libs/verified-pages/src/pages/delete-subscription/index.ts index c00f60c40..f561c690f 100644 --- a/libs/verified-pages/src/pages/delete-subscription/index.ts +++ b/libs/verified-pages/src/pages/delete-subscription/index.ts @@ -1,5 +1,5 @@ import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth"; -import { getSubscriptionById } from "@hmcts/subscriptions"; +import { getSubscriptionById } from "@hmcts/subscription"; import type { Request, RequestHandler, Response } from "express"; import { cy } from "./cy.js"; import { en } from "./en.js"; @@ -19,23 +19,29 @@ const getHandler = async (req: Request, res: Response) => { } const rawSubscriptionId = req.query.subscriptionId as string; - const subscriptionId = rawSubscriptionId?.trim(); + const subscriptionIds = rawSubscriptionId?.trim(); - if (!subscriptionId) { + if (!subscriptionIds) { return res.redirect("/subscription-management"); } - if (!isValidUUID(subscriptionId)) { - return res.redirect("/subscription-management"); + // Split comma-separated IDs and validate each one + const idsArray = subscriptionIds.split(",").map((id: string) => id.trim()); + for (const id of idsArray) { + if (!isValidUUID(id)) { + return res.redirect("/subscription-management"); + } } const userId = req.user.id; try { - const subscription = await getSubscriptionById(subscriptionId, userId); - - if (!subscription) { - return res.redirect("/subscription-management"); + // Verify user owns all subscriptions + for (const id of idsArray) { + const subscription = await getSubscriptionById(id, userId); + if (!subscription) { + return res.redirect("/subscription-management"); + } } if (!res.locals.navigation) { @@ -45,7 +51,7 @@ const getHandler = async (req: Request, res: Response) => { res.render("delete-subscription/index", { ...t, - subscriptionId, + subscriptionId: subscriptionIds, csrfToken: (req as any).csrfToken?.() || "" }); } catch (error) { @@ -63,25 +69,30 @@ const postHandler = async (req: Request, res: Response) => { } const { subscription, subscriptionId: bodySubscriptionId, "unsubscribe-confirm": unsubscribeConfirm } = req.body; - const subscriptionId = subscription || bodySubscriptionId; + const subscriptionIds = subscription || bodySubscriptionId; // If no subscriptionId in body, redirect to subscription management - if (!subscriptionId) { + if (!subscriptionIds) { return res.redirect("/subscription-management"); } - // Validate UUID format - if (!isValidUUID(subscriptionId)) { - return res.redirect("/subscription-management"); + // Split comma-separated IDs and validate each one + const idsArray = subscriptionIds.split(",").map((id: string) => id.trim()); + for (const id of idsArray) { + if (!isValidUUID(id)) { + return res.redirect("/subscription-management"); + } } const userId = req.user.id; - // Verify user owns the subscription + // Verify user owns all subscriptions try { - const sub = await getSubscriptionById(subscriptionId, userId); - if (!sub) { - return res.redirect("/subscription-management"); + for (const id of idsArray) { + const sub = await getSubscriptionById(id, userId); + if (!sub) { + return res.redirect("/subscription-management"); + } } } catch (error) { console.error("Error validating subscription ownership:", error); @@ -91,7 +102,7 @@ const postHandler = async (req: Request, res: Response) => { // If this is a direct POST from subscription-management (no confirmation yet) if (!unsubscribeConfirm) { // Redirect to GET to show confirmation page - return res.redirect(`/delete-subscription?subscriptionId=${subscriptionId}`); + return res.redirect(`/delete-subscription?subscriptionId=${subscriptionIds}`); } // Handle confirmation page submission @@ -101,7 +112,7 @@ const postHandler = async (req: Request, res: Response) => { if (unsubscribeConfirm === "yes") { req.session.emailSubscriptions = req.session.emailSubscriptions || {}; - req.session.emailSubscriptions.subscriptionToRemove = subscriptionId; + req.session.emailSubscriptions.subscriptionToRemove = subscriptionIds; return res.redirect("/unsubscribe-confirmation"); } @@ -113,7 +124,7 @@ const postHandler = async (req: Request, res: Response) => { return res.render("delete-subscription/index", { ...t, - subscriptionId, + subscriptionId: subscriptionIds, csrfToken: (req as any).csrfToken?.() || "", errors: { titleText: t.errorSummaryTitle, diff --git a/libs/verified-pages/src/pages/location-name-search/cy.ts b/libs/verified-pages/src/pages/location-name-search/cy.ts index aecff22b8..2fb817972 100644 --- a/libs/verified-pages/src/pages/location-name-search/cy.ts +++ b/libs/verified-pages/src/pages/location-name-search/cy.ts @@ -3,7 +3,6 @@ export const cy = { heading: "Tanysgrifio yn ôl enw llys neu dribiwnlys", description: "Tanysgrifiwch i dderbyn rhestr gwrandawiadau yn ôl llys neu dribiwnlys", continueButton: "Parhau", - back: "Yn ôl", backToTop: "Yn ôl i'r brig", totalSelected: "Cyfanswm a ddewiswyd", selected: "wedi'u dewis", diff --git a/libs/verified-pages/src/pages/location-name-search/en.ts b/libs/verified-pages/src/pages/location-name-search/en.ts index 3f7140fa2..85e7bd547 100644 --- a/libs/verified-pages/src/pages/location-name-search/en.ts +++ b/libs/verified-pages/src/pages/location-name-search/en.ts @@ -3,7 +3,6 @@ export const en = { heading: "Subscribe by court or tribunal name", description: "Subscribe to receive hearings list by court or tribunal", continueButton: "Continue", - back: "Back", backToTop: "Back to top", totalSelected: "Total selected", selected: "selected", diff --git a/libs/verified-pages/src/pages/location-name-search/index.njk b/libs/verified-pages/src/pages/location-name-search/index.njk index e6ff468e5..0dcfe8e12 100644 --- a/libs/verified-pages/src/pages/location-name-search/index.njk +++ b/libs/verified-pages/src/pages/location-name-search/index.njk @@ -7,13 +7,6 @@ {{ title }} - {{ serviceName }} - {{ govUk }} {% endblock %} -{% block beforeContent %} - {{ govukBackLink({ - text: back, - href: "/subscription-management" - }) }} -{% endblock %} - {% block page_content %}
diff --git a/libs/verified-pages/src/pages/pending-subscriptions/cy.ts b/libs/verified-pages/src/pages/pending-subscriptions/cy.ts index 5f373b458..39ea78c36 100644 --- a/libs/verified-pages/src/pages/pending-subscriptions/cy.ts +++ b/libs/verified-pages/src/pages/pending-subscriptions/cy.ts @@ -1,12 +1,17 @@ export const cy = { - title: "Cadarnhau eich tanysgrifiadau e-bost", - heading: "Cadarnhau eich tanysgrifiadau e-bost", - confirmButton: "Cadarnhau tanysgrifiad", - confirmButtonPlural: "Cadarnhau tanysgrifiadau", + title: "Cadarnhewch eich tanysgrifiadau e-bost", + heading: "Cadarnhewch eich tanysgrifiadau e-bost", + confirmButton: "Cadarnhau tanysgrifiadau", removeLink: "Dileu", addAnotherSubscription: "Ychwanegu tanysgrifiad e-bost arall", errorSummaryTitle: "Mae problem wedi codi", errorAtLeastOne: "Mae angen o leiaf un tanysgrifiad.", - addSubscriptions: "Ychwanegu Tanysgrifiadau", - back: "Yn ôl" + addSubscriptions: "Ychwanegu tanysgrifiadau", + courtSubscriptionsHeading: "Tanysgrifiadau llys neu dribiwnlys", + caseSubscriptionsHeading: "Tanysgrifiadau achos", + courtHeading: "Enw llys neu dribiwnlys", + caseNameHeading: "Enw'r achos", + caseNumberHeading: "Cyfeirnod", + actionsHeading: "Camau gweithredu", + notAvailable: "Ddim ar gael" }; diff --git a/libs/verified-pages/src/pages/pending-subscriptions/en.ts b/libs/verified-pages/src/pages/pending-subscriptions/en.ts index 2a269d07d..1ef028c50 100644 --- a/libs/verified-pages/src/pages/pending-subscriptions/en.ts +++ b/libs/verified-pages/src/pages/pending-subscriptions/en.ts @@ -1,12 +1,17 @@ export const en = { title: "Confirm your email subscriptions", heading: "Confirm your email subscriptions", - confirmButton: "Confirm subscription", - confirmButtonPlural: "Confirm subscriptions", + confirmButton: "Confirm subscriptions", removeLink: "Remove", - addAnotherSubscription: "Add another email Subscription", + addAnotherSubscription: "Add another email subscription", errorSummaryTitle: "There is a problem", errorAtLeastOne: "At least one subscription is needed.", addSubscriptions: "Add Subscriptions", - back: "Back" + courtSubscriptionsHeading: "Court or tribunal subscriptions", + caseSubscriptionsHeading: "Case subscriptions", + courtHeading: "Court or tribunal name", + caseNameHeading: "Case name", + caseNumberHeading: "Reference number", + actionsHeading: "Actions", + notAvailable: "Not available" }; diff --git a/libs/verified-pages/src/pages/pending-subscriptions/index.njk b/libs/verified-pages/src/pages/pending-subscriptions/index.njk index 8ca45988c..d1bf26668 100644 --- a/libs/verified-pages/src/pages/pending-subscriptions/index.njk +++ b/libs/verified-pages/src/pages/pending-subscriptions/index.njk @@ -4,7 +4,7 @@ {% from "govuk/components/button/macro.njk" import govukButton %} {% from "govuk/components/back-link/macro.njk" import govukBackLink %} -{% set displayError = locations.length == 0 %} +{% set displayError = locations.length == 0 and cases.length == 0 %} {% block pageTitle %} {% if displayError %} @@ -14,13 +14,6 @@ {% endif %} {% endblock %} -{% block beforeContent %} - {{ govukBackLink({ - text: back, - href: "/location-name-search" - }) }} -{% endblock %} - {% block page_content %}
{% if displayError %} @@ -33,7 +26,7 @@ }) }}
-
+
{{ govukButton({ text: addSubscriptions @@ -60,36 +53,70 @@ } -
{{ tableHeaderCourtName }}{{ tableHeaderDateAdded }}{{ tableHeaderCourtName }}{{ tableHeaderDateAdded }}
{{ subscription.courtOrTribunalName }}{{ subscription.dateAdded | date('D MMMM YYYY') }}{{ subscription.dateAdded | date('D MMMM YYYY') }}
- - - - - - - - {% for location in locations %} - - - - - {% endfor %} - -
{{ heading }}Actions
{{ location.name }} - - - - - -
+ {% if cases.length > 0 %} + + + + + + + + + + {% for caseItem in cases %} + + + + + + {% endfor %} + +
{{ caseNameHeading }}{{ caseNumberHeading }}{{ actionsHeading }}
{{ caseItem.caseName or notAvailable }}{{ caseItem.caseNumber or notAvailable }} +
+ + + + +
+
+ {% endif %} + + {% if locations.length > 0 %} + + + + + + + + + {% for location in locations %} + + + + + {% endfor %} + +
{{ courtHeading }}{{ actionsHeading }}
{{ location.name }} +
+ + + + +
+
+ {% endif %}

- {{ addAnotherSubscription }} + {{ addAnotherSubscription }}

+ {{ govukButton({ text: confirmButton diff --git a/libs/verified-pages/src/pages/pending-subscriptions/index.test.ts b/libs/verified-pages/src/pages/pending-subscriptions/index.test.ts index 494eacd07..4cbc4b6ef 100644 --- a/libs/verified-pages/src/pages/pending-subscriptions/index.test.ts +++ b/libs/verified-pages/src/pages/pending-subscriptions/index.test.ts @@ -1,6 +1,9 @@ -import * as subscriptionService from "@hmcts/subscriptions"; +import * as locationService from "@hmcts/location"; +import * as subscriptionService from "@hmcts/subscription"; import type { Request, Response } from "express"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; import { GET, POST } from "./index.js"; vi.mock("@hmcts/auth", () => ({ @@ -17,9 +20,10 @@ vi.mock("@hmcts/location", () => ({ })) })); -vi.mock("@hmcts/subscriptions", () => ({ +vi.mock("@hmcts/subscription", () => ({ getAllSubscriptionsByUserId: vi.fn(), - replaceUserSubscriptions: vi.fn() + replaceUserSubscriptions: vi.fn(), + createCaseSubscription: vi.fn() })); describe("pending-subscriptions", () => { @@ -35,12 +39,13 @@ describe("pending-subscriptions", () => { pendingSubscriptions: ["456", "789"] } } as any, - path: "/pending-subscriptions" + path: "/pending-subscriptions", + csrfToken: vi.fn(() => "mock-csrf-token") }; mockRes = { render: vi.fn(), redirect: vi.fn(), - locals: {} + locals: { locale: "en" } }; }); @@ -48,6 +53,37 @@ describe("pending-subscriptions", () => { vi.clearAllMocks(); }); + describe("translations", () => { + describe("en", () => { + it("should have all required translation keys", () => { + expect(en.title).toBeDefined(); + expect(en.heading).toBeDefined(); + expect(en.confirmButton).toBeDefined(); + expect(en.removeLink).toBeDefined(); + expect(en.addAnotherSubscription).toBeDefined(); + expect(en.errorSummaryTitle).toBeDefined(); + expect(en.errorAtLeastOne).toBeDefined(); + expect(en.addSubscriptions).toBeDefined(); + expect(en.courtSubscriptionsHeading).toBeDefined(); + expect(en.caseSubscriptionsHeading).toBeDefined(); + expect(en.courtHeading).toBeDefined(); + expect(en.caseNameHeading).toBeDefined(); + expect(en.caseNumberHeading).toBeDefined(); + expect(en.actionsHeading).toBeDefined(); + expect(en.notAvailable).toBeDefined(); + }); + }); + + describe("cy", () => { + it("should have all required translation keys matching en", () => { + const enKeys = Object.keys(en); + const cyKeys = Object.keys(cy); + + expect(cyKeys).toEqual(enKeys); + }); + }); + }); + describe("GET", () => { it("should render page with pending subscriptions", async () => { await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); @@ -90,6 +126,138 @@ describe("pending-subscriptions", () => { }) ); }); + + it("should render page with Welsh translations when locale is cy", async () => { + mockRes.locals = { locale: "cy" }; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "pending-subscriptions/index", + expect.objectContaining({ + title: cy.title, + heading: cy.heading + }) + ); + }); + + it("should use Welsh location names when locale is cy", async () => { + mockRes.locals = { locale: "cy" }; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "pending-subscriptions/index", + expect.objectContaining({ + locations: expect.arrayContaining([expect.objectContaining({ name: "Lleoliad 456" }), expect.objectContaining({ name: "Lleoliad 789" })]) + }) + ); + }); + + it("should set navigation items", async () => { + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.locals?.navigation).toBeDefined(); + expect(mockRes.locals?.navigation?.verifiedItems).toBeDefined(); + }); + + it("should include CSRF token in render data", async () => { + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "pending-subscriptions/index", + expect.objectContaining({ + csrfToken: "mock-csrf-token" + }) + ); + }); + + it("should render page with pending cases", async () => { + mockReq.session = { + emailSubscriptions: { + pendingSubscriptions: [], + pendingCaseSubscriptions: [{ id: "case1", caseName: "Test Case", caseNumber: "REF123" }] + } + } as any; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "pending-subscriptions/index", + expect.objectContaining({ + cases: expect.arrayContaining([expect.objectContaining({ caseName: "Test Case" })]) + }) + ); + }); + + it("should render page with both pending locations and cases", async () => { + mockReq.session = { + emailSubscriptions: { + pendingSubscriptions: ["456"], + pendingCaseSubscriptions: [{ id: "case1", caseName: "Test Case", caseNumber: "REF123" }] + } + } as any; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "pending-subscriptions/index", + expect.objectContaining({ + isPlural: true, + locations: expect.any(Array), + cases: expect.any(Array) + }) + ); + }); + + it("should sort locations alphabetically by name", async () => { + vi.mocked(locationService.getLocationById).mockImplementation((id: number) => ({ + locationId: id, + name: id === 456 ? "Zebra Court" : "Alpha Court", + welshName: id === 456 ? "Llys Zebra" : "Llys Alpha" + })); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + const renderCall = vi.mocked(mockRes.render).mock.calls[0]; + const locations = renderCall[1].locations; + expect(locations[0].name).toBe("Alpha Court"); + expect(locations[1].name).toBe("Zebra Court"); + }); + + it("should sort cases alphabetically by case name", async () => { + mockReq.session = { + emailSubscriptions: { + pendingSubscriptions: [], + pendingCaseSubscriptions: [ + { id: "case1", caseName: "Zebra Case", caseNumber: "REF123" }, + { id: "case2", caseName: "Alpha Case", caseNumber: "REF456" } + ] + } + } as any; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + const renderCall = vi.mocked(mockRes.render).mock.calls[0]; + const cases = renderCall[1].cases; + expect(cases[0].caseName).toBe("Alpha Case"); + expect(cases[1].caseName).toBe("Zebra Case"); + }); + + it("should filter out null locations when getLocationById fails", async () => { + vi.mocked(locationService.getLocationById).mockImplementation((id: number) => + id === 456 ? null : { locationId: id, name: `Location ${id}`, welshName: `Lleoliad ${id}` } + ); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "pending-subscriptions/index", + expect.objectContaining({ + locations: expect.arrayContaining([expect.objectContaining({ locationId: "789" })]) + }) + ); + }); }); describe("POST", () => { @@ -141,12 +309,12 @@ describe("pending-subscriptions", () => { }); it("should redirect when confirming with no pending subscriptions", async () => { - mockReq.session = { emailSubscriptions: { pendingSubscriptions: [] } } as any; + mockReq.session = { emailSubscriptions: { pendingSubscriptions: [], pendingCaseSubscriptions: [] } } as any; mockReq.body = { action: "confirm" }; await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); - expect(mockRes.redirect).toHaveBeenCalledWith("/location-name-search"); + expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-add"); }); it("should handle error when confirming subscriptions", async () => { @@ -168,7 +336,7 @@ describe("pending-subscriptions", () => { it("should preserve existing subscriptions when adding new ones", async () => { mockReq.body = { action: "confirm" }; - mockReq.session = { emailSubscriptions: { pendingSubscriptions: ["456", "789"] } } as any; + mockReq.session = { emailSubscriptions: { pendingSubscriptions: ["456", "789"], pendingCaseSubscriptions: [] } } as any; vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([ { @@ -189,5 +357,201 @@ describe("pending-subscriptions", () => { expect(subscriptionService.replaceUserSubscriptions).toHaveBeenCalledWith("user123", ["123", "456", "789"]); expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-confirmed"); }); + + it("should remove case when action is removeCase", async () => { + mockReq.session = { + emailSubscriptions: { + pendingSubscriptions: [], + pendingCaseSubscriptions: [ + { id: "case1", caseName: "Case A", caseNumber: "REF1" }, + { id: "case2", caseName: "Case B", caseNumber: "REF2" } + ] + } + } as any; + mockReq.body = { action: "removeCase", caseId: "case1" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session?.emailSubscriptions?.pendingCaseSubscriptions).toEqual([{ id: "case2", caseName: "Case B", caseNumber: "REF2" }]); + expect(mockRes.redirect).toHaveBeenCalledWith("/pending-subscriptions"); + }); + + it("should show error when removing last case", async () => { + mockReq.session = { + emailSubscriptions: { + pendingSubscriptions: [], + pendingCaseSubscriptions: [{ id: "case1", caseName: "Case A", caseNumber: "REF1" }] + } + } as any; + mockReq.body = { action: "removeCase", caseId: "case1" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "pending-subscriptions/index", + expect.objectContaining({ + errors: expect.any(Object) + }) + ); + }); + + it("should redirect to sign-in when user is not authenticated", async () => { + mockReq.user = undefined; + mockReq.body = { action: "confirm" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/sign-in"); + expect(subscriptionService.getAllSubscriptionsByUserId).not.toHaveBeenCalled(); + }); + + it("should confirm case subscriptions when action is confirm", async () => { + mockReq.session = { + emailSubscriptions: { + pendingSubscriptions: [], + pendingCaseSubscriptions: [{ id: "case1", caseName: "Case A", caseNumber: "REF1", searchType: "CASE_NUMBER" }] + } + } as any; + mockReq.body = { action: "confirm" }; + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.createCaseSubscription).mockResolvedValue({ + subscriptionId: "sub1", + userId: "user123", + searchType: "CASE_NUMBER", + searchValue: "REF1", + caseNumber: "REF1", + caseName: "Case A", + dateAdded: new Date() + }); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(subscriptionService.createCaseSubscription).toHaveBeenCalledWith("user123", "CASE_NUMBER", "REF1", "REF1", "Case A"); + expect(mockReq.session?.emailSubscriptions?.confirmationComplete).toBe(true); + expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-confirmed"); + }); + + it("should default to CASE_NUMBER search type for backward compatibility", async () => { + mockReq.session = { + emailSubscriptions: { + pendingSubscriptions: [], + pendingCaseSubscriptions: [{ id: "case1", caseName: "Case A", caseNumber: "REF1" }] + } + } as any; + mockReq.body = { action: "confirm" }; + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.createCaseSubscription).mockResolvedValue({ + subscriptionId: "sub1", + userId: "user123", + searchType: "CASE_NUMBER", + searchValue: "REF1", + caseNumber: "REF1", + caseName: "Case A", + dateAdded: new Date() + }); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(subscriptionService.createCaseSubscription).toHaveBeenCalledWith("user123", "CASE_NUMBER", "REF1", "REF1", "Case A"); + }); + + it("should use CASE_NAME search type when specified", async () => { + mockReq.session = { + emailSubscriptions: { + pendingSubscriptions: [], + pendingCaseSubscriptions: [{ id: "case1", caseName: "Case A", caseNumber: "REF1", searchType: "CASE_NAME" }] + } + } as any; + mockReq.body = { action: "confirm" }; + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.createCaseSubscription).mockResolvedValue({ + subscriptionId: "sub1", + userId: "user123", + searchType: "CASE_NAME", + searchValue: "Case A", + caseNumber: "REF1", + caseName: "Case A", + dateAdded: new Date() + }); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(subscriptionService.createCaseSubscription).toHaveBeenCalledWith("user123", "CASE_NAME", "Case A", "REF1", "Case A"); + }); + + it("should confirm both locations and cases", async () => { + mockReq.session = { + emailSubscriptions: { + pendingSubscriptions: ["456"], + pendingCaseSubscriptions: [{ id: "case1", caseName: "Case A", caseNumber: "REF1", searchType: "CASE_NUMBER" }] + } + } as any; + mockReq.body = { action: "confirm" }; + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.replaceUserSubscriptions).mockResolvedValue({ added: 1, removed: 0 }); + vi.mocked(subscriptionService.createCaseSubscription).mockResolvedValue({ + subscriptionId: "sub1", + userId: "user123", + searchType: "CASE_NUMBER", + searchValue: "REF1", + caseNumber: "REF1", + caseName: "Case A", + dateAdded: new Date() + }); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(subscriptionService.replaceUserSubscriptions).toHaveBeenCalledWith("user123", ["456"]); + expect(subscriptionService.createCaseSubscription).toHaveBeenCalledWith("user123", "CASE_NUMBER", "REF1", "REF1", "Case A"); + expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-confirmed"); + }); + + it("should set navigation items when rendering error", async () => { + mockReq.session = { emailSubscriptions: { pendingSubscriptions: ["456"] } } as any; + mockReq.body = { action: "remove", locationId: "456" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.locals?.navigation).toBeDefined(); + expect(mockRes.locals?.navigation?.verifiedItems).toBeDefined(); + }); + + it("should clear pending subscriptions after successful confirmation", async () => { + mockReq.body = { action: "confirm" }; + mockReq.session = { emailSubscriptions: { pendingSubscriptions: ["456"], pendingCaseSubscriptions: [] } } as any; + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.replaceUserSubscriptions).mockResolvedValue({ added: 1, removed: 0 }); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session?.emailSubscriptions?.pendingSubscriptions).toBeUndefined(); + expect(mockReq.session?.emailSubscriptions?.pendingCaseSubscriptions).toBeUndefined(); + }); + + it("should set confirmed locations and cases in session", async () => { + mockReq.session = { + emailSubscriptions: { + pendingSubscriptions: ["456"], + pendingCaseSubscriptions: [{ id: "case1", caseName: "Case A", caseNumber: "REF1" }] + } + } as any; + mockReq.body = { action: "confirm" }; + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.replaceUserSubscriptions).mockResolvedValue({ added: 1, removed: 0 }); + vi.mocked(subscriptionService.createCaseSubscription).mockResolvedValue({ + subscriptionId: "sub1", + userId: "user123", + searchType: "CASE_NUMBER", + searchValue: "REF1", + caseNumber: "REF1", + caseName: "Case A", + dateAdded: new Date() + }); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session?.emailSubscriptions?.confirmedLocations).toEqual(["456"]); + expect(mockReq.session?.emailSubscriptions?.confirmedCases).toEqual([{ id: "case1", caseName: "Case A", caseNumber: "REF1" }]); + }); }); }); diff --git a/libs/verified-pages/src/pages/pending-subscriptions/index.ts b/libs/verified-pages/src/pages/pending-subscriptions/index.ts index 4e009a9a7..c5dec0153 100644 --- a/libs/verified-pages/src/pages/pending-subscriptions/index.ts +++ b/libs/verified-pages/src/pages/pending-subscriptions/index.ts @@ -1,6 +1,6 @@ import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth"; import { getLocationById } from "@hmcts/location"; -import { getAllSubscriptionsByUserId, replaceUserSubscriptions } from "@hmcts/subscriptions"; +import { createCaseSubscription, getAllSubscriptionsByUserId, replaceUserSubscriptions } from "@hmcts/subscription"; import type { Request, RequestHandler, Response } from "express"; import { cy } from "./cy.js"; import { en } from "./en.js"; @@ -10,8 +10,9 @@ const getHandler = async (req: Request, res: Response) => { const t = locale === "cy" ? cy : en; const pendingLocationIds = req.session.emailSubscriptions?.pendingSubscriptions || []; + const pendingCases = req.session.emailSubscriptions?.pendingCaseSubscriptions || []; - if (pendingLocationIds.length === 0) { + if (pendingLocationIds.length === 0 && pendingCases.length === 0) { if (!res.locals.navigation) { res.locals.navigation = {}; } @@ -24,6 +25,7 @@ const getHandler = async (req: Request, res: Response) => { errorList: [{ text: t.errorAtLeastOne, href: "#" }] }, locations: [], + cases: [], showBackToSearch: true }); } @@ -42,18 +44,26 @@ const getHandler = async (req: Request, res: Response) => { ) ).filter(Boolean); + // Sort locations by name + const sortedLocations = [...pendingLocations].sort((a: any, b: any) => (a.name || "").localeCompare(b.name || "")); + + // Sort cases by case name + const sortedCases = [...pendingCases].sort((a: any, b: any) => (a.caseName || "").localeCompare(b.caseName || "")); + if (!res.locals.navigation) { res.locals.navigation = {}; } res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); - const isPlural = pendingLocations.length > 1; + const totalCount = pendingLocations.length + pendingCases.length; + const isPlural = totalCount > 1; res.render("pending-subscriptions/index", { ...t, - locations: pendingLocations, + locations: sortedLocations, + cases: sortedCases, isPlural, - confirmButton: isPlural ? t.confirmButtonPlural : t.confirmButton + csrfToken: (req as any).csrfToken?.() || "" }); }; @@ -66,86 +76,172 @@ const postHandler = async (req: Request, res: Response) => { } const userId = req.user.id; - const { action, locationId } = req.body; + const { action, locationId, caseId } = req.body; const pendingLocationIds = req.session.emailSubscriptions?.pendingSubscriptions || []; + const pendingCases = req.session.emailSubscriptions?.pendingCaseSubscriptions || []; if (action === "remove" && locationId) { - req.session.emailSubscriptions.pendingSubscriptions = pendingLocationIds.filter((id: string) => id !== locationId); - - if (req.session.emailSubscriptions.pendingSubscriptions.length === 0) { - if (!res.locals.navigation) { - res.locals.navigation = {}; - } - res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); - - return res.render("pending-subscriptions/index", { - ...t, - errors: { - titleText: t.errorSummaryTitle, - errorList: [{ text: t.errorAtLeastOne, href: "#" }] - }, - locations: [], - showBackToSearch: true - }); - } + return handleRemoveLocation(req, res, locationId, pendingLocationIds, pendingCases, locale, t); + } - return res.redirect("/pending-subscriptions"); + if (action === "removeCase" && caseId) { + return handleRemoveCase(req, res, caseId, pendingLocationIds, pendingCases, locale, t); } if (action === "confirm") { - if (pendingLocationIds.length === 0) { - return res.redirect("/location-name-search"); + return handleConfirm(req, res, userId, pendingLocationIds, pendingCases, locale, t); + } +}; + +const handleRemoveLocation = ( + req: Request, + res: Response, + locationId: string, + pendingLocationIds: string[], + pendingCases: any[], + locale: string, + t: typeof en +) => { + req.session.emailSubscriptions.pendingSubscriptions = pendingLocationIds.filter((id: string) => id !== locationId); + + const remainingLocationCount = req.session.emailSubscriptions.pendingSubscriptions.length; + const remainingCaseCount = pendingCases.length; + + if (remainingLocationCount === 0 && remainingCaseCount === 0) { + return renderEmptyPendingSubscriptions(res, req.path, locale, t); + } + + return res.redirect("/pending-subscriptions"); +}; + +const handleRemoveCase = (req: Request, res: Response, caseId: string, pendingLocationIds: string[], pendingCases: any[], locale: string, t: typeof en) => { + req.session.emailSubscriptions.pendingCaseSubscriptions = pendingCases.filter((c: any) => c.id !== caseId); + + const remainingLocationCount = pendingLocationIds.length; + const remainingCaseCount = req.session.emailSubscriptions.pendingCaseSubscriptions.length; + + if (remainingLocationCount === 0 && remainingCaseCount === 0) { + return renderEmptyPendingSubscriptions(res, req.path, locale, t); + } + + return res.redirect("/pending-subscriptions"); +}; + +const handleConfirm = async (req: Request, res: Response, userId: string, pendingLocationIds: string[], pendingCases: any[], locale: string, t: typeof en) => { + if (pendingLocationIds.length === 0 && pendingCases.length === 0) { + return res.redirect("/subscription-add"); + } + + try { + if (pendingLocationIds.length > 0) { + await createLocationSubscriptions(userId, pendingLocationIds); + } + + if (pendingCases.length > 0) { + await createCaseSubscriptions(userId, pendingCases); + } + + req.session.emailSubscriptions.confirmationComplete = true; + req.session.emailSubscriptions.confirmedLocations = pendingLocationIds; + req.session.emailSubscriptions.confirmedCases = pendingCases; + delete req.session.emailSubscriptions.pendingSubscriptions; + delete req.session.emailSubscriptions.pendingCaseSubscriptions; + + res.redirect("/subscription-confirmed"); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + + const pendingLocations = ( + await Promise.all( + pendingLocationIds.map(async (id: string) => { + const location = await getLocationById(Number.parseInt(id, 10)); + return location + ? { + locationId: id, + name: locale === "cy" ? location.welshName : location.name + } + : null; + }) + ) + ).filter(Boolean); + + if (!res.locals.navigation) { + res.locals.navigation = {}; } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); - try { - const existingSubscriptions = await getAllSubscriptionsByUserId(userId); - const existingLocationIds = existingSubscriptions.map((sub) => sub.locationId.toString()); - const allLocationIds = [...new Set([...existingLocationIds, ...pendingLocationIds])]; - - await replaceUserSubscriptions(userId, allLocationIds); - - req.session.emailSubscriptions.confirmationComplete = true; - req.session.emailSubscriptions.confirmedLocations = pendingLocationIds; - delete req.session.emailSubscriptions.pendingSubscriptions; - - res.redirect("/subscription-confirmed"); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - - const pendingLocations = ( - await Promise.all( - pendingLocationIds.map(async (id: string) => { - const location = await getLocationById(Number.parseInt(id, 10)); - return location - ? { - locationId: id, - name: locale === "cy" ? location.welshName : location.name - } - : null; - }) - ) - ).filter(Boolean); - - if (!res.locals.navigation) { - res.locals.navigation = {}; - } - res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); - - const isPlural = pendingLocations.length > 1; - - res.render("pending-subscriptions/index", { - ...t, - errors: { - titleText: t.errorSummaryTitle, - errorList: [{ text: errorMessage }] - }, - locations: pendingLocations, - isPlural, - confirmButton: isPlural ? t.confirmButtonPlural : t.confirmButton - }); + const totalCount = pendingLocations.length + pendingCases.length; + const isPlural = totalCount > 1; + + res.render("pending-subscriptions/index", { + ...t, + errors: { + titleText: t.errorSummaryTitle, + errorList: [{ text: errorMessage }] + }, + locations: pendingLocations, + cases: pendingCases, + isPlural + }); + } +}; + +const createLocationSubscriptions = async (userId: string, pendingLocationIds: string[]): Promise => { + const existingSubscriptions = await getAllSubscriptionsByUserId(userId); + const existingLocationIds = existingSubscriptions.filter((sub) => sub.locationId !== undefined).map((sub) => sub.locationId!.toString()); + const allLocationIds = [...new Set([...existingLocationIds, ...pendingLocationIds])]; + + await replaceUserSubscriptions(userId, allLocationIds); +}; + +const createCaseSubscriptions = async (userId: string, pendingCases: any[]): Promise => { + const uniqueCases = deduplicateCases(pendingCases); + + await Promise.all( + uniqueCases.map((caseItem: any) => { + const searchType = caseItem.searchType || "CASE_NUMBER"; + const searchValue = searchType === "CASE_NAME" ? caseItem.caseName : caseItem.caseNumber; + return createCaseSubscription(userId, searchType, searchValue, caseItem.caseNumber, caseItem.caseName); + }) + ); +}; + +const deduplicateCases = (pendingCases: any[]): any[] => { + return pendingCases.reduce((acc: any[], caseItem: any) => { + const searchType = caseItem.searchType || "CASE_NUMBER"; + const searchValue = searchType === "CASE_NAME" ? caseItem.caseName : caseItem.caseNumber; + const key = `${searchType}:${searchValue}`; + + if ( + !acc.some((c: any) => { + const cSearchType = c.searchType || "CASE_NUMBER"; + const cSearchValue = cSearchType === "CASE_NAME" ? c.caseName : c.caseNumber; + return `${cSearchType}:${cSearchValue}` === key; + }) + ) { + acc.push(caseItem); } + return acc; + }, []); +}; + +const renderEmptyPendingSubscriptions = (res: Response, path: string, locale: string, t: typeof en) => { + if (!res.locals.navigation) { + res.locals.navigation = {}; } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(path, locale); + + return res.render("pending-subscriptions/index", { + ...t, + errors: { + titleText: t.errorSummaryTitle, + errorList: [{ text: t.errorAtLeastOne, href: "#" }] + }, + locations: [], + cases: [], + showBackToSearch: true + }); }; export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler]; diff --git a/libs/verified-pages/src/pages/subscription-add/cy.ts b/libs/verified-pages/src/pages/subscription-add/cy.ts new file mode 100644 index 000000000..f53a18921 --- /dev/null +++ b/libs/verified-pages/src/pages/subscription-add/cy.ts @@ -0,0 +1,12 @@ +export const cy = { + title: "Sut ydych chi eisiau ychwanegu tanysgrifiad e-bost?", + heading: "Sut ydych chi eisiau ychwanegu tanysgrifiad e-bost?", + bodyText: "Gallwch ond chwilio am wybodaeth sydd eisoes wedi’i chyhoeddi.", + radioLabel: "Dewiswch ddull tanysgrifio", + optionCourtOrTribunal: "Yn ôl enw llys neu dribiwnlys", + optionCaseName: "Yn ôl enw'r achos", + optionCaseReference: "Yn ôl rhif cyfeirnod yr achos, ID yr achos neu rif cyfeirnod unigryw (URN)", + continueButton: "Parhau", + errorRequired: "Dywedwch wrthym sut yr hoffech ychwanegu tanysgrifiad", + errorSummaryTitle: "Mae problem wedi codi" +}; diff --git a/libs/verified-pages/src/pages/subscription-add/en.ts b/libs/verified-pages/src/pages/subscription-add/en.ts new file mode 100644 index 000000000..f70f98096 --- /dev/null +++ b/libs/verified-pages/src/pages/subscription-add/en.ts @@ -0,0 +1,12 @@ +export const en = { + title: "How do you want to add an email subscription?", + heading: "How do you want to add an email subscription?", + bodyText: "You can only search for information that is currently published.", + radioLabel: "Select subscription method", + optionCourtOrTribunal: "By court or tribunal name", + optionCaseName: "By case name", + optionCaseReference: "By case reference number, case ID or unique reference number (URN)", + continueButton: "Continue", + errorRequired: "Select how you want to add an email subscription", + errorSummaryTitle: "There is a problem" +}; diff --git a/libs/verified-pages/src/pages/subscription-add/index.njk b/libs/verified-pages/src/pages/subscription-add/index.njk new file mode 100644 index 000000000..a8c454610 --- /dev/null +++ b/libs/verified-pages/src/pages/subscription-add/index.njk @@ -0,0 +1,50 @@ +{% 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/inset-text/macro.njk" import govukInsetText %} + +{% block content %} +
+
+ + {% if errorSummary %} + {{ govukErrorSummary(errorSummary) }} + {% endif %} + +

{{ heading }}

+ + {{ govukInsetText({ + text: bodyText + }) }} + + + + + {{ govukRadios({ + name: "subscriptionMethod", + errorMessage: fieldErrors.subscriptionMethod if fieldErrors and fieldErrors.subscriptionMethod, + items: [ + { + value: "courtOrTribunal", + text: optionCourtOrTribunal + }, + { + value: "caseName", + text: optionCaseName + }, + { + value: "caseReference", + text: optionCaseReference + } + ] + }) }} + + {{ govukButton({ + text: continueButton + }) }} + + +
+
+{% endblock %} diff --git a/libs/verified-pages/src/pages/subscription-add/index.test.ts b/libs/verified-pages/src/pages/subscription-add/index.test.ts new file mode 100644 index 000000000..fb0aae4c9 --- /dev/null +++ b/libs/verified-pages/src/pages/subscription-add/index.test.ts @@ -0,0 +1,177 @@ +import type { Request, Response } from "express"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; +import { GET, POST } from "./index.js"; + +vi.mock("@hmcts/auth", () => ({ + buildVerifiedUserNavigation: vi.fn(() => []), + requireAuth: vi.fn(() => (_req: any, _res: any, next: any) => next()), + blockUserAccess: vi.fn(() => (_req: any, _res: any, next: any) => next()) +})); + +describe("subscription-add", () => { + let mockReq: Partial; + let mockRes: Partial; + + beforeEach(() => { + mockReq = { + body: {}, + path: "/subscription-add", + csrfToken: vi.fn(() => "mock-csrf-token") + }; + mockRes = { + render: vi.fn(), + redirect: vi.fn(), + locals: {} + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("translations", () => { + describe("en", () => { + it("should have all required translation keys", () => { + expect(en.title).toBeDefined(); + expect(en.heading).toBeDefined(); + expect(en.bodyText).toBeDefined(); + expect(en.radioLabel).toBeDefined(); + expect(en.optionCourtOrTribunal).toBeDefined(); + expect(en.optionCaseName).toBeDefined(); + expect(en.optionCaseReference).toBeDefined(); + expect(en.continueButton).toBeDefined(); + expect(en.errorRequired).toBeDefined(); + expect(en.errorSummaryTitle).toBeDefined(); + }); + + it("should have correct title", () => { + expect(en.title).toBe("How do you want to add an email subscription?"); + }); + }); + + describe("cy", () => { + it("should have all required translation keys matching en", () => { + const enKeys = Object.keys(en); + const cyKeys = Object.keys(cy); + + expect(cyKeys).toEqual(enKeys); + }); + + it("should have Welsh translations", () => { + expect(cy.title).toBe("Sut ydych chi eisiau ychwanegu tanysgrifiad e-bost?"); + expect(cy.continueButton).toBe("Parhau"); + }); + }); + }); + + describe("GET", () => { + it("should render page with English translations", async () => { + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "subscription-add/index", + expect.objectContaining({ + title: en.title, + heading: en.heading, + csrfToken: "mock-csrf-token" + }) + ); + }); + + it("should render page with Welsh translations when locale is cy", async () => { + mockRes.locals = { locale: "cy" }; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "subscription-add/index", + expect.objectContaining({ + title: cy.title, + heading: cy.heading + }) + ); + }); + + it("should set navigation items", async () => { + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.locals?.navigation).toBeDefined(); + expect(mockRes.locals?.navigation?.verifiedItems).toBeDefined(); + }); + }); + + describe("POST", () => { + it("should redirect to location-name-search when courtOrTribunal is selected", async () => { + mockReq.body = { subscriptionMethod: "courtOrTribunal" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/location-name-search"); + }); + + it("should redirect to case-name-search when caseName is selected", async () => { + mockReq.body = { subscriptionMethod: "caseName" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/case-name-search"); + }); + + it("should redirect to case-number-search when caseReference is selected", async () => { + mockReq.body = { subscriptionMethod: "caseReference" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/case-number-search"); + }); + + it("should show validation error when no option is selected", async () => { + mockReq.body = {}; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "subscription-add/index", + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + text: en.errorRequired, + href: "#subscriptionMethod" + }) + ]), + fieldErrors: expect.objectContaining({ + subscriptionMethod: expect.objectContaining({ text: en.errorRequired }) + }) + }) + ); + }); + + it("should redirect back to subscription-add for invalid option", async () => { + mockReq.body = { subscriptionMethod: "invalid" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-add"); + }); + + it("should show Welsh error messages when locale is cy", async () => { + mockRes.locals = { locale: "cy" }; + mockReq.body = {}; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "subscription-add/index", + expect.objectContaining({ + errors: expect.arrayContaining([ + expect.objectContaining({ + text: cy.errorRequired + }) + ]) + }) + ); + }); + }); +}); diff --git a/libs/verified-pages/src/pages/subscription-add/index.ts b/libs/verified-pages/src/pages/subscription-add/index.ts new file mode 100644 index 000000000..03da8db8e --- /dev/null +++ b/libs/verified-pages/src/pages/subscription-add/index.ts @@ -0,0 +1,60 @@ +import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth"; +import type { Request, RequestHandler, Response } from "express"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +const getHandler = async (req: Request, res: Response) => { + const locale = res.locals.locale || "en"; + const t = locale === "cy" ? cy : en; + + if (!res.locals.navigation) { + res.locals.navigation = {}; + } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + + res.render("subscription-add/index", { + ...t, + csrfToken: (req as any).csrfToken?.() || "" + }); +}; + +const postHandler = async (req: Request, res: Response) => { + const locale = res.locals.locale || "en"; + const t = locale === "cy" ? cy : en; + const { subscriptionMethod } = req.body; + + if (!subscriptionMethod) { + if (!res.locals.navigation) { + res.locals.navigation = {}; + } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + + return res.render("subscription-add/index", { + ...t, + errors: [{ text: t.errorRequired, href: "#subscriptionMethod" }], + errorSummary: { + titleText: t.errorSummaryTitle, + errorList: [{ text: t.errorRequired, href: "#subscriptionMethod" }] + }, + fieldErrors: { + subscriptionMethod: { text: t.errorRequired } + }, + csrfToken: (req as any).csrfToken?.() || "" + }); + } + + // Redirect based on selected method + switch (subscriptionMethod) { + case "courtOrTribunal": + return res.redirect("/location-name-search"); + case "caseName": + return res.redirect("/case-name-search"); + case "caseReference": + return res.redirect("/case-number-search"); + default: + return res.redirect("/subscription-add"); + } +}; + +export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler]; +export const POST: RequestHandler[] = [requireAuth(), blockUserAccess(), postHandler]; diff --git a/libs/verified-pages/src/pages/subscription-confirmed/index.njk b/libs/verified-pages/src/pages/subscription-confirmed/index.njk index b2dba632d..c8d51288a 100644 --- a/libs/verified-pages/src/pages/subscription-confirmed/index.njk +++ b/libs/verified-pages/src/pages/subscription-confirmed/index.njk @@ -19,7 +19,7 @@
  • - {{ addNewSubscriptionLink }} + {{ addNewSubscriptionLink }}
  • {{ manageSubscriptionsLink }} diff --git a/libs/verified-pages/src/pages/subscription-confirmed/index.test.ts b/libs/verified-pages/src/pages/subscription-confirmed/index.test.ts index 477e12eb2..b03d07fde 100644 --- a/libs/verified-pages/src/pages/subscription-confirmed/index.test.ts +++ b/libs/verified-pages/src/pages/subscription-confirmed/index.test.ts @@ -1,5 +1,8 @@ +import * as locationService from "@hmcts/location"; import type { Request, Response } from "express"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; import { GET } from "./index.js"; vi.mock("@hmcts/auth", () => ({ @@ -33,7 +36,7 @@ describe("subscription-confirmed", () => { mockRes = { render: vi.fn(), redirect: vi.fn(), - locals: {} + locals: { locale: "en" } }; }); @@ -41,6 +44,31 @@ describe("subscription-confirmed", () => { vi.clearAllMocks(); }); + describe("translations", () => { + describe("en", () => { + it("should have all required translation keys", () => { + expect(en.title).toBeDefined(); + expect(en.panelTitle).toBeDefined(); + expect(en.panelTitlePlural).toBeDefined(); + expect(en.continueText).toBeDefined(); + expect(en.yourAccountLink).toBeDefined(); + expect(en.inOrderTo).toBeDefined(); + expect(en.addNewSubscriptionLink).toBeDefined(); + expect(en.manageSubscriptionsLink).toBeDefined(); + expect(en.findCourtLink).toBeDefined(); + }); + }); + + describe("cy", () => { + it("should have all required translation keys matching en", () => { + const enKeys = Object.keys(en); + const cyKeys = Object.keys(cy); + + expect(cyKeys).toEqual(enKeys); + }); + }); + }); + describe("GET", () => { it("should render page with confirmed locations", async () => { await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); @@ -99,5 +127,145 @@ describe("subscription-confirmed", () => { }) ); }); + + it("should render page with Welsh translations when locale is cy", async () => { + mockRes.locals = { locale: "cy" }; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "subscription-confirmed/index", + expect.objectContaining({ + title: cy.title, + panelTitle: cy.panelTitlePlural + }) + ); + }); + + it("should use Welsh location names when locale is cy", async () => { + mockRes.locals = { locale: "cy" }; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "subscription-confirmed/index", + expect.objectContaining({ + locations: expect.arrayContaining(["Lleoliad 456", "Lleoliad 789"]) + }) + ); + }); + + it("should set navigation items", async () => { + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.locals?.navigation).toBeDefined(); + expect(mockRes.locals?.navigation?.verifiedItems).toBeDefined(); + }); + + it("should use singular panel title when only one subscription", async () => { + mockReq.session = { + emailSubscriptions: { + confirmationComplete: true, + confirmedLocations: ["456"] + } + } as any; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "subscription-confirmed/index", + expect.objectContaining({ + panelTitle: en.panelTitle, + isPlural: false + }) + ); + }); + + it("should handle confirmed cases", async () => { + mockReq.session = { + emailSubscriptions: { + confirmationComplete: true, + confirmedLocations: [], + confirmedCases: [{ id: "case1", caseName: "Case A", caseNumber: "REF123" }] + } + } as any; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "subscription-confirmed/index", + expect.objectContaining({ + cases: expect.arrayContaining([expect.objectContaining({ caseName: "Case A" })]), + isPlural: false + }) + ); + }); + + it("should handle both confirmed locations and cases", async () => { + mockReq.session = { + emailSubscriptions: { + confirmationComplete: true, + confirmedLocations: ["456"], + confirmedCases: [{ id: "case1", caseName: "Case A", caseNumber: "REF123" }] + } + } as any; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "subscription-confirmed/index", + expect.objectContaining({ + locations: expect.any(Array), + cases: expect.any(Array), + isPlural: true + }) + ); + }); + + it("should clear confirmation data from session after rendering", async () => { + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session?.emailSubscriptions?.confirmationComplete).toBeUndefined(); + expect(mockReq.session?.emailSubscriptions?.confirmedLocations).toBeUndefined(); + expect(mockReq.session?.emailSubscriptions?.confirmedCases).toBeUndefined(); + }); + + it("should filter out null locations when getLocationById returns null", async () => { + vi.mocked(locationService.getLocationById).mockImplementation((id: number) => + id === 456 ? null : { locationId: id, name: `Location ${id}`, welshName: `Lleoliad ${id}` } + ); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "subscription-confirmed/index", + expect.objectContaining({ + locations: ["Location 789"] + }) + ); + }); + + it("should redirect to subscription-management when no confirmationComplete flag", async () => { + mockReq.session = { emailSubscriptions: {} } as any; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management"); + expect(mockRes.render).not.toHaveBeenCalled(); + }); + + it("should redirect to subscription-management when confirmationComplete is false", async () => { + mockReq.session = { + emailSubscriptions: { + confirmationComplete: false, + confirmedLocations: ["456"] + } + } as any; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management"); + expect(mockRes.render).not.toHaveBeenCalled(); + }); }); }); diff --git a/libs/verified-pages/src/pages/subscription-confirmed/index.ts b/libs/verified-pages/src/pages/subscription-confirmed/index.ts index af8b53b01..0bbcb16ea 100644 --- a/libs/verified-pages/src/pages/subscription-confirmed/index.ts +++ b/libs/verified-pages/src/pages/subscription-confirmed/index.ts @@ -13,6 +13,7 @@ const getHandler = async (req: Request, res: Response) => { } const confirmedLocationIds = req.session.emailSubscriptions.confirmedLocations || []; + const confirmedCases = req.session.emailSubscriptions.confirmedCases || []; const confirmedLocations = ( await Promise.all( @@ -25,17 +26,20 @@ const getHandler = async (req: Request, res: Response) => { delete req.session.emailSubscriptions.confirmationComplete; delete req.session.emailSubscriptions.confirmedLocations; + delete req.session.emailSubscriptions.confirmedCases; if (!res.locals.navigation) { res.locals.navigation = {}; } res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); - const isPlural = confirmedLocations.length > 1; + const totalCount = confirmedLocations.length + confirmedCases.length; + const isPlural = totalCount > 1; res.render("subscription-confirmed/index", { ...t, locations: confirmedLocations, + cases: confirmedCases, isPlural, panelTitle: isPlural ? t.panelTitlePlural : t.panelTitle }); diff --git a/libs/verified-pages/src/pages/subscription-management/cy.ts b/libs/verified-pages/src/pages/subscription-management/cy.ts index 2eecede55..f2da0b7d9 100644 --- a/libs/verified-pages/src/pages/subscription-management/cy.ts +++ b/libs/verified-pages/src/pages/subscription-management/cy.ts @@ -2,10 +2,20 @@ export const cy = { title: "Eich tanysgrifiadau e-bost", heading: "Eich tanysgrifiadau e-bost", noSubscriptions: "Nid oes gennych unrhyw danysgrifiadau gweithredol", + noCaseSubscriptions: "Nid oes gennych unrhyw danysgrifiadau achos", + noCourtSubscriptions: "Nid oes gennych unrhyw danysgrifiadau llys neu dribiwnlys", addButton: "Ychwanegu tanysgrifiad e-bost", bulkUnsubscribeButton: "Dileu swmp o danysgrifiadau", tableHeaderLocation: "Enw llys neu dribiwnlys", - tableHeaderDate: "Dyddiad ychwanegu", - tableHeaderActions: "Gweithredoedd", - removeLink: "Dad-danysgrifio" + tableHeaderCaseName: "Enw'r achos", + tableHeaderCaseNumber: "Cyfeirnod", + tableHeaderDate: "Dyddiad wedi’i ychwanegu", + tableHeaderActions: "Camau gweithredu", + removeLink: "Dad-danysgrifio", + tabAllLabel: "Pob tanysgrifiad", + tabCaseLabel: "Tanysgrifiadau yn ôl achos", + tabCourtLabel: "Tanysgrifiadau yn ôl llys neu dribiwnlys", + courtSubscriptionsHeading: "Tanysgrifiadau llys neu dribiwnlys", + caseSubscriptionsHeading: "Tanysgrifiadau achos", + notAvailable: "Ddim ar gael" }; diff --git a/libs/verified-pages/src/pages/subscription-management/en.ts b/libs/verified-pages/src/pages/subscription-management/en.ts index 245fc6436..d41e96823 100644 --- a/libs/verified-pages/src/pages/subscription-management/en.ts +++ b/libs/verified-pages/src/pages/subscription-management/en.ts @@ -2,10 +2,20 @@ export const en = { title: "Your email subscriptions", heading: "Your email subscriptions", noSubscriptions: "You do not have any active subscriptions", + noCaseSubscriptions: "You do not have any case subscriptions", + noCourtSubscriptions: "You do not have any court or tribunal subscriptions", addButton: "Add email subscription", bulkUnsubscribeButton: "Bulk unsubscribe", tableHeaderLocation: "Court or tribunal name", + tableHeaderCaseName: "Case name", + tableHeaderCaseNumber: "Reference number", tableHeaderDate: "Date added", tableHeaderActions: "Actions", - removeLink: "Unsubscribe" + removeLink: "Unsubscribe", + tabAllLabel: "All subscriptions", + tabCaseLabel: "Subscriptions by case", + tabCourtLabel: "Subscriptions by court or tribunal", + courtSubscriptionsHeading: "Court or tribunal subscriptions", + caseSubscriptionsHeading: "Case subscriptions", + notAvailable: "Not available" }; diff --git a/libs/verified-pages/src/pages/subscription-management/index.njk b/libs/verified-pages/src/pages/subscription-management/index.njk index e686ee4da..46892aa51 100644 --- a/libs/verified-pages/src/pages/subscription-management/index.njk +++ b/libs/verified-pages/src/pages/subscription-management/index.njk @@ -1,6 +1,5 @@ {% extends "layouts/base-template.njk" %} {% from "govuk/components/button/macro.njk" import govukButton %} -{% from "govuk/components/input/macro.njk" import govukInput %} {% block pageTitle %} {{ title }} - {{ serviceName }} - {{ govUk }} @@ -14,10 +13,10 @@
    {{ govukButton({ text: addButton, - href: '/location-name-search' + href: '/subscription-add' }) }} - {% if count > 0 %} + {% if totalCount > 0 %} {{ govukButton({ text: bulkUnsubscribeButton, href: '/bulk-unsubscribe', @@ -26,33 +25,150 @@ {% endif %}
    - {% if count > 0 %} - - - - - - - - - - {% for subscription in subscriptions %} - - - - - - {% endfor %} - -
    {{ tableHeaderLocation }}{{ tableHeaderDate }}{{ tableHeaderActions }}
    {{ subscription.locationName }}{{ subscription.dateAdded | date('D MMMM YYYY') }} -
    - - - -
    -
    + {% if totalCount > 0 %} + + + {% if currentView == 'all' %} + {% if caseCount > 0 %} + + + + + + + + + + + {% for subscription in caseSubscriptions %} + + + + + + + {% endfor %} + +
    {{ tableHeaderCaseName }}{{ tableHeaderCaseNumber }}{{ tableHeaderDate }}{{ tableHeaderActions }}
    {{ subscription.caseName or notAvailable }}{{ subscription.caseNumber or notAvailable }}{{ subscription.dateAdded | date('D MMMM YYYY') }} +
    + + + +
    +
    + {% endif %} + + {% if courtCount > 0 %} + + + + + + + + + + {% for subscription in courtSubscriptions %} + + + + + + {% endfor %} + +
    {{ tableHeaderLocation }}{{ tableHeaderDate }}{{ tableHeaderActions }}
    {{ subscription.locationName }}{{ subscription.dateAdded | date('D MMMM YYYY') }} +
    + + + +
    +
    + {% endif %} + {% endif %} + + {% if currentView == 'case' %} + {% if caseCount > 0 %} + + + + + + + + + + + {% for subscription in caseSubscriptions %} + + + + + + + {% endfor %} + +
    {{ tableHeaderCaseName }}{{ tableHeaderCaseNumber }}{{ tableHeaderDate }}{{ tableHeaderActions }}
    {{ subscription.caseName or notAvailable }}{{ subscription.caseNumber or notAvailable }}{{ subscription.dateAdded | date('D MMMM YYYY') }} +
    + + + +
    +
    + {% else %} +

    {{ noCaseSubscriptions }}

    + {% endif %} + {% endif %} + + {% if currentView == 'location' %} + {% if courtCount > 0 %} + + + + + + + + + + {% for subscription in courtSubscriptions %} + + + + + + {% endfor %} + +
    {{ tableHeaderLocation }}{{ tableHeaderDate }}{{ tableHeaderActions }}
    {{ subscription.locationName }}{{ subscription.dateAdded | date('D MMMM YYYY') }} +
    + + + +
    +
    + {% else %} +

    {{ noCourtSubscriptions }}

    + {% endif %} + {% endif %} {% else %}

    {{ noSubscriptions }}

    {% endif %} diff --git a/libs/verified-pages/src/pages/subscription-management/index.test.ts b/libs/verified-pages/src/pages/subscription-management/index.test.ts index f39021a78..d3a757673 100644 --- a/libs/verified-pages/src/pages/subscription-management/index.test.ts +++ b/libs/verified-pages/src/pages/subscription-management/index.test.ts @@ -1,6 +1,8 @@ -import * as subscriptionService from "@hmcts/subscriptions"; +import * as subscriptionService from "@hmcts/subscription"; import type { Request, Response } from "express"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; import { GET } from "./index.js"; vi.mock("@hmcts/auth", () => ({ @@ -9,8 +11,9 @@ vi.mock("@hmcts/auth", () => ({ blockUserAccess: vi.fn(() => (_req: any, _res: any, next: any) => next()) })); -vi.mock("@hmcts/subscriptions", () => ({ - getAllSubscriptionsByUserId: vi.fn() +vi.mock("@hmcts/subscription", () => ({ + getAllSubscriptionsByUserId: vi.fn(), + getCaseSubscriptionsByUserId: vi.fn() })); describe("subscription-management", () => { @@ -20,12 +23,14 @@ describe("subscription-management", () => { beforeEach(() => { mockReq = { user: { id: "user123" } as any, - path: "/subscription-management" + path: "/subscription-management", + query: {}, + csrfToken: vi.fn(() => "mock-csrf-token") }; mockRes = { render: vi.fn(), redirect: vi.fn(), - locals: {} + locals: { locale: "en" } }; }); @@ -33,9 +38,44 @@ describe("subscription-management", () => { vi.clearAllMocks(); }); + describe("translations", () => { + describe("en", () => { + it("should have all required translation keys", () => { + expect(en.title).toBeDefined(); + expect(en.heading).toBeDefined(); + expect(en.noSubscriptions).toBeDefined(); + expect(en.noCaseSubscriptions).toBeDefined(); + expect(en.noCourtSubscriptions).toBeDefined(); + expect(en.addButton).toBeDefined(); + expect(en.bulkUnsubscribeButton).toBeDefined(); + expect(en.tableHeaderLocation).toBeDefined(); + expect(en.tableHeaderCaseName).toBeDefined(); + expect(en.tableHeaderCaseNumber).toBeDefined(); + expect(en.tableHeaderDate).toBeDefined(); + expect(en.tableHeaderActions).toBeDefined(); + expect(en.removeLink).toBeDefined(); + expect(en.tabAllLabel).toBeDefined(); + expect(en.tabCaseLabel).toBeDefined(); + expect(en.tabCourtLabel).toBeDefined(); + expect(en.courtSubscriptionsHeading).toBeDefined(); + expect(en.caseSubscriptionsHeading).toBeDefined(); + expect(en.notAvailable).toBeDefined(); + }); + }); + + describe("cy", () => { + it("should have all required translation keys matching en", () => { + const enKeys = Object.keys(en); + const cyKeys = Object.keys(cy); + + expect(cyKeys).toEqual(enKeys); + }); + }); + }); + describe("GET", () => { - it("should render page with subscriptions", async () => { - const mockSubscriptions = [ + it("should render page with court subscriptions", async () => { + const mockCourtSubscriptions = [ { subscriptionId: "sub1", type: "court" as const, @@ -52,16 +92,20 @@ describe("subscription-management", () => { } ]; - vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue(mockSubscriptions); + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue(mockCourtSubscriptions); + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue([]); await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); expect(subscriptionService.getAllSubscriptionsByUserId).toHaveBeenCalledWith("user123", "en"); + expect(subscriptionService.getCaseSubscriptionsByUserId).toHaveBeenCalledWith("user123"); expect(mockRes.render).toHaveBeenCalledWith( "subscription-management/index", expect.objectContaining({ - count: 2, - subscriptions: expect.arrayContaining([ + courtCount: 2, + caseCount: 0, + totalCount: 2, + courtSubscriptions: expect.arrayContaining([ expect.objectContaining({ locationName: "Birmingham Crown Court" }), expect.objectContaining({ locationName: "Manchester Crown Court" }) ]) @@ -71,13 +115,16 @@ describe("subscription-management", () => { it("should render page with no subscriptions", async () => { vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue([]); await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); expect(mockRes.render).toHaveBeenCalledWith( "subscription-management/index", expect.objectContaining({ - count: 0 + courtCount: 0, + caseCount: 0, + totalCount: 0 }) ); }); @@ -89,6 +136,280 @@ describe("subscription-management", () => { expect(mockRes.redirect).toHaveBeenCalledWith("/sign-in"); expect(subscriptionService.getAllSubscriptionsByUserId).not.toHaveBeenCalled(); + expect(subscriptionService.getCaseSubscriptionsByUserId).not.toHaveBeenCalled(); + }); + + it("should render page with case subscriptions", async () => { + const mockCaseSubscriptions = [ + { + subscriptionId: "sub1", + type: "case" as const, + caseName: "Test Case", + partyName: "John Doe", + referenceNumber: "REF123", + dateAdded: new Date() + } + ]; + + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue(mockCaseSubscriptions); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "subscription-management/index", + expect.objectContaining({ + courtCount: 0, + caseCount: 1, + totalCount: 1, + caseSubscriptions: expect.arrayContaining([expect.objectContaining({ caseName: "Test Case" })]) + }) + ); + }); + + it("should render page with both court and case subscriptions", async () => { + const mockCourtSubscriptions = [ + { + subscriptionId: "sub1", + type: "court" as const, + courtOrTribunalName: "Birmingham Crown Court", + locationId: 456, + dateAdded: new Date() + } + ]; + const mockCaseSubscriptions = [ + { + subscriptionId: "sub2", + type: "case" as const, + caseName: "Test Case", + partyName: "John Doe", + referenceNumber: "REF123", + dateAdded: new Date() + } + ]; + + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue(mockCourtSubscriptions); + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue(mockCaseSubscriptions); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "subscription-management/index", + expect.objectContaining({ + courtCount: 1, + caseCount: 1, + totalCount: 2 + }) + ); + }); + + it("should render page with Welsh translations when locale is cy", async () => { + mockRes.locals = { locale: "cy" }; + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue([]); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(subscriptionService.getAllSubscriptionsByUserId).toHaveBeenCalledWith("user123", "cy"); + expect(mockRes.render).toHaveBeenCalledWith( + "subscription-management/index", + expect.objectContaining({ + title: cy.title, + heading: cy.heading + }) + ); + }); + + it("should set navigation items", async () => { + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue([]); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.locals?.navigation).toBeDefined(); + expect(mockRes.locals?.navigation?.verifiedItems).toBeDefined(); + }); + + it("should include CSRF token in render data", async () => { + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue([]); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "subscription-management/index", + expect.objectContaining({ + csrfToken: "mock-csrf-token" + }) + ); + }); + + it("should handle view parameter", async () => { + mockReq.query = { view: "case" }; + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue([]); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "subscription-management/index", + expect.objectContaining({ + currentView: "case" + }) + ); + }); + + it("should default to 'all' view when no view parameter provided", async () => { + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue([]); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "subscription-management/index", + expect.objectContaining({ + currentView: "all" + }) + ); + }); + + it("should sort court subscriptions alphabetically by location name", async () => { + const mockCourtSubscriptions = [ + { + subscriptionId: "sub1", + type: "court" as const, + courtOrTribunalName: "Zebra Court", + locationId: 456, + dateAdded: new Date() + }, + { + subscriptionId: "sub2", + type: "court" as const, + courtOrTribunalName: "Alpha Court", + locationId: 789, + dateAdded: new Date() + } + ]; + + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue(mockCourtSubscriptions); + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue([]); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + const renderCall = vi.mocked(mockRes.render).mock.calls[0]; + const courtSubscriptions = renderCall[1].courtSubscriptions; + expect(courtSubscriptions[0].locationName).toBe("Alpha Court"); + expect(courtSubscriptions[1].locationName).toBe("Zebra Court"); + }); + + it("should sort case subscriptions alphabetically by case name", async () => { + const mockCaseSubscriptions = [ + { + subscriptionId: "sub1", + type: "case" as const, + caseName: "Zebra Case", + partyName: "Party A", + referenceNumber: "REF1", + dateAdded: new Date() + }, + { + subscriptionId: "sub2", + type: "case" as const, + caseName: "Alpha Case", + partyName: "Party B", + referenceNumber: "REF2", + dateAdded: new Date() + } + ]; + + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue(mockCaseSubscriptions); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + const renderCall = vi.mocked(mockRes.render).mock.calls[0]; + const caseSubscriptions = renderCall[1].caseSubscriptions; + expect(caseSubscriptions[0].caseName).toBe("Alpha Case"); + expect(caseSubscriptions[1].caseName).toBe("Zebra Case"); + }); + + it("should deduplicate case subscriptions with same case name and case number", async () => { + const mockCaseSubscriptions = [ + { + subscriptionId: "sub1", + type: "case" as const, + caseName: "Test Case", + caseNumber: "CASE123", + partyName: "Party A", + referenceNumber: "REF1", + searchType: "CASE_NAME", + dateAdded: new Date() + }, + { + subscriptionId: "sub2", + type: "case" as const, + caseName: "Test Case", + caseNumber: "CASE123", + partyName: "Party A", + referenceNumber: "REF1", + searchType: "CASE_NUMBER", + dateAdded: new Date() + }, + { + subscriptionId: "sub3", + type: "case" as const, + caseName: "Different Case", + caseNumber: "CASE456", + partyName: "Party B", + referenceNumber: "REF2", + searchType: "CASE_NAME", + dateAdded: new Date() + } + ]; + + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue(mockCaseSubscriptions); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + const renderCall = vi.mocked(mockRes.render).mock.calls[0]; + const caseSubscriptions = renderCall[1].caseSubscriptions; + + expect(caseSubscriptions).toHaveLength(2); + expect(caseSubscriptions[0].caseName).toBe("Different Case"); + expect(caseSubscriptions[0].allSubscriptionIds).toEqual(["sub3"]); + expect(caseSubscriptions[1].caseName).toBe("Test Case"); + expect(caseSubscriptions[1].allSubscriptionIds).toEqual(["sub1", "sub2"]); + expect(renderCall[1].caseCount).toBe(2); + expect(renderCall[1].totalCount).toBe(2); + }); + + it("should handle errors gracefully", async () => { + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockRejectedValue(new Error("Database error")); + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue([]); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "subscription-management/index", + expect.objectContaining({ + courtSubscriptions: [], + caseSubscriptions: [], + courtCount: 0, + caseCount: 0, + totalCount: 0 + }) + ); + }); + + it("should set navigation items when error occurs", async () => { + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockRejectedValue(new Error("Database error")); + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue([]); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.locals?.navigation).toBeDefined(); + expect(mockRes.locals?.navigation?.verifiedItems).toBeDefined(); }); }); }); diff --git a/libs/verified-pages/src/pages/subscription-management/index.ts b/libs/verified-pages/src/pages/subscription-management/index.ts index 9ee02cbce..4ccdcd2b1 100644 --- a/libs/verified-pages/src/pages/subscription-management/index.ts +++ b/libs/verified-pages/src/pages/subscription-management/index.ts @@ -1,5 +1,5 @@ import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth"; -import { getAllSubscriptionsByUserId } from "@hmcts/subscriptions"; +import { getAllSubscriptionsByUserId, getCaseSubscriptionsByUserId } from "@hmcts/subscription"; import type { Request, RequestHandler, Response } from "express"; import { cy } from "./cy.js"; import { en } from "./en.js"; @@ -13,24 +13,30 @@ const getHandler = async (req: Request, res: Response) => { } const userId = req.user.id; + const view = (req.query.view as string) || "all"; try { - const subscriptions = await getAllSubscriptionsByUserId(userId, locale); + const [courtSubscriptions, caseSubscriptions] = await Promise.all([getAllSubscriptionsByUserId(userId, locale), getCaseSubscriptionsByUserId(userId)]); - const subscriptionsWithDetails = subscriptions.map((sub) => ({ - ...sub, - locationName: sub.courtOrTribunalName - })); + const courtSubscriptionsWithDetails = sortCourtSubscriptions(courtSubscriptions); + const deduplicatedCaseSubscriptions = deduplicateCaseSubscriptions(caseSubscriptions); + const sortedCaseSubscriptions = sortCaseSubscriptions(deduplicatedCaseSubscriptions); if (!res.locals.navigation) { res.locals.navigation = {}; } res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + const totalCount = courtSubscriptions.length + deduplicatedCaseSubscriptions.length; + res.render("subscription-management/index", { ...t, - subscriptions: subscriptionsWithDetails, - count: subscriptions.length, + courtSubscriptions: courtSubscriptionsWithDetails, + caseSubscriptions: sortedCaseSubscriptions, + courtCount: courtSubscriptions.length, + caseCount: deduplicatedCaseSubscriptions.length, + totalCount, + currentView: view, csrfToken: (req as any).csrfToken?.() || "" }); } catch (error) { @@ -43,11 +49,47 @@ const getHandler = async (req: Request, res: Response) => { res.render("subscription-management/index", { ...t, - subscriptions: [], - count: 0, + courtSubscriptions: [], + caseSubscriptions: [], + courtCount: 0, + caseCount: 0, + totalCount: 0, + currentView: view, csrfToken: (req as any).csrfToken?.() || "" }); } }; +const sortCourtSubscriptions = (subscriptions: any[]): any[] => { + return subscriptions + .map((sub) => ({ + ...sub, + locationName: sub.courtOrTribunalName + })) + .sort((a, b) => (a.locationName || "").localeCompare(b.locationName || "")); +}; + +const deduplicateCaseSubscriptions = (subscriptions: any[]): any[] => { + const seen = new Map(); + + for (const sub of subscriptions) { + const caseNumber = sub.caseNumber || ""; + const caseName = sub.caseName || ""; + const key = `${caseNumber}:${caseName}`; + + if (!seen.has(key)) { + seen.set(key, { ...sub, allSubscriptionIds: [sub.subscriptionId] }); + } else { + const existing = seen.get(key); + existing.allSubscriptionIds.push(sub.subscriptionId); + } + } + + return Array.from(seen.values()); +}; + +const sortCaseSubscriptions = (subscriptions: any[]): any[] => { + return [...subscriptions].sort((a, b) => (a.caseName || "").localeCompare(b.caseName || "")); +}; + export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler]; diff --git a/libs/verified-pages/src/pages/unsubscribe-confirmation/index.njk b/libs/verified-pages/src/pages/unsubscribe-confirmation/index.njk index e1a7419eb..506ece2af 100644 --- a/libs/verified-pages/src/pages/unsubscribe-confirmation/index.njk +++ b/libs/verified-pages/src/pages/unsubscribe-confirmation/index.njk @@ -18,7 +18,7 @@

    diff --git a/libs/verified-pages/src/pages/unsubscribe-confirmation/index.test.ts b/libs/verified-pages/src/pages/unsubscribe-confirmation/index.test.ts index 15e592b54..21c821525 100644 --- a/libs/verified-pages/src/pages/unsubscribe-confirmation/index.test.ts +++ b/libs/verified-pages/src/pages/unsubscribe-confirmation/index.test.ts @@ -1,6 +1,8 @@ -import * as subscriptionService from "@hmcts/subscriptions"; +import * as subscriptionService from "@hmcts/subscription"; import type { Request, Response } from "express"; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; import { GET } from "./index.js"; vi.mock("@hmcts/auth", () => ({ @@ -9,7 +11,7 @@ vi.mock("@hmcts/auth", () => ({ blockUserAccess: vi.fn(() => (_req: any, _res: any, next: any) => next()) })); -vi.mock("@hmcts/subscriptions", () => ({ +vi.mock("@hmcts/subscription", () => ({ removeSubscription: vi.fn() })); @@ -25,12 +27,13 @@ describe("unsubscribe-confirmation", () => { emailSubscriptions: { subscriptionToRemove: "sub123" } - } as any + } as any, + path: "/unsubscribe-confirmation" }; mockRes = { render: vi.fn(), redirect: vi.fn(), - locals: {} + locals: { locale: "en" } }; }); @@ -42,6 +45,31 @@ describe("unsubscribe-confirmation", () => { consoleErrorSpy.mockRestore(); }); + describe("translations", () => { + describe("en", () => { + it("should have all required translation keys", () => { + expect(en.title).toBeDefined(); + expect(en.panelTitle).toBeDefined(); + expect(en.panelText).toBeDefined(); + expect(en.continueText).toBeDefined(); + expect(en.yourAccountLink).toBeDefined(); + expect(en.inOrderTo).toBeDefined(); + expect(en.addNewSubscriptionLink).toBeDefined(); + expect(en.manageSubscriptionsLink).toBeDefined(); + expect(en.findCourtLink).toBeDefined(); + }); + }); + + describe("cy", () => { + it("should have all required translation keys matching en", () => { + const enKeys = Object.keys(en); + const cyKeys = Object.keys(cy); + + expect(cyKeys).toEqual(enKeys); + }); + }); + }); + describe("GET", () => { it("should redirect if no subscriptionToRemove in session", async () => { mockReq.session = { emailSubscriptions: {} } as any; @@ -84,5 +112,189 @@ describe("unsubscribe-confirmation", () => { expect(mockRes.redirect).toHaveBeenCalledWith("/sign-in"); expect(subscriptionService.removeSubscription).not.toHaveBeenCalled(); }); + + it("should render page with Welsh translations when locale is cy", async () => { + mockRes.locals = { locale: "cy" }; + vi.mocked(subscriptionService.removeSubscription).mockResolvedValue({ + subscriptionId: "sub123", + userId: "user123", + locationId: 456, + dateAdded: new Date() + }); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "unsubscribe-confirmation/index", + expect.objectContaining({ + title: cy.title, + panelTitle: cy.panelTitle + }) + ); + }); + + it("should set navigation items", async () => { + vi.mocked(subscriptionService.removeSubscription).mockResolvedValue({ + subscriptionId: "sub123", + userId: "user123", + locationId: 456, + dateAdded: new Date() + }); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.locals?.navigation).toBeDefined(); + expect(mockRes.locals?.navigation?.verifiedItems).toBeDefined(); + }); + + it("should clear subscriptionToRemove from session after removal", async () => { + vi.mocked(subscriptionService.removeSubscription).mockResolvedValue({ + subscriptionId: "sub123", + userId: "user123", + locationId: 456, + dateAdded: new Date() + }); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session?.emailSubscriptions?.subscriptionToRemove).toBeUndefined(); + }); + + it("should redirect to subscription-management when session.emailSubscriptions is undefined", async () => { + mockReq.session = {} as any; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management"); + expect(subscriptionService.removeSubscription).not.toHaveBeenCalled(); + }); + + it("should not error when clearing subscriptionToRemove if emailSubscriptions exists", async () => { + mockReq.session = { + emailSubscriptions: { + subscriptionToRemove: "sub123" + } + } as any; + vi.mocked(subscriptionService.removeSubscription).mockResolvedValue({ + subscriptionId: "sub123", + userId: "user123", + locationId: 456, + dateAdded: new Date() + }); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session?.emailSubscriptions?.subscriptionToRemove).toBeUndefined(); + expect(mockRes.render).toHaveBeenCalled(); + }); + + it("should handle error when removeSubscription fails with authentication error", async () => { + vi.mocked(subscriptionService.removeSubscription).mockRejectedValue(new Error("Authentication failed")); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(consoleErrorSpy).toHaveBeenCalledWith("Error removing subscription:", expect.any(Error)); + expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management"); + }); + + it("should handle error when removeSubscription fails with not found error", async () => { + vi.mocked(subscriptionService.removeSubscription).mockRejectedValue(new Error("Subscription not found")); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(consoleErrorSpy).toHaveBeenCalledWith("Error removing subscription:", expect.any(Error)); + expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management"); + }); + + it("should call removeSubscription with correct parameters", async () => { + vi.mocked(subscriptionService.removeSubscription).mockResolvedValue({ + subscriptionId: "sub123", + userId: "user123", + locationId: 456, + dateAdded: new Date() + }); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(subscriptionService.removeSubscription).toHaveBeenCalledWith("sub123", "user123"); + expect(subscriptionService.removeSubscription).toHaveBeenCalledTimes(1); + }); + + it("should render page with English content by default", async () => { + vi.mocked(subscriptionService.removeSubscription).mockResolvedValue({ + subscriptionId: "sub123", + userId: "user123", + locationId: 456, + dateAdded: new Date() + }); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "unsubscribe-confirmation/index", + expect.objectContaining({ + title: en.title, + panelTitle: en.panelTitle + }) + ); + }); + + it("should remove multiple subscriptions for comma-separated IDs", async () => { + const id1 = "sub123"; + const id2 = "sub456"; + mockReq.session = { + emailSubscriptions: { + subscriptionToRemove: `${id1},${id2}` + } + } as any; + + vi.mocked(subscriptionService.removeSubscription) + .mockResolvedValueOnce({ + subscriptionId: id1, + userId: "user123", + locationId: 456, + dateAdded: new Date() + }) + .mockResolvedValueOnce({ + subscriptionId: id2, + userId: "user123", + locationId: 456, + dateAdded: new Date() + }); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(subscriptionService.removeSubscription).toHaveBeenCalledWith(id1, "user123"); + expect(subscriptionService.removeSubscription).toHaveBeenCalledWith(id2, "user123"); + expect(subscriptionService.removeSubscription).toHaveBeenCalledTimes(2); + expect(mockReq.session?.emailSubscriptions?.subscriptionToRemove).toBeUndefined(); + expect(mockRes.render).toHaveBeenCalledWith("unsubscribe-confirmation/index", expect.any(Object)); + }); + + it("should redirect on error when removing any of multiple subscriptions", async () => { + const id1 = "sub123"; + const id2 = "sub456"; + mockReq.session = { + emailSubscriptions: { + subscriptionToRemove: `${id1},${id2}` + } + } as any; + + vi.mocked(subscriptionService.removeSubscription) + .mockResolvedValueOnce({ + subscriptionId: id1, + userId: "user123", + locationId: 456, + dateAdded: new Date() + }) + .mockRejectedValueOnce(new Error("Failed to remove")); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(subscriptionService.removeSubscription).toHaveBeenCalledWith(id1, "user123"); + expect(subscriptionService.removeSubscription).toHaveBeenCalledWith(id2, "user123"); + expect(consoleErrorSpy).toHaveBeenCalledWith("Error removing subscription:", expect.any(Error)); + expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management"); + }); }); }); diff --git a/libs/verified-pages/src/pages/unsubscribe-confirmation/index.ts b/libs/verified-pages/src/pages/unsubscribe-confirmation/index.ts index 8f5b48809..3e8a51229 100644 --- a/libs/verified-pages/src/pages/unsubscribe-confirmation/index.ts +++ b/libs/verified-pages/src/pages/unsubscribe-confirmation/index.ts @@ -1,5 +1,5 @@ import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth"; -import { removeSubscription } from "@hmcts/subscriptions"; +import { removeSubscription } from "@hmcts/subscription"; import type { Request, RequestHandler, Response } from "express"; import { cy } from "./cy.js"; import { en } from "./en.js"; @@ -13,14 +13,20 @@ const getHandler = async (req: Request, res: Response) => { } const userId = req.user.id; - const subscriptionId = req.session.emailSubscriptions?.subscriptionToRemove; + const subscriptionIds = req.session.emailSubscriptions?.subscriptionToRemove; - if (!subscriptionId) { + if (!subscriptionIds) { return res.redirect("/subscription-management"); } + // Split comma-separated IDs if present + const idsArray = subscriptionIds.split(",").map((id: string) => id.trim()); + try { - await removeSubscription(subscriptionId, userId); + // Delete all subscriptions + for (const id of idsArray) { + await removeSubscription(id, userId); + } if (req.session.emailSubscriptions) { delete req.session.emailSubscriptions.subscriptionToRemove; diff --git a/package.json b/package.json index d77306433..3fc70e8b0 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "packageManager": "yarn@4.12.0", "resolutions": { "@isaacs/brace-expansion": "5.0.1", + "ajv": "8.18.0", "axios": "1.13.5", "body-parser": "2.2.2", "glob": "13.0.3", diff --git a/tsconfig.json b/tsconfig.json index ed3ec2052..74cdac12a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,10 +32,12 @@ "@hmcts/administrative-court-daily-cause-list": ["libs/list-types/administrative-court-daily-cause-list/src"], "@hmcts/blob-ingestion": ["libs/api/src"], "@hmcts/blob-ingestion/config": ["libs/api/src/config"], - "@hmcts/subscriptions": ["libs/subscriptions/src"], - "@hmcts/subscriptions/config": ["libs/subscriptions/src/config"], + "@hmcts/subscriptions": ["libs/subscription/src"], + "@hmcts/subscriptions/config": ["libs/subscription/src/config"], "@hmcts/notification": ["libs/notification/src"], - "@hmcts/notifications": ["libs/notifications/src"] + "@hmcts/notifications": ["libs/notifications/src"], + "@hmcts/list-search-config": ["libs/list-search-config/src"], + "@hmcts/list-search-config/config": ["libs/list-search-config/src/config"] }, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, diff --git a/yarn.lock b/yarn.lock index 592befefb..48e679fc9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1162,6 +1162,14 @@ __metadata: languageName: unknown linkType: soft +"@hmcts/list-search-config@workspace:libs/list-search-config": + version: 0.0.0-use.local + resolution: "@hmcts/list-search-config@workspace:libs/list-search-config" + peerDependencies: + express: ^5.2.0 + languageName: unknown + linkType: soft + "@hmcts/list-types-common@workspace:*, @hmcts/list-types-common@workspace:libs/list-types/common": version: 0.0.0-use.local resolution: "@hmcts/list-types-common@workspace:libs/list-types/common" @@ -1298,9 +1306,9 @@ __metadata: languageName: unknown linkType: soft -"@hmcts/subscriptions@workspace:*, @hmcts/subscriptions@workspace:libs/subscriptions": +"@hmcts/subscription@workspace:*, @hmcts/subscription@workspace:libs/subscription": version: 0.0.0-use.local - resolution: "@hmcts/subscriptions@workspace:libs/subscriptions" + resolution: "@hmcts/subscription@workspace:libs/subscription" dependencies: "@hmcts/auth": "workspace:*" "@hmcts/location": "workspace:*" @@ -1329,7 +1337,7 @@ __metadata: version: 0.0.0-use.local resolution: "@hmcts/verified-pages@workspace:libs/verified-pages" dependencies: - "@hmcts/subscriptions": "workspace:*" + "@hmcts/subscription": "workspace:*" peerDependencies: express: ^5.2.0 languageName: unknown @@ -3347,15 +3355,15 @@ __metadata: languageName: node linkType: hard -"ajv@npm:8.17.1": - version: 8.17.1 - resolution: "ajv@npm:8.17.1" +"ajv@npm:8.18.0": + version: 8.18.0 + resolution: "ajv@npm:8.18.0" dependencies: fast-deep-equal: "npm:^3.1.3" fast-uri: "npm:^3.0.1" json-schema-traverse: "npm:^1.0.0" require-from-string: "npm:^2.0.2" - checksum: 10c0/ec3ba10a573c6b60f94639ffc53526275917a2df6810e4ab5a6b959d87459f9ef3f00d5e7865b82677cb7d21590355b34da14d1d0b9c32d75f95a187e76fff35 + checksum: 10c0/e7517c426173513a07391be951879932bdf3348feaebd2199f5b901c20f99d60db8cd1591502d4d551dc82f594e82a05c4fe1c70139b15b8937f7afeaed9532f languageName: node linkType: hard