diff --git a/apps/web/src/assets/css/index.scss b/apps/web/src/assets/css/index.scss index e3940a95..02728003 100644 --- a/apps/web/src/assets/css/index.scss +++ b/apps/web/src/assets/css/index.scss @@ -7,7 +7,7 @@ @use "@hmcts/web-core/src/assets/css/button-as-link.scss"; @use "@hmcts/admin-pages/src/assets/css/index.scss" as admin; @use "@hmcts/system-admin-pages/src/assets/css/dashboard.scss"; -@use "@hmcts/verified-pages/src/assets/css/index.scss" as verified; +@use "@hmcts/verified-pages/src/assets/css/verified-pages.scss" as verified; .govuk-service-navigation__navigation-end { margin-left: auto !important; diff --git a/apps/web/vite.build.ts b/apps/web/vite.build.ts index b6a43ab7..4cdf8f78 100644 --- a/apps/web/vite.build.ts +++ b/apps/web/vite.build.ts @@ -1,6 +1,8 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; +import { assets as adminPagesAssets } from "@hmcts/admin-pages/config"; import { assets as systemAdminAssets } from "@hmcts/system-admin-pages/config"; +import { assets as verifiedPagesAssets } from "@hmcts/verified-pages/config"; import { createBaseViteConfig } from "@hmcts/web-core"; import { assets as webCoreAssets } from "@hmcts/web-core/config"; import { defineConfig, mergeConfig } from "vite"; @@ -9,10 +11,19 @@ import { viteStaticCopy } from "vite-plugin-static-copy"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const baseConfig = createBaseViteConfig([path.join(__dirname, "src", "assets"), webCoreAssets, systemAdminAssets]); +const baseConfig = createBaseViteConfig([path.join(__dirname, "src", "assets"), webCoreAssets, systemAdminAssets, verifiedPagesAssets, adminPagesAssets]); export default defineConfig( mergeConfig(baseConfig, { + build: { + rollupOptions: { + input: { + ...baseConfig.build?.rollupOptions?.input, + web_css: path.join(__dirname, "src/assets/css/index.scss"), + web_js: path.join(__dirname, "src/assets/js/index.ts") + } + } + }, plugins: [ viteStaticCopy({ targets: [ diff --git a/docs/tickets/VIBE-306/plan.md b/docs/tickets/VIBE-306/plan.md new file mode 100644 index 00000000..dc0b68f1 --- /dev/null +++ b/docs/tickets/VIBE-306/plan.md @@ -0,0 +1,312 @@ +# VIBE-306: Technical Implementation Plan + +## Overview +This ticket implements bulk unsubscribe functionality for verified media users, allowing them to select multiple subscriptions and remove them in a single operation. The implementation includes a 3-page workflow with tabbed views, checkbox selection, confirmation, and success messaging. + +## Summary +Verified media users can efficiently manage their subscriptions by selecting multiple subscriptions across different categories (all, by case, by court/tribunal) and removing them in bulk. The feature includes tab-based filtering, select-all functionality, confirmation workflow, and prevents accidental deletion through a two-step confirmation process. + +## Architecture + +### Database Requirements +- Use existing subscription tables (location subscriptions, case subscriptions, list type subscriptions) +- Delete operations must be transactional to ensure all selected subscriptions are removed atomically +- Soft delete vs hard delete consideration (recommendation: hard delete for immediate effect) + +### Subscription Types +1. **Subscriptions by case** - case name, party name, reference number subscriptions +2. **Subscriptions by court or tribunal** - location-based subscriptions +3. **All subscriptions** - combined view of both types + +## Module Structure + +Extend existing subscription module or create: `libs/bulk-unsubscribe` + +``` +libs/bulk-unsubscribe/ +├── package.json +├── tsconfig.json +└── src/ + ├── index.ts # Business logic exports + ├── config.ts # Module configuration + ├── pages/ + │ ├── bulk-unsubscribe.ts + │ ├── bulk-unsubscribe.njk + │ ├── confirm-bulk-unsubscribe.ts + │ ├── confirm-bulk-unsubscribe.njk + │ ├── bulk-unsubscribe-success.ts + │ └── bulk-unsubscribe-success.njk + ├── services/ + │ └── bulk-unsubscribe-service.ts + ├── assets/ + │ └── js/ + │ └── select-all.ts # Client-side select all logic + └── locales/ + ├── en.ts + └── cy.ts +``` + +## Implementation Tasks + +### 1. Database Service + +**bulk-unsubscribe-service.ts:** +- `getAllSubscriptionsByUserId(userId)` - Get all subscriptions for display +- `getCaseSubscriptionsByUserId(userId)` - Get case subscriptions only +- `getCourtSubscriptionsByUserId(userId)` - Get court/tribunal subscriptions only +- `deleteSubscriptionsByIds(subscriptionIds, userId)` - Bulk delete with transaction +- `validateSubscriptionOwnership(subscriptionIds, userId)` - Security check +- `getSubscriptionDetailsForConfirmation(subscriptionIds)` - Get details for confirmation page + +### 2. Page Controllers and Templates + +**bulk-unsubscribe (Page 1):** +- GET: + - Query user's subscriptions based on selected tab (default: All) + - Query parameter: `view` (all|case|court) + - Display tabbed interface + - Render tables with checkboxes + - Show empty state if no subscriptions in selected view +- POST: + - Validate at least one subscription selected + - Store selected subscription IDs in session + - Redirect to confirm-bulk-unsubscribe +- Tab switching: + - Use query parameters to switch views + - Preserve selections across tab switches (store in session) +- Select all functionality: + - Client-side JavaScript to check/uncheck all boxes + - Header checkbox controls all row checkboxes + +**confirm-bulk-unsubscribe (Page 2):** +- GET: + - Retrieve selected subscription IDs from session + - Query subscription details for display + - Render confirmation table + - Show Yes/No radio buttons +- POST: + - Validate radio selection (Yes or No) + - If No: Redirect to your-email-subscriptions + - If Yes: Delete subscriptions using bulk-unsubscribe-service + - Clear session data + - Redirect to bulk-unsubscribe-success +- Transaction handling: + - Use database transaction to ensure all-or-nothing deletion + - Log deletion for audit purposes + +**bulk-unsubscribe-success (Page 3):** +- GET: Display success banner +- Show navigation links: + - Add a new email subscription + - Manage your current email subscriptions + - Find a court or tribunal +- Clear session data if not already cleared +- POST/Redirect/GET pattern to prevent duplicate deletions + +### 3. Tab Implementation + +**Tabbed Views:** +- Implement GOV.UK Tabs component +- Three tabs: + 1. All subscriptions - Combined view + 2. Subscriptions by case - Case subscriptions only + 3. Subscriptions by court or tribunal - Location subscriptions only +- Tab state managed via query parameter: `/bulk-unsubscribe?view=case` +- Preserve selections when switching tabs (store in session) + +**Empty States:** +- If no subscriptions in selected tab, show empty state message +- Hide table when empty +- Message: "You do not have any subscriptions in this category." + +### 4. Table Structures + +**Subscriptions by case table:** +- Columns: Select (checkbox), Case name, Party name, Reference number, Date added +- Each row has unique checkbox with subscription ID as value + +**Subscriptions by court or tribunal table:** +- Columns: Select (checkbox), Court or tribunal name, Date added +- Each row has unique checkbox with subscription ID as value + +**All subscriptions view:** +- Display both tables sequentially +- Case subscriptions table first +- Court/tribunal subscriptions table second +- Separate "Select all" for each table + +### 5. Select All Functionality + +**Client-side JavaScript (select-all.ts):** +```typescript +// Pseudocode +function initSelectAll() { + const selectAllCheckboxes = document.querySelectorAll('.select-all-checkbox'); + + selectAllCheckboxes.forEach(checkbox => { + checkbox.addEventListener('change', (e) => { + const tableId = e.target.dataset.table; + const rowCheckboxes = document.querySelectorAll(`#${tableId} .row-checkbox`); + + rowCheckboxes.forEach(row => { + row.checked = e.target.checked; + }); + }); + }); + + // Update select-all if all rows manually checked + updateSelectAllState(); +} +``` + +### 6. Session Management +- Store selected subscription IDs across pages +- Session data structure: +```typescript +{ + bulk_unsubscribe: { + selected_ids: [123, 456, 789], + view: 'all' | 'case' | 'court' + } +} +``` +- Clear session after successful deletion or cancellation + +### 7. Validation + +**Page 1 Validation:** +- At least one checkbox must be selected +- Error: "At least one subscription must be selected" +- Display GOV.UK error summary with anchor link + +**Page 2 Validation:** +- One radio must be selected (Yes or No) +- Error: "An option must be selected." +- Display GOV.UK error summary + +### 8. Security Considerations +- Validate subscription ownership before deletion +- Use CSRF tokens on POST requests +- Ensure authenticated user owns all selected subscriptions +- Use database transactions for atomic deletions +- Log all bulk deletions for audit trail + +### 9. Locales +Create en.ts and cy.ts with content for: +- Page titles +- Tab labels +- Table column headings +- Button labels +- Radio options +- Empty state messages +- Error messages +- Success banner text +- Navigation links + +### 10. Accessibility Implementation +- Ensure all tabs support keyboard navigation +- ARIA roles for tabs: `role="tablist"`, `role="tab"`, `role="tabpanel"` +- Checkboxes must have associated labels +- Select all checkbox clearly labeled +- Error summaries with anchor links to fields +- Success banner with `role="status"` or `role="alert"` +- Tables use semantic markup with proper header scope +- Screen reader announcements for tab changes +- Visible focus indicators on all interactive elements + +### 11. Styling +- Use GOV.UK Design System components: + - Tabs + - Checkboxes + - Radios + - Tables + - Button (green) + - Error summary + - Success banner +- Responsive design for mobile/tablet +- Ensure checkbox alignment in tables +- Visual indication for selected rows (optional) + +### 12. Integration +- Link from "Your email subscriptions" page +- Add "Bulk unsubscribe" button/link next to "Add email subscription" +- Ensure authentication middleware protects all pages +- Add authorization check for verified media user role +- Register module in apps/web/src/app.ts + +### 13. Testing + +**Unit Tests (Vitest):** +- bulk-unsubscribe-service.test.ts + - Get all subscriptions + - Get case subscriptions + - Get court subscriptions + - Delete subscriptions in transaction + - Validate subscription ownership + - Handle errors gracefully + - Verify audit logging + +**E2E Tests (Playwright):** +- Create single journey test: "Verified user can bulk unsubscribe @nightly" + - Navigate from email subscriptions to bulk unsubscribe + - Test "All subscriptions" tab view + - Test "Subscriptions by case" tab view + - Test "Subscriptions by court or tribunal" tab view + - Test validation error when no checkbox selected + - Select multiple subscriptions + - Test select all functionality + - Test tab switching preserves selections + - Proceed to confirmation page + - Verify selected subscriptions displayed + - Test validation error when no radio selected + - Test "No" returns to email subscriptions + - Select "Yes" and confirm deletion + - Verify success page + - Verify subscriptions deleted from database + - Test empty state when no subscriptions + - Test back navigation + - Test Welsh translation at key points + - Test accessibility inline + - Test keyboard navigation + +### 14. Documentation +- Update README if needed +- Document bulk unsubscribe workflow +- Add comments for transaction logic +- Document audit logging + +## Dependencies +- @hmcts/postgres - Database access via Prisma +- @hmcts/auth - Authentication/authorization +- GOV.UK Frontend - UI components (Tabs, Tables, Checkboxes, Radios) +- express-session - Session management +- VIBE-300 - Subscription by case name and case reference (pre-requisite) + +## Migration Requirements +- No new database tables required +- Use existing subscription tables +- Ensure cascading deletes configured correctly (if using foreign keys) + +## Risk Considerations +- Accidental bulk deletion mitigated by two-step confirmation +- Transaction failure handling critical for data integrity +- Session timeout could lose selections (inform user to complete quickly) +- Large number of subscriptions may cause performance issues (pagination consideration) +- Concurrent deletion attempts need proper locking +- Audit trail essential for compliance and user support + +## Definition of Done +- All 3 pages implemented with Welsh translations +- Tabbed interface functional with correct filtering +- Select all functionality working +- Bulk deletion service with transaction support +- Validation working on both pages +- Empty state handling correct +- Session management functional +- Audit logging implemented +- All pages meet WCAG 2.2 AA standards +- E2E journey test passes (including Welsh and accessibility) +- Unit tests achieve >80% coverage on service +- Code reviewed and approved +- Integration with email subscriptions page complete +- Security validation in place diff --git a/docs/tickets/VIBE-306/specification.md b/docs/tickets/VIBE-306/specification.md new file mode 100644 index 00000000..f9fe753a --- /dev/null +++ b/docs/tickets/VIBE-306/specification.md @@ -0,0 +1,211 @@ +# VIBE-306: Verified user - Bulk unsubscribe process + +## User Story +As a Verified Media User, I want to bulk unsubscribe from my subscriptions in CaTH so that I can stop receiving notifications from publications I am no longer interested in. + +## Problem Statement +Verified users are users who have applied to create accounts in CaTH to have access to restricted hearing information which they can subscribe to receive email notifications from CaTH and also unsubscribe from. + +## Pre-conditions +- The user has a verified account +- The verified user already has some active subscriptions in CaTH +- Subscription by case name and case reference number have been implemented (VIBE-300) +- Verified user dashboard has already been created + +## Technical Specification +On confirmation, all the selected subscriptions must be deleted from subscription database table for given user. + +## User Journey Flow + +``` +START + | + v +User logs in → lands on "Your email subscriptions" + | + v +Clicks "Bulk unsubscribe" + | + v +Page 1: Bulk Unsubscribe (tabs + tables) + | + |-- If no subscriptions found in selected view → show empty state + | + |-- User selects one or more checkboxes? + | | + | |-- NO → Error: "At least one subscription must be selected" + | | + | └-- YES + | + v +Clicks "Bulk unsubscribe" button + | + v +Page 2: Confirm removal + | + |-- User selects radio? + | | + | |-- NO SELECTION → Error: "An option must be selected" + | | + | |-- Selects "No" → return to "Your email subscriptions" + | | + | └-- Selects "Yes" + | + v +Subscriptions removed + | + v +Page 3: Success page ("Email subscriptions updated") + | + v +User may: +• Add a new email subscription +• Manage current subscriptions +• Find a court or tribunal + | + v +END +``` + +## Pages and Content + +### Page 1: Your email subscriptions (Bulk Unsubscribe entry point) + +**URL:** https://www.court-tribunal-hearings.service.gov.uk/bulk-unsubscribe + +**Form fields:** +- View selection tabs (All subscriptions / Subscriptions by case / Subscriptions by court or tribunal) + - Input type: tab selection + - Required: No + - Validation: None +- Subscription selection checkbox (per row) + - Input type: checkbox + - Required: No (but at least one must be selected before continuing) + - Validation: "At least one subscription must be selected" if user submits with none selected +- Select all checkbox (table header) + - Input type: checkbox + - Required: No + - Validation: None + +**Note:** Table values (case name, party name, reference number, court or tribunal name, date added) are system-generated read-only content. + +**Content:** +- EN: Title/H1 "Bulk Unsubscribe" +- CY: Title/H1 "Welsh placeholder" +- EN: Tabs — "All subscriptions", "Subscriptions by case", "Subscriptions by court or tribunal" +- CY: Tabs — "Welsh placeholder", "Welsh placeholder", "Welsh placeholder" +- EN: Table headings (depending on selected tab): + - **Subscriptions by case:** "Case name", "Party name", "Reference number", "Date added", "Select" + - **Subscriptions by court or tribunal:** "Court or tribunal name", "Date added", "Select" + - **All subscriptions:** Case table (same as above), then court or tribunal table (same as above) +- CY: Table headings — "Welsh placeholder", "Welsh placeholder", "Welsh placeholder", "Welsh placeholder", "Welsh placeholder" +- EN: Button — "Bulk unsubscribe" (green) +- CY: Button — "Welsh placeholder" +- EN: Empty-state message (if no subscriptions in selected view): "You do not have any subscriptions in this category." +- CY: Empty-state message — "Welsh placeholder" + +**Errors:** +- EN: Error summary title — "There is a problem" +- EN: Error message — "At least one subscription must be selected" +- CY: "Welsh placeholder" / "Welsh placeholder" + +**Back navigation:** +Back link returns the user to the previous page ("Your email subscriptions"). + +--- + +### Page 2: Confirm selected subscriptions for removal + +**URL:** https://www.court-tribunal-hearings.service.gov.uk/bulk-unsubscribe + +**Form fields:** +- Radio button: Yes + - Input type: radio + - Required: Yes + - Validation: Must be selected if user chooses Yes +- Radio button: No + - Input type: radio + - Required: Yes + - Validation: Must be selected if user chooses No +- Validation rule (page-level): + - If user submits without selecting Yes/No → show "An option must be selected" + +**Content:** +- EN: Title/H1 "Are you sure you want to remove these subscriptions?" +- CY: Title/H1 "Welsh placeholder" +- EN: Table headings mirror those shown on the previous page, showing the selected subscriptions +- CY: Table headings — "Welsh placeholder", etc. +- EN: Radio options — "Yes", "No" +- CY: Radio options — "Welsh placeholder", "Welsh placeholder" +- EN: Button — "Continue" (green) +- CY: Button — "Welsh placeholder" + +**Errors:** +- EN: Error summary title — "There is a problem" +- EN: Error message — "An option must be selected." +- CY: "Welsh placeholder" / "Welsh placeholder" + +**Back navigation:** +Back link returns the user to the Bulk Unsubscribe selection page with previous selections retained. + +--- + +### Page 3: Email subscriptions updated (Success page) + +**URL:** https://www.court-tribunal-hearings.service.gov.uk/bulk-unsubscribe-confirmed + +**Form fields:** +None (this page does not contain interactive fields). + +**Content:** +- EN: Title/H1 (green banner) — "Email subscriptions updated" +- CY: Title/H1 — "Welsh placeholder" +- EN: Intro text — "To continue, you can go to your account in order to:" +- CY: Intro text — "Welsh placeholder" +- EN: Bullet list: + - "add a new email subscription" + - "manage your current email subscriptions" + - "find a court or tribunal" +- CY: Bullet list — "Welsh placeholder", "Welsh placeholder", "Welsh placeholder" + +**Errors:** None. + +**Back navigation:** +Back link returns the user to the confirmation page. + +--- + +## Accessibility Requirements +- Must comply with WCAG 2.2 AA and GOV.UK Design System +- Error summaries must appear at the top and contain anchor links to the relevant field +- All interactive elements (tabs, checkboxes, radios, buttons, back links) must be accessible via keyboard navigation and support visible focus states +- The success banner must be announced by assistive technology as a status message +- Tables must include appropriate semantic markup ( headers, row labels where required) +- Content must be fully navigable without reliance on colour alone + +## Acceptance Criteria +1. When the verified user signs into CaTH, the user can see the following tabs; Dashboard and 3 tabs 'Court and tribunal hearings', 'Dashboard', and 'Email subscriptions' +2. When the verified user clicks on the 'Email subscriptions' tab, the user is taken to a page with a header title 'Your email subscriptions' and can see the 'Bulk unsubscribe' tab under the header and beside the 'Add email subscription' tab +3. The user can tick the check box at the table header to select all the items in the table +4. Underneath the table, the user sees a green 'Bulk unsubscribe' button which when clicked, takes the user to the page titled 'Are you sure you want to remove these subscriptions?' which displays all selected options in a table with similar column titles to aforementioned +5. Underneath the table, 2 radio buttons are available with the options 'Yes' and 'No' +6. If the user selects 'No', then the user is taken back to the 'Your email subscriptions' page +7. If the user selects 'Yes', then the user is taken to the confirmation page which displays the page title 'Email subscriptions updated' in a green banner. Underneath the banner, user the following options in bullet points after the text 'To continue, you can go to your account in order to:' + - add a new email subscription + - manage your current email subscriptions + - find a court or tribunal +8. The user can navigate to the previous page on each page using the 'back' link provided at the top left of the page +9. All CaTH pages specifications are maintained + +## Test Scenarios +- Submitting Page 1 with no checkboxes selected shows the correct error summary and message +- Submitting Page 1 with selected subscriptions takes the user to the confirmation page showing only the selected subscriptions +- Confirming "No" on Page 2 returns the user to "Your email subscriptions" +- Submitting Page 2 with no radio selection shows the correct error summary and message +- Selecting "Yes" removes the subscriptions and displays the success page +- View tabs correctly filter subscriptions and show empty states where appropriate +- "Select all" checkbox ticks and unticks all subscription rows +- Back links always return to the previous page and preserve user state +- Empty-state scenario: When a user has no subscriptions in a selected view, the empty-state message is displayed and no table appears +- Language toggle updates all text to Welsh placeholders +- URLs for pages 1–3 remain /bulk-unsubscribe; success page loads /bulk-unsubscribe-confirmed diff --git a/e2e-tests/tests/bulk-unsubscribe.spec.ts b/e2e-tests/tests/bulk-unsubscribe.spec.ts new file mode 100644 index 00000000..7dc5429d --- /dev/null +++ b/e2e-tests/tests/bulk-unsubscribe.spec.ts @@ -0,0 +1,383 @@ +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"; + +interface TestData { + locationId1: number; + locationName1: string; + locationWelshName1: string; + locationId2: number; + locationName2: string; + locationWelshName2: string; + locationId3: number; + locationName3: string; + locationWelshName3: string; +} + +const testDataMap = new Map(); + +async function createTestData(): Promise { + const timestamp = Date.now(); + const random = Math.random(); + + const timestampPart = timestamp % 1000000000; + const randomPart = Math.floor(random * 1000000000); + const combined = timestampPart + randomPart; + const baseLocationId = 1000000000 + (combined % 1000000000); + + const locationId1 = baseLocationId; + const locationId2 = baseLocationId + 1; + const locationId3 = baseLocationId + 2; + + const locationName1 = `E2E Bulk Test Location 1 ${timestamp}-${random}`; + const locationWelshName1 = `Lleoliad Prawf Swmp E2E 1 ${timestamp}-${random}`; + const locationName2 = `E2E Bulk Test Location 2 ${timestamp}-${random}`; + const locationWelshName2 = `Lleoliad Prawf Swmp E2E 2 ${timestamp}-${random}`; + const locationName3 = `E2E Bulk Test Location 3 ${timestamp}-${random}`; + const locationWelshName3 = `Lleoliad Prawf Swmp E2E 3 ${timestamp}-${random}`; + + const subJurisdiction = await prisma.subJurisdiction.findFirst(); + const region = await prisma.region.findFirst(); + + if (!subJurisdiction || !region) { + throw new Error("No sub-jurisdiction or region found in database"); + } + + for (const [id, name, welshName] of [ + [locationId1, locationName1, locationWelshName1], + [locationId2, locationName2, locationWelshName2], + [locationId3, locationName3, locationWelshName3], + ]) { + await prisma.location.upsert({ + where: { locationId: id }, + create: { + locationId: id, + name: name, + welshName: welshName, + email: "test.location@test.hmcts.net", + contactNo: "01234567890", + locationSubJurisdictions: { + create: { + subJurisdictionId: subJurisdiction.subJurisdictionId, + }, + }, + locationRegions: { + create: { + regionId: region.regionId, + }, + }, + }, + update: { + name: name, + welshName: welshName, + email: "test.location@test.hmcts.net", + contactNo: "01234567890", + }, + }); + } + + return { + locationId1, + locationName1, + locationWelshName1, + locationId2, + locationName2, + locationWelshName2, + locationId3, + locationName3, + locationWelshName3, + }; +} + +async function deleteTestData(testData: TestData): Promise { + try { + await prisma.subscription.deleteMany({ + where: { + locationId: { + in: [testData.locationId1, testData.locationId2, testData.locationId3], + }, + }, + }); + + await prisma.location.deleteMany({ + where: { + locationId: { + in: [testData.locationId1, testData.locationId2, testData.locationId3], + }, + }, + }); + } catch (error) { + console.log("Test data cleanup:", error); + } +} + +test.describe("Bulk Unsubscribe", () => { + test.beforeEach(async ({ page }, testInfo) => { + const testData = await createTestData(); + testDataMap.set(testInfo.testId, testData); + + await page.goto("/sign-in"); + const hmctsRadio = page.getByRole("radio", { name: /with a myhmcts account/i }); + await hmctsRadio.check(); + const continueButton = page.getByRole("button", { name: /continue/i }); + await continueButton.click(); + + await loginWithCftIdam( + page, + process.env.CFT_VALID_TEST_ACCOUNT!, + process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD! + ); + + await expect(page).toHaveURL(/\/account-home/); + }); + + test.afterEach(async ({}, testInfo) => { + const testData = testDataMap.get(testInfo.testId); + if (testData) { + await deleteTestData(testData); + testDataMap.delete(testInfo.testId); + } + }); + + test("Verified user can bulk unsubscribe", async ({ page }, testInfo) => { + const testData = testDataMap.get(testInfo.testId); + if (!testData) throw new Error("Test data not found"); + + // STEP 1: Create multiple subscriptions to test bulk unsubscribe + await page.goto("/account-home"); + const emailSubsTile = page.locator(".verified-tile").nth(2); + await emailSubsTile.click(); + await expect(page).toHaveURL("/subscription-management"); + + // Add first subscription + await page.getByRole("button", { name: /add email subscription/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(); + await page.getByRole("button", { name: /confirm/i }).click(); + await expect(page).toHaveURL("/subscription-confirmed", { timeout: 10000 }); + await page.getByRole("link", { name: /manage.*subscriptions/i }).click(); + + // Add second subscription + await page.getByRole("button", { name: /add email subscription/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(); + await page.getByRole("button", { name: /confirm/i }).click(); + await expect(page).toHaveURL("/subscription-confirmed", { timeout: 10000 }); + await page.getByRole("link", { name: /manage.*subscriptions/i }).click(); + + // Add third subscription + await page.getByRole("button", { name: /add email subscription/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(); + await page.getByRole("button", { name: /confirm/i }).click(); + await expect(page).toHaveURL("/subscription-confirmed", { timeout: 10000 }); + await page.getByRole("link", { name: /manage.*subscriptions/i }).click(); + + // STEP 2: Navigate to bulk unsubscribe from subscription management + await expect(page).toHaveURL("/subscription-management"); + const bulkUnsubscribeButton = page.getByRole("button", { name: /bulk unsubscribe/i }); + await expect(bulkUnsubscribeButton).toBeVisible(); + await bulkUnsubscribeButton.click(); + await expect(page).toHaveURL("/bulk-unsubscribe"); + + // STEP 3: Verify page structure and accessibility + await expect(page.getByRole("heading", { name: /bulk unsubscribe/i, level: 1 })).toBeVisible(); + + // Check accessibility on bulk unsubscribe page + let accessibilityScanResults = await new AxeBuilder({ page }) + .disableRules(["region"]) + .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 7: Test Welsh translation + await page.goto("/bulk-unsubscribe?lng=cy"); + // Verify Welsh content appears (placeholder text as per spec) + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + + // Switch back to English + await page.goto("/bulk-unsubscribe?lng=en"); + + // STEP 8: Test validation - submitting with no checkboxes selected + const bulkUnsubscribeSubmitButton = page.locator('form[method="post"]').getByRole("button", { name: /bulk unsubscribe/i }); + await bulkUnsubscribeSubmitButton.click(); + + // Verify error summary and message + const errorSummary = page.locator(".govuk-error-summary"); + 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(); + + // Verify all individual 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(); + + await expect(checkbox1).toBeChecked(); + await expect(checkbox2).toBeChecked(); + await expect(checkbox3).toBeChecked(); + + // Uncheck select-all + await selectAllCheckbox.uncheck(); + await expect(checkbox1).not.toBeChecked(); + await expect(checkbox2).not.toBeChecked(); + await expect(checkbox3).not.toBeChecked(); + + // STEP 10: Select specific subscriptions manually + 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 12: Navigate back to subscription management + await page.goto("/subscription-management"); + + // Navigate back to bulk unsubscribe + await page.getByRole("button", { name: /bulk unsubscribe/i }).click(); + await expect(page).toHaveURL("/bulk-unsubscribe"); + + // Re-select subscriptions + await checkbox1.check(); + await checkbox2.check(); + + // STEP 13: Proceed to confirmation page + await bulkUnsubscribeSubmitButton.click(); + await expect(page).toHaveURL(/\/confirm-bulk-unsubscribe/); + + // STEP 14: Verify confirmation page displays selected subscriptions + await expect(page.getByRole("heading", { name: /are you sure you want to remove these subscriptions/i, level: 1 })).toBeVisible(); + 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()).not.toBeVisible(); // Not selected + + // Check accessibility on confirmation page + accessibilityScanResults = await new AxeBuilder({ page }) + .disableRules(["region"]) + .analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // STEP 15: Test validation - submitting with no radio selected + const continueButton = page.getByRole("button", { name: /continue/i }); + await continueButton.click(); + + const errorSummaryConfirm = page.locator(".govuk-error-summary"); + await expect(errorSummaryConfirm).toBeVisible(); + await expect(page.getByText(/an option must be selected/i).first()).toBeVisible(); + + // STEP 16: Test keyboard navigation for radio buttons + const yesRadio = page.getByRole("radio", { name: /yes/i }); + const noRadio = page.getByRole("radio", { name: /no/i }); + + await yesRadio.focus(); + await page.keyboard.press("ArrowDown"); + await expect(noRadio).toBeFocused(); + await page.keyboard.press("ArrowUp"); + await expect(yesRadio).toBeFocused(); + + // STEP 17: Test "No" radio returns to subscription management + await noRadio.check(); + await continueButton.click(); + await expect(page).toHaveURL("/subscription-management"); + + // STEP 18: Complete full bulk unsubscribe flow - select "Yes" + await page.getByRole("button", { name: /bulk unsubscribe/i }).click(); + await checkbox1.check(); + await checkbox2.check(); + await bulkUnsubscribeSubmitButton.click(); + await expect(page).toHaveURL(/\/confirm-bulk-unsubscribe/); + + await yesRadio.check(); + await continueButton.click(); + + // STEP 19: Verify success page + await expect(page).toHaveURL("/bulk-unsubscribe-success"); + + const successPanel = page.locator(".govuk-panel--confirmation"); + await expect(successPanel).toBeVisible(); + await expect(page.getByRole("heading", { name: /email subscriptions updated/i })).toBeVisible(); + + // Verify success page content + await expect(page.getByText(/to continue, you can go to your account in order to/i)).toBeVisible(); + await expect(page.getByText(/add a new email subscription/i)).toBeVisible(); + await expect(page.getByText(/manage your current email subscriptions/i)).toBeVisible(); + await expect(page.getByText(/find a court or tribunal/i)).toBeVisible(); + + // Check accessibility on success page + accessibilityScanResults = await new AxeBuilder({ page }) + .disableRules(["region"]) + .analyze(); + expect(accessibilityScanResults.violations).toEqual([]); + + // STEP 20: Test Welsh translation on success page + await page.goto("/bulk-unsubscribe-success?lng=cy"); + await expect(page.locator(".govuk-panel--confirmation")).toBeVisible(); + + // STEP 21: Verify subscriptions were actually deleted from database + const remainingSubscriptions = await prisma.subscription.findMany({ + where: { + locationId: { + in: [testData.locationId1, testData.locationId2], + }, + }, + }); + + expect(remainingSubscriptions).toHaveLength(0); + + // STEP 22: Verify non-deleted subscription still exists + const subscription3 = await prisma.subscription.findFirst({ + where: { + locationId: testData.locationId3, + }, + }); + + expect(subscription3).not.toBeNull(); + + // Test completes successfully - bulk unsubscribe functionality verified + }); +}); diff --git a/libs/subscriptions/src/index.ts b/libs/subscriptions/src/index.ts index 8797aab9..a5f61f42 100644 --- a/libs/subscriptions/src/index.ts +++ b/libs/subscriptions/src/index.ts @@ -1,3 +1,2 @@ -export * from "./repository/queries.js"; 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 index e5f557dd..01ba9d51 100644 --- a/libs/subscriptions/src/repository/queries.test.ts +++ b/libs/subscriptions/src/repository/queries.test.ts @@ -4,9 +4,12 @@ import { countSubscriptionsByUserId, createSubscriptionRecord, deleteSubscriptionRecord, + deleteSubscriptionsByIds, findSubscriptionById, findSubscriptionByUserAndLocation, - findSubscriptionsByUserId + findSubscriptionsByUserId, + findSubscriptionsWithLocationByIds, + findSubscriptionsWithLocationByUserId } from "./queries.js"; vi.mock("@hmcts/postgres", () => ({ @@ -14,10 +17,13 @@ vi.mock("@hmcts/postgres", () => ({ subscription: { findMany: vi.fn(), findUnique: vi.fn(), + findFirst: vi.fn(), count: vi.fn(), create: vi.fn(), - delete: vi.fn() - } + delete: vi.fn(), + deleteMany: vi.fn() + }, + $transaction: vi.fn() } })); @@ -115,6 +121,7 @@ describe("Subscription Queries", () => { describe("findSubscriptionById", () => { it("should find subscription by ID", async () => { const subscriptionId = "sub123"; + const userId = "user123"; const mockSubscription = { subscriptionId, userId: "user123", @@ -122,22 +129,23 @@ describe("Subscription Queries", () => { dateAdded: new Date() }; - vi.mocked(prisma.subscription.findUnique).mockResolvedValue(mockSubscription); + vi.mocked(prisma.subscription.findFirst).mockResolvedValue(mockSubscription); - const result = await findSubscriptionById(subscriptionId); + const result = await findSubscriptionById(subscriptionId, userId); expect(result).toEqual(mockSubscription); - expect(prisma.subscription.findUnique).toHaveBeenCalledWith({ - where: { subscriptionId } + 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.findUnique).mockResolvedValue(null); + vi.mocked(prisma.subscription.findFirst).mockResolvedValue(null); - const result = await findSubscriptionById(subscriptionId); + const result = await findSubscriptionById(subscriptionId, userId); expect(result).toBeNull(); }); @@ -198,21 +206,186 @@ describe("Subscription Queries", () => { describe("deleteSubscriptionRecord", () => { it("should delete a subscription", async () => { const subscriptionId = "sub1"; - const mockSubscription = { - subscriptionId, - userId: "user123", - locationId: 456, - dateAdded: new Date() - }; + const userId = "user123"; - vi.mocked(prisma.subscription.delete).mockResolvedValue(mockSubscription); + vi.mocked(prisma.subscription.deleteMany).mockResolvedValue({ count: 1 }); - const result = await deleteSubscriptionRecord(subscriptionId); + const result = await deleteSubscriptionRecord(subscriptionId, userId); - expect(result).toEqual(mockSubscription); - expect(prisma.subscription.delete).toHaveBeenCalledWith({ - where: { subscriptionId } + 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 index 570473cb..534fe3b0 100644 --- a/libs/subscriptions/src/repository/queries.ts +++ b/libs/subscriptions/src/repository/queries.ts @@ -39,14 +39,51 @@ export async function createSubscriptionRecord(userId: string, locationId: numbe }); } -export async function findSubscriptionById(subscriptionId: string) { - return prisma.subscription.findUnique({ - where: { subscriptionId } +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 deleteSubscriptionRecord(subscriptionId: string) { - return prisma.subscription.delete({ - where: { subscriptionId } +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/subscriptions/src/repository/service.test.ts b/libs/subscriptions/src/repository/service.test.ts index 9b1cc7f3..23513b36 100644 --- a/libs/subscriptions/src/repository/service.test.ts +++ b/libs/subscriptions/src/repository/service.test.ts @@ -1,17 +1,17 @@ -import { prisma } from "@hmcts/postgres"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as validation from "../validation/validation.js"; import * as queries from "./queries.js"; -import { createMultipleSubscriptions, createSubscription, getSubscriptionsByUserId, removeSubscription, replaceUserSubscriptions } from "./service.js"; - -vi.mock("@hmcts/postgres", () => ({ - prisma: { - subscription: { - findUnique: vi.fn(), - update: vi.fn() - } - } -})); +import { + createMultipleSubscriptions, + createSubscription, + deleteSubscriptionsByIds, + getAllSubscriptionsByUserId, + getCaseSubscriptionsByUserId, + getCourtSubscriptionsByUserId, + getSubscriptionDetailsForConfirmation, + removeSubscription, + replaceUserSubscriptions +} from "./service.js"; vi.mock("./queries.js"); vi.mock("../validation/validation.js"); @@ -76,27 +76,6 @@ describe("Subscription Service", () => { }); }); - describe("getSubscriptionsByUserId", () => { - it("should return user subscriptions", async () => { - const userId = "user123"; - const subscriptions = [ - { - subscriptionId: "sub1", - userId, - locationId: 456, - dateAdded: new Date() - } - ]; - - vi.mocked(queries.findSubscriptionsByUserId).mockResolvedValue(subscriptions); - - const result = await getSubscriptionsByUserId(userId); - - expect(result).toEqual(subscriptions); - expect(queries.findSubscriptionsByUserId).toHaveBeenCalledWith(userId); - }); - }); - describe("removeSubscription", () => { const subscriptionId = "sub123"; const userId = "user123"; @@ -109,32 +88,37 @@ describe("Subscription Service", () => { dateAdded: new Date() }; - vi.mocked(prisma.subscription.findUnique).mockResolvedValue(subscription); - vi.mocked(queries.deleteSubscriptionRecord).mockResolvedValue(subscription); + vi.mocked(queries.findSubscriptionById).mockResolvedValue(subscription); + vi.mocked(queries.deleteSubscriptionRecord).mockResolvedValue(1); const result = await removeSubscription(subscriptionId, userId); - expect(result).toBeDefined(); - expect(queries.deleteSubscriptionRecord).toHaveBeenCalledWith(subscriptionId); + expect(result).toBe(1); + expect(queries.findSubscriptionById).toHaveBeenCalledWith(subscriptionId, userId); + expect(queries.deleteSubscriptionRecord).toHaveBeenCalledWith(subscriptionId, userId); }); - it("should throw error if subscription not found", async () => { - vi.mocked(prisma.subscription.findUnique).mockResolvedValue(null); + it("should throw error if subscription not found during lookup", async () => { + vi.mocked(queries.findSubscriptionById).mockResolvedValue(null); await expect(removeSubscription(subscriptionId, userId)).rejects.toThrow("Subscription not found"); + expect(queries.findSubscriptionById).toHaveBeenCalledWith(subscriptionId, userId); + expect(queries.deleteSubscriptionRecord).not.toHaveBeenCalled(); }); - it("should throw error if user is not the owner", async () => { + it("should throw error if delete returns 0 count", async () => { const subscription = { subscriptionId, - userId: "differentUser", + userId, locationId: 456, dateAdded: new Date() }; - vi.mocked(prisma.subscription.findUnique).mockResolvedValue(subscription); + vi.mocked(queries.findSubscriptionById).mockResolvedValue(subscription); + vi.mocked(queries.deleteSubscriptionRecord).mockResolvedValue(0); - await expect(removeSubscription(subscriptionId, userId)).rejects.toThrow("Unauthorized"); + await expect(removeSubscription(subscriptionId, userId)).rejects.toThrow("Subscription not found"); + expect(queries.deleteSubscriptionRecord).toHaveBeenCalledWith(subscriptionId, userId); }); }); @@ -196,7 +180,7 @@ describe("Subscription Service", () => { vi.mocked(queries.findSubscriptionsByUserId).mockResolvedValue(existingSubscriptions); vi.mocked(validation.validateLocationId).mockResolvedValue(true); - vi.mocked(queries.deleteSubscriptionRecord).mockResolvedValue(existingSubscriptions[0]); + vi.mocked(queries.deleteSubscriptionRecord).mockResolvedValue(1); vi.mocked(queries.createSubscriptionRecord).mockResolvedValue({ subscriptionId: "sub3", userId, @@ -208,7 +192,7 @@ describe("Subscription Service", () => { expect(result.added).toBe(1); expect(result.removed).toBe(1); - expect(queries.deleteSubscriptionRecord).toHaveBeenCalledWith("sub1"); + expect(queries.deleteSubscriptionRecord).toHaveBeenCalledWith("sub1", userId); expect(queries.createSubscriptionRecord).toHaveBeenCalledWith(userId, 101); }); @@ -238,13 +222,15 @@ describe("Subscription Service", () => { ]; vi.mocked(queries.findSubscriptionsByUserId).mockResolvedValue(existingSubscriptions); - vi.mocked(queries.deleteSubscriptionRecord).mockResolvedValue(existingSubscriptions[0]); + vi.mocked(queries.deleteSubscriptionRecord).mockResolvedValue(1); const result = await replaceUserSubscriptions(userId, []); expect(result.added).toBe(0); expect(result.removed).toBe(2); expect(queries.deleteSubscriptionRecord).toHaveBeenCalledTimes(2); + expect(queries.deleteSubscriptionRecord).toHaveBeenCalledWith("sub1", userId); + expect(queries.deleteSubscriptionRecord).toHaveBeenCalledWith("sub2", userId); expect(queries.createSubscriptionRecord).not.toHaveBeenCalled(); }); @@ -290,4 +276,251 @@ describe("Subscription Service", () => { expect(queries.createSubscriptionRecord).not.toHaveBeenCalled(); }); }); + + describe("getAllSubscriptionsByUserId", () => { + 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" + } + }, + { + subscriptionId: "sub-2", + userId: mockUserId, + locationId: 2, + dateAdded: new Date("2024-01-02"), + location: { + locationId: 2, + name: "Manchester Crown Court", + welshName: null + } + } + ]; + + it("should return all subscriptions with location details in English", async () => { + vi.mocked(queries.findSubscriptionsWithLocationByUserId).mockResolvedValue(mockSubscriptions); + + const result = await getAllSubscriptionsByUserId(mockUserId, "en"); + + expect(result).toEqual([ + { + subscriptionId: "sub-1", + type: "court", + courtOrTribunalName: "Birmingham Crown Court", + locationId: 1, + dateAdded: new Date("2024-01-01") + }, + { + subscriptionId: "sub-2", + type: "court", + courtOrTribunalName: "Manchester Crown Court", + locationId: 2, + dateAdded: new Date("2024-01-02") + } + ]); + + expect(queries.findSubscriptionsWithLocationByUserId).toHaveBeenCalledWith(mockUserId); + }); + + it("should return subscriptions with Welsh names when locale is cy", async () => { + vi.mocked(queries.findSubscriptionsWithLocationByUserId).mockResolvedValue(mockSubscriptions); + + const result = await getAllSubscriptionsByUserId(mockUserId, "cy"); + + expect(result[0].courtOrTribunalName).toBe("Welsh Birmingham Crown Court"); + expect(result[1].courtOrTribunalName).toBe("Manchester Crown Court"); + }); + + it("should return empty array when user has no subscriptions", async () => { + vi.mocked(queries.findSubscriptionsWithLocationByUserId).mockResolvedValue([]); + + const result = await getAllSubscriptionsByUserId(mockUserId); + + expect(result).toEqual([]); + }); + }); + + 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" + } + }, + { + subscriptionId: "sub-2", + userId: mockUserId, + locationId: 2, + dateAdded: new Date("2024-01-02"), + location: { + locationId: 2, + name: "Manchester Crown Court", + welshName: null + } + } + ]; + + it("should return court subscriptions with location details", async () => { + vi.mocked(queries.findSubscriptionsWithLocationByUserId).mockResolvedValue(mockSubscriptions); + + const result = await getCourtSubscriptionsByUserId(mockUserId, "en"); + + expect(result).toEqual([ + { + subscriptionId: "sub-1", + type: "court", + courtOrTribunalName: "Birmingham Crown Court", + locationId: 1, + dateAdded: new Date("2024-01-01") + }, + { + subscriptionId: "sub-2", + type: "court", + courtOrTribunalName: "Manchester Crown Court", + locationId: 2, + dateAdded: new Date("2024-01-02") + } + ]); + }); + + it("should use Welsh names when locale is cy", async () => { + vi.mocked(queries.findSubscriptionsWithLocationByUserId).mockResolvedValue(mockSubscriptions); + + const result = await getCourtSubscriptionsByUserId(mockUserId, "cy"); + + expect(result[0].courtOrTribunalName).toBe("Welsh Birmingham Crown Court"); + }); + }); + + describe("getSubscriptionDetailsForConfirmation", () => { + const mockSubscriptions = [ + { + 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" + } + }, + { + subscriptionId: "sub-2", + userId: "user-123", + locationId: 2, + dateAdded: new Date("2024-01-02"), + location: { + locationId: 2, + name: "Manchester Crown Court", + welshName: null + } + } + ]; + + it("should return subscription details with location information", async () => { + vi.mocked(queries.findSubscriptionsWithLocationByIds).mockResolvedValue(mockSubscriptions); + + const result = await getSubscriptionDetailsForConfirmation(["sub-1", "sub-2"], "user-123", "en"); + + expect(result).toEqual([ + { + subscriptionId: "sub-1", + type: "court", + courtOrTribunalName: "Birmingham Crown Court", + locationId: 1, + dateAdded: new Date("2024-01-01") + }, + { + subscriptionId: "sub-2", + type: "court", + courtOrTribunalName: "Manchester Crown Court", + locationId: 2, + dateAdded: new Date("2024-01-02") + } + ]); + }); + + it("should return empty array when no subscription IDs provided", async () => { + const result = await getSubscriptionDetailsForConfirmation([], "user-123"); + + expect(result).toEqual([]); + expect(queries.findSubscriptionsWithLocationByIds).not.toHaveBeenCalled(); + }); + + it("should use Welsh names when locale is cy", async () => { + vi.mocked(queries.findSubscriptionsWithLocationByIds).mockResolvedValue(mockSubscriptions); + + const result = await getSubscriptionDetailsForConfirmation(["sub-1"], "user-123", "cy"); + + expect(result[0].courtOrTribunalName).toBe("Welsh Birmingham Crown Court"); + }); + }); + + describe("deleteSubscriptionsByIds", () => { + const mockUserId = "user-123"; + + it("should delete subscriptions in a transaction when user owns them", async () => { + vi.mocked(queries.deleteSubscriptionsByIds).mockResolvedValue(2); + + const result = await deleteSubscriptionsByIds(["sub-1", "sub-2"], mockUserId); + + expect(result).toBe(2); + expect(queries.deleteSubscriptionsByIds).toHaveBeenCalledWith(["sub-1", "sub-2"], mockUserId); + }); + + it("should throw error when no subscriptions provided", async () => { + await expect(deleteSubscriptionsByIds([], mockUserId)).rejects.toThrow("No subscriptions provided for deletion"); + }); + + it("should throw error when count does not match (some subscriptions do not exist or user does not own them)", async () => { + vi.mocked(queries.deleteSubscriptionsByIds).mockResolvedValue(1); + + await expect(deleteSubscriptionsByIds(["sub-1", "sub-2"], mockUserId)).rejects.toThrow("Unauthorized: User does not own all selected subscriptions"); + expect(queries.deleteSubscriptionsByIds).toHaveBeenCalledWith(["sub-1", "sub-2"], mockUserId); + }); + + it("should handle transaction rollback on database error", async () => { + vi.mocked(queries.deleteSubscriptionsByIds).mockRejectedValue(new Error("Database connection failed")); + + await expect(deleteSubscriptionsByIds(["sub-1"], mockUserId)).rejects.toThrow("Database connection failed"); + }); + + it("should delete correct subscriptions with proper where clause", async () => { + const subscriptionIds = ["sub-1", "sub-2", "sub-3"]; + vi.mocked(queries.deleteSubscriptionsByIds).mockResolvedValue(3); + + await deleteSubscriptionsByIds(subscriptionIds, mockUserId); + + expect(queries.deleteSubscriptionsByIds).toHaveBeenCalledWith(subscriptionIds, mockUserId); + }); + + it("should throw error when no subscriptions match deletion criteria", async () => { + vi.mocked(queries.deleteSubscriptionsByIds).mockResolvedValue(0); + + await expect(deleteSubscriptionsByIds(["sub-1"], mockUserId)).rejects.toThrow("Unauthorized: User does not own all selected subscriptions"); + }); + }); }); diff --git a/libs/subscriptions/src/repository/service.ts b/libs/subscriptions/src/repository/service.ts index 0296da90..98706240 100644 --- a/libs/subscriptions/src/repository/service.ts +++ b/libs/subscriptions/src/repository/service.ts @@ -1,15 +1,39 @@ -import { prisma } from "@hmcts/postgres"; import { validateLocationId } from "../validation/validation.js"; import { countSubscriptionsByUserId, createSubscriptionRecord, deleteSubscriptionRecord, + deleteSubscriptionsByIds as deleteSubscriptionsByIdsQuery, + findSubscriptionById, findSubscriptionByUserAndLocation, - findSubscriptionsByUserId + findSubscriptionsByUserId, + findSubscriptionsWithLocationByIds, + findSubscriptionsWithLocationByUserId } from "./queries.js"; const MAX_SUBSCRIPTIONS = 50; +interface SubscriptionDto { + subscriptionId: string; + type: "court" | "case"; + courtOrTribunalName: string; + locationId: number; + dateAdded: Date; +} + +function mapSubscriptionToDto( + sub: { subscriptionId: string; locationId: number; dateAdded: Date; location: { name: string; welshName: string | null } }, + locale: string +): SubscriptionDto { + return { + subscriptionId: sub.subscriptionId, + type: "court", + courtOrTribunalName: locale === "cy" && sub.location.welshName ? sub.location.welshName : sub.location.name, + locationId: sub.locationId, + dateAdded: sub.dateAdded + }; +} + export async function createSubscription(userId: string, locationId: string) { const locationValid = await validateLocationId(locationId); if (!locationValid) { @@ -30,24 +54,24 @@ export async function createSubscription(userId: string, locationId: string) { return createSubscriptionRecord(userId, locationIdNumber); } -export async function getSubscriptionsByUserId(userId: string) { - return findSubscriptionsByUserId(userId); +export async function getSubscriptionById(subscriptionId: string, userId: string) { + return findSubscriptionById(subscriptionId, userId); } export async function removeSubscription(subscriptionId: string, userId: string) { - const subscription = await prisma.subscription.findUnique({ - where: { subscriptionId } - }); + const subscription = await findSubscriptionById(subscriptionId, userId); if (!subscription) { throw new Error("Subscription not found"); } - if (subscription.userId !== userId) { - throw new Error("Unauthorized"); + const count = await deleteSubscriptionRecord(subscriptionId, userId); + + if (count === 0) { + throw new Error("Subscription not found"); } - return deleteSubscriptionRecord(subscriptionId); + return count; } export async function createMultipleSubscriptions(userId: string, locationIds: string[]) { @@ -91,7 +115,7 @@ export async function replaceUserSubscriptions(userId: string, newLocationIds: s // Perform deletions and creations after validation passes await Promise.all([ - ...toDelete.map((sub) => deleteSubscriptionRecord(sub.subscriptionId)), + ...toDelete.map((sub) => deleteSubscriptionRecord(sub.subscriptionId, userId)), ...toAdd.map((locationId) => createSubscriptionRecord(userId, locationId)) ]); @@ -100,3 +124,41 @@ export async function replaceUserSubscriptions(userId: string, newLocationIds: s removed: toDelete.length }; } + +export async function getAllSubscriptionsByUserId(userId: string, locale = "en") { + const subscriptions = await findSubscriptionsWithLocationByUserId(userId); + return subscriptions.map((sub) => mapSubscriptionToDto(sub, locale)); +} + +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 getCourtSubscriptionsByUserId(userId: string, locale = "en") { + return getAllSubscriptionsByUserId(userId, locale); +} + +export async function getSubscriptionDetailsForConfirmation(subscriptionIds: string[], userId: string, locale = "en") { + if (subscriptionIds.length === 0) { + return []; + } + + const subscriptions = await findSubscriptionsWithLocationByIds(subscriptionIds, userId); + return subscriptions.map((sub) => mapSubscriptionToDto(sub, locale)); +} + +export async function deleteSubscriptionsByIds(subscriptionIds: string[], userId: string) { + if (subscriptionIds.length === 0) { + throw new Error("No subscriptions provided for deletion"); + } + + const count = await deleteSubscriptionsByIdsQuery(subscriptionIds, userId); + + if (count !== subscriptionIds.length) { + throw new Error("Unauthorized: User does not own all selected subscriptions"); + } + + return count; +} diff --git a/libs/verified-pages/src/assets/css/index.scss b/libs/verified-pages/src/assets/css/verified-pages.scss similarity index 100% rename from libs/verified-pages/src/assets/css/index.scss rename to libs/verified-pages/src/assets/css/verified-pages.scss diff --git a/libs/verified-pages/src/assets/js/select_all.test.ts b/libs/verified-pages/src/assets/js/select_all.test.ts new file mode 100644 index 00000000..9c75ff91 --- /dev/null +++ b/libs/verified-pages/src/assets/js/select_all.test.ts @@ -0,0 +1,234 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +describe("select-all", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + const createTestDOM = (tableId = "test-table") => { + document.body.innerHTML = ` +
+ + + + + + + + + + + + + +
+
+ `; + }; + + const loadSelectAllScript = async () => { + await import("./select_all.js"); + document.dispatchEvent(new Event("DOMContentLoaded")); + }; + + describe("when select-all checkbox is clicked", () => { + it("should check all row checkboxes when select-all is checked", async () => { + createTestDOM(); + await loadSelectAllScript(); + + const selectAllCheckbox = document.querySelector(".select-all-checkbox"); + const rowCheckboxes = document.querySelectorAll(".row-checkbox"); + + selectAllCheckbox!.checked = true; + selectAllCheckbox!.dispatchEvent(new Event("change")); + + rowCheckboxes.forEach((checkbox) => { + expect(checkbox.checked).toBe(true); + }); + }); + + it("should uncheck all row checkboxes when select-all is unchecked", async () => { + createTestDOM(); + await loadSelectAllScript(); + + const selectAllCheckbox = document.querySelector(".select-all-checkbox"); + const rowCheckboxes = document.querySelectorAll(".row-checkbox"); + + selectAllCheckbox!.checked = true; + selectAllCheckbox!.dispatchEvent(new Event("change")); + + selectAllCheckbox!.checked = false; + selectAllCheckbox!.dispatchEvent(new Event("change")); + + rowCheckboxes.forEach((checkbox) => { + expect(checkbox.checked).toBe(false); + }); + }); + }); + + describe("when row checkboxes are clicked", () => { + it("should check select-all when all row checkboxes are checked", async () => { + createTestDOM(); + await loadSelectAllScript(); + + const selectAllCheckbox = document.querySelector(".select-all-checkbox"); + const rowCheckboxes = document.querySelectorAll(".row-checkbox"); + + rowCheckboxes.forEach((checkbox) => { + checkbox.checked = true; + checkbox.dispatchEvent(new Event("change")); + }); + + expect(selectAllCheckbox!.checked).toBe(true); + expect(selectAllCheckbox!.indeterminate).toBe(false); + }); + + it("should uncheck select-all when all row checkboxes are unchecked", async () => { + createTestDOM(); + await loadSelectAllScript(); + + const selectAllCheckbox = document.querySelector(".select-all-checkbox"); + const rowCheckboxes = document.querySelectorAll(".row-checkbox"); + + selectAllCheckbox!.checked = true; + selectAllCheckbox!.dispatchEvent(new Event("change")); + + rowCheckboxes.forEach((checkbox) => { + checkbox.checked = false; + checkbox.dispatchEvent(new Event("change")); + }); + + expect(selectAllCheckbox!.checked).toBe(false); + expect(selectAllCheckbox!.indeterminate).toBe(false); + }); + + it("should set select-all to indeterminate when some row checkboxes are checked", async () => { + createTestDOM(); + await loadSelectAllScript(); + + const selectAllCheckbox = document.querySelector(".select-all-checkbox"); + const rowCheckboxes = Array.from(document.querySelectorAll(".row-checkbox")); + + rowCheckboxes[0].checked = true; + rowCheckboxes[0].dispatchEvent(new Event("change")); + + expect(selectAllCheckbox!.checked).toBe(false); + expect(selectAllCheckbox!.indeterminate).toBe(true); + }); + + it("should remove indeterminate state when all checkboxes are checked", async () => { + createTestDOM(); + await loadSelectAllScript(); + + const selectAllCheckbox = document.querySelector(".select-all-checkbox"); + const rowCheckboxes = Array.from(document.querySelectorAll(".row-checkbox")); + + rowCheckboxes[0].checked = true; + rowCheckboxes[0].dispatchEvent(new Event("change")); + expect(selectAllCheckbox!.indeterminate).toBe(true); + + rowCheckboxes[1].checked = true; + rowCheckboxes[1].dispatchEvent(new Event("change")); + expect(selectAllCheckbox!.indeterminate).toBe(true); + + rowCheckboxes[2].checked = true; + rowCheckboxes[2].dispatchEvent(new Event("change")); + + expect(selectAllCheckbox!.checked).toBe(true); + expect(selectAllCheckbox!.indeterminate).toBe(false); + }); + }); + + describe("multiple tables", () => { + it("should handle multiple select-all checkboxes independently", async () => { + document.body.innerHTML = ` +
+ + + + + + +
+
+
+ + + + + + +
+
+ `; + + await loadSelectAllScript(); + + const selectAllCheckboxes = Array.from(document.querySelectorAll(".select-all-checkbox")); + const table1Checkboxes = Array.from(document.querySelectorAll("#table1 .row-checkbox")); + const table2Checkboxes = Array.from(document.querySelectorAll("#table2 .row-checkbox")); + + selectAllCheckboxes[0].checked = true; + selectAllCheckboxes[0].dispatchEvent(new Event("change")); + + table1Checkboxes.forEach((checkbox) => { + expect(checkbox.checked).toBe(true); + }); + + table2Checkboxes.forEach((checkbox) => { + expect(checkbox.checked).toBe(false); + }); + }); + }); + + describe("edge cases", () => { + it("should handle select-all checkbox without data-table attribute", async () => { + document.body.innerHTML = ` +
+ +
+ `; + + await loadSelectAllScript(); + + expect(() => { + const selectAllCheckbox = document.querySelector(".select-all-checkbox"); + selectAllCheckbox!.dispatchEvent(new Event("change")); + }).not.toThrow(); + }); + + it("should handle table with no row checkboxes", async () => { + document.body.innerHTML = ` +
+ + + +
+
+ `; + + await loadSelectAllScript(); + + const selectAllCheckbox = document.querySelector(".select-all-checkbox"); + selectAllCheckbox!.checked = true; + + expect(() => { + selectAllCheckbox!.dispatchEvent(new Event("change")); + }).not.toThrow(); + }); + + it("should handle no select-all checkboxes", async () => { + document.body.innerHTML = `
`; + + expect(async () => { + await loadSelectAllScript(); + }).not.toThrow(); + }); + }); +}); diff --git a/libs/verified-pages/src/assets/js/select_all.ts b/libs/verified-pages/src/assets/js/select_all.ts new file mode 100644 index 00000000..cbb6e582 --- /dev/null +++ b/libs/verified-pages/src/assets/js/select_all.ts @@ -0,0 +1,40 @@ +function syncRowCheckboxes(rowCheckboxes: NodeListOf, isChecked: boolean) { + rowCheckboxes.forEach((checkbox) => { + checkbox.checked = isChecked; + }); +} + +function updateSelectAllState(selectAllCheckbox: HTMLInputElement, rowCheckboxes: NodeListOf) { + const checkboxArray = Array.from(rowCheckboxes); + const hasCheckboxes = checkboxArray.length > 0; + const allChecked = hasCheckboxes && checkboxArray.every((checkbox) => checkbox.checked); + const someChecked = checkboxArray.some((checkbox) => checkbox.checked); + + selectAllCheckbox.checked = allChecked; + selectAllCheckbox.indeterminate = someChecked && !allChecked; +} + +function setupSelectAllCheckbox(selectAllCheckbox: HTMLInputElement) { + const tableId = selectAllCheckbox.dataset.table; + if (!tableId) return; + + const rowCheckboxes = document.querySelectorAll(`#${tableId} .row-checkbox`); + + selectAllCheckbox.addEventListener("change", () => { + selectAllCheckbox.indeterminate = false; + syncRowCheckboxes(rowCheckboxes, selectAllCheckbox.checked); + }); + + rowCheckboxes.forEach((rowCheckbox) => { + rowCheckbox.addEventListener("change", () => { + updateSelectAllState(selectAllCheckbox, rowCheckboxes); + }); + }); + + updateSelectAllState(selectAllCheckbox, rowCheckboxes); +} + +document.addEventListener("DOMContentLoaded", () => { + const selectAllCheckboxes = document.querySelectorAll(".select-all-checkbox"); + selectAllCheckboxes.forEach(setupSelectAllCheckbox); +}); diff --git a/libs/verified-pages/src/pages/bulk-unsubscribe-success/cy.ts b/libs/verified-pages/src/pages/bulk-unsubscribe-success/cy.ts new file mode 100644 index 00000000..e1f98f64 --- /dev/null +++ b/libs/verified-pages/src/pages/bulk-unsubscribe-success/cy.ts @@ -0,0 +1,11 @@ +export const cy = { + successTitle: "Tanysgrifiadau e-bost wedi'u diweddaru", + successHeading: "Tanysgrifiadau e-bost wedi’u diweddaru", + successIntro: "I barhau, gallwch fynd i’ch cyfrif i:", + successLinkAddSubscription: "ychwanegu tanysgrifiad e-bost newydd", + successLinkManageSubscriptions: "rheoli eich tanysgrifiadau e-bost cyfredol", + successLinkFindCourt: "dod o hyd i lys neu dribiwnlys", + linkAddSubscription: "/location-name-search", + linkManageSubscriptions: "/subscription-management", + linkFindCourt: "/court-and-tribunal-hearings" +}; diff --git a/libs/verified-pages/src/pages/bulk-unsubscribe-success/en.ts b/libs/verified-pages/src/pages/bulk-unsubscribe-success/en.ts new file mode 100644 index 00000000..f0bf333f --- /dev/null +++ b/libs/verified-pages/src/pages/bulk-unsubscribe-success/en.ts @@ -0,0 +1,11 @@ +export const en = { + successTitle: "Email subscriptions updated", + successHeading: "Email subscriptions updated", + successIntro: "To continue, you can go to your account in order to:", + successLinkAddSubscription: "add a new email subscription", + successLinkManageSubscriptions: "manage your current email subscriptions", + successLinkFindCourt: "find a court or tribunal", + linkAddSubscription: "/location-name-search", + linkManageSubscriptions: "/subscription-management", + linkFindCourt: "/court-and-tribunal-hearings" +}; diff --git a/libs/verified-pages/src/pages/bulk-unsubscribe-success/index.njk b/libs/verified-pages/src/pages/bulk-unsubscribe-success/index.njk new file mode 100644 index 00000000..42c87af2 --- /dev/null +++ b/libs/verified-pages/src/pages/bulk-unsubscribe-success/index.njk @@ -0,0 +1,31 @@ +{% extends "layouts/base-template.njk" %} + +{% block pageTitle %} + {{ successTitle }} - {{ serviceName }} - {{ govUk }} +{% endblock %} + +{% block page_content %} +
+ +
+

+ {{ successHeading }} +

+
+ +

{{ successIntro }}

+ + + +
+{% endblock %} diff --git a/libs/verified-pages/src/pages/bulk-unsubscribe-success/index.test.ts b/libs/verified-pages/src/pages/bulk-unsubscribe-success/index.test.ts new file mode 100644 index 00000000..89af15d3 --- /dev/null +++ b/libs/verified-pages/src/pages/bulk-unsubscribe-success/index.test.ts @@ -0,0 +1,66 @@ +import type { Request, Response } from "express"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { GET } 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("bulk-unsubscribe-success", () => { + let mockReq: Partial; + let mockRes: Partial; + + beforeEach(() => { + mockReq = { + user: { id: "user123" } as any, + path: "/bulk-unsubscribe-success", + body: {}, + session: { + bulkUnsubscribe: { + deletedCount: 5 + } + } as any + }; + mockRes = { + render: vi.fn(), + redirect: vi.fn(), + locals: { locale: "en", navigation: {} } + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("GET", () => { + it("should render success page", async () => { + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith("bulk-unsubscribe-success/index", expect.any(Object)); + }); + + it("should clear bulkUnsubscribe from session after rendering", async () => { + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session.bulkUnsubscribe).toEqual({}); + }); + + it("should redirect to sign-in when user is not authenticated", async () => { + mockReq.user = undefined; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/sign-in"); + }); + + it("should render success page even when no bulkUnsubscribe in session", async () => { + mockReq.session = {} as any; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith("bulk-unsubscribe-success/index", expect.any(Object)); + }); + }); +}); diff --git a/libs/verified-pages/src/pages/bulk-unsubscribe-success/index.ts b/libs/verified-pages/src/pages/bulk-unsubscribe-success/index.ts new file mode 100644 index 00000000..af72cbb4 --- /dev/null +++ b/libs/verified-pages/src/pages/bulk-unsubscribe-success/index.ts @@ -0,0 +1,26 @@ +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 (!req.user?.id) { + return res.redirect("/sign-in"); + } + + if (req.session.bulkUnsubscribe) { + req.session.bulkUnsubscribe = {}; + } + + if (!res.locals.navigation) { + res.locals.navigation = {}; + } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + + res.render("bulk-unsubscribe-success/index", t); +}; + +export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler]; diff --git a/libs/verified-pages/src/pages/bulk-unsubscribe/cy.ts b/libs/verified-pages/src/pages/bulk-unsubscribe/cy.ts new file mode 100644 index 00000000..5272941f --- /dev/null +++ b/libs/verified-pages/src/pages/bulk-unsubscribe/cy.ts @@ -0,0 +1,17 @@ +export const cy = { + bulkUnsubscribeTitle: "Datdanysgrifio Swmp", + bulkUnsubscribeHeading: "Datdanysgrifio Swmp", + tabAllSubscriptions: "Pob tanysgrifiad", + 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", + bulkUnsubscribeButton: "Dileu swmp o danysgrifiadau", + emptyStateMessage: "Nid oes gennych unrhyw danysgrifiadau yn y categori hwn.", + 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 new file mode 100644 index 00000000..9ab926d5 --- /dev/null +++ b/libs/verified-pages/src/pages/bulk-unsubscribe/en.ts @@ -0,0 +1,17 @@ +export const en = { + bulkUnsubscribeTitle: "Bulk unsubscribe", + bulkUnsubscribeHeading: "Bulk unsubscribe", + tabAllSubscriptions: "All subscriptions", + tabSubscriptionsByCase: "Subscriptions by case", + tabSubscriptionsByCourt: "Subscriptions by court or tribunal", + tableHeaderCaseName: "Case name", + tableHeaderPartyName: "Party name(s)", + tableHeaderReferenceNumber: "Reference number", + tableHeaderDateAdded: "Date added", + tableHeaderCourtName: "Court or tribunal name", + bulkUnsubscribeButton: "Bulk unsubscribe", + emptyStateMessage: "You do not have any subscriptions in this category.", + 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 new file mode 100644 index 00000000..572b8406 --- /dev/null +++ b/libs/verified-pages/src/pages/bulk-unsubscribe/index.njk @@ -0,0 +1,277 @@ +{% extends "layouts/base-template.njk" %} +{% 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 }} +{% endblock %} + +{% block page_content %} +
+ + {% if errors %} + {{ govukErrorSummary({ + titleText: errorSummaryTitle, + errorList: errors + }) }} + {% endif %} + +

{{ bulkUnsubscribeHeading }}

+ + {% if showEmptyState %} +

{{ emptyStateMessage }}

+ {% else %} +
+ + + {% set allTabHtml %} + {% if hasCaseSubscriptions %} +

{{ tabSubscriptionsByCase }}

+ + + + + + + + + + + + {% for subscription in caseSubscriptions %} + + + + + + + + {% endfor %} + +
{{ tableHeaderCaseName }}{{ tableHeaderPartyName }}{{ tableHeaderReferenceNumber }}{{ tableHeaderDateAdded }} +
+ + +
+
{{ subscription.caseName }}{{ subscription.partyName }}{{ subscription.referenceNumber }}{{ subscription.dateAdded | date('D MMMM YYYY') }} +
+ + +
+
+ {% endif %} + + {% if hasCourtSubscriptions %} + {% if hasCaseSubscriptions %} +

{{ tabSubscriptionsByCourt }}

+ {% endif %} + + + + + + + + + + {% for subscription in courtSubscriptions %} + + + + + + {% endfor %} + +
{{ tableHeaderCourtName }}{{ tableHeaderDateAdded }} +
+ + +
+
{{ subscription.courtOrTribunalName }}{{ subscription.dateAdded | date('D MMMM YYYY') }} +
+ + +
+
+ {% endif %} + {% endset %} + + {% set caseTabHtml %} + {% if hasCaseSubscriptions %} + + + + + + + + + + + + {% for subscription in caseSubscriptions %} + + + + + + + + {% endfor %} + +
{{ tableHeaderCaseName }}{{ tableHeaderPartyName }}{{ tableHeaderReferenceNumber }}{{ tableHeaderDateAdded }} +
+ + +
+
{{ subscription.caseName }}{{ subscription.partyName }}{{ subscription.referenceNumber }}{{ subscription.dateAdded | date('D MMMM YYYY') }} +
+ + +
+
+ {% else %} +

{{ emptyStateMessage }}

+ {% endif %} + {% endset %} + + {% set courtTabHtml %} + {% if hasCourtSubscriptions %} + + + + + + + + + + {% for subscription in courtSubscriptions %} + + + + + + {% endfor %} + +
{{ tableHeaderCourtName }}{{ tableHeaderDateAdded }} +
+ + +
+
{{ subscription.courtOrTribunalName }}{{ subscription.dateAdded | date('D MMMM YYYY') }} +
+ + +
+
+ {% else %} +

{{ emptyStateMessage }}

+ {% 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 + } + } + ] + }) }} + +
+ {{ govukButton({ + text: bulkUnsubscribeButton, + classes: "govuk-button" + }) }} +
+
+ {% endif %} + +
+ + +{% endblock %} diff --git a/libs/verified-pages/src/pages/bulk-unsubscribe/index.test.ts b/libs/verified-pages/src/pages/bulk-unsubscribe/index.test.ts new file mode 100644 index 00000000..2588a69a --- /dev/null +++ b/libs/verified-pages/src/pages/bulk-unsubscribe/index.test.ts @@ -0,0 +1,205 @@ +import * as subscriptionService from "@hmcts/subscriptions"; +import type { Request, Response } from "express"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +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/subscriptions", () => ({ + getCaseSubscriptionsByUserId: vi.fn(), + getCourtSubscriptionsByUserId: vi.fn() +})); + +describe("bulk-unsubscribe", () => { + let mockReq: Partial; + let mockRes: Partial; + + beforeEach(() => { + mockReq = { + user: { id: "user123" } as any, + path: "/bulk-unsubscribe", + query: {}, + body: {}, + session: {} as any + }; + mockRes = { + render: vi.fn(), + redirect: vi.fn(), + locals: { locale: "en", navigation: {} } + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("GET", () => { + it("should render page with both case and court subscriptions", async () => { + const mockCaseSubscriptions = [ + { + subscriptionId: "case-1", + type: "case" as const, + caseName: "Test Case", + partyName: "John Doe", + referenceNumber: "REF123", + dateAdded: new Date() + } + ]; + + const mockCourtSubscriptions = [ + { + subscriptionId: "court-1", + type: "court" as const, + courtOrTribunalName: "Birmingham Crown Court", + locationId: 1, + dateAdded: new Date() + } + ]; + + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockResolvedValue(mockCaseSubscriptions); + vi.mocked(subscriptionService.getCourtSubscriptionsByUserId).mockResolvedValue(mockCourtSubscriptions); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "bulk-unsubscribe/index", + expect.objectContaining({ + caseSubscriptions: mockCaseSubscriptions, + courtSubscriptions: mockCourtSubscriptions, + hasCaseSubscriptions: true, + hasCourtSubscriptions: true, + showEmptyState: false, + allSubscriptionsCount: 2, + caseSubscriptionsCount: 1, + courtSubscriptionsCount: 1 + }) + ); + }); + + it("should render empty state when no subscriptions exist", 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({ + caseSubscriptions: [], + courtSubscriptions: [], + hasCaseSubscriptions: false, + hasCourtSubscriptions: false, + showEmptyState: true, + allSubscriptionsCount: 0, + caseSubscriptionsCount: 0, + courtSubscriptionsCount: 0 + }) + ); + }); + + it("should redirect to sign-in when user is not authenticated", async () => { + mockReq.user = undefined; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/sign-in"); + }); + + it("should handle errors gracefully", async () => { + vi.mocked(subscriptionService.getCaseSubscriptionsByUserId).mockRejectedValue(new Error("Database error")); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.render).toHaveBeenCalledWith( + "bulk-unsubscribe/index", + expect.objectContaining({ + showEmptyState: true, + caseSubscriptions: [], + courtSubscriptions: [] + }) + ); + }); + + it("should restore previously selected subscriptions from session", async () => { + mockReq.session = { + bulkUnsubscribe: { + selectedIds: ["sub-1", "sub-2"] + } + } as any; + + 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({ + previouslySelected: ["sub-1", "sub-2"] + }) + ); + }); + }); + + describe("POST", () => { + it("should redirect to confirmation page with selected subscriptions", async () => { + mockReq.body = { + subscriptions: ["sub-1", "sub-2"] + }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session.bulkUnsubscribe).toEqual({ + selectedIds: ["sub-1", "sub-2"] + }); + expect(mockRes.redirect).toHaveBeenCalledWith("/confirm-bulk-unsubscribe"); + }); + + it("should handle single subscription selection", async () => { + mockReq.body = { + subscriptions: "sub-1" + }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockReq.session.bulkUnsubscribe).toEqual({ + selectedIds: ["sub-1"] + }); + expect(mockRes.redirect).toHaveBeenCalledWith("/confirm-bulk-unsubscribe"); + }); + + it("should show validation error when no subscriptions selected", 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({ + errors: expect.arrayContaining([ + expect.objectContaining({ + text: expect.any(String), + href: "#subscriptions" + }) + ]) + }) + ); + }); + + it("should redirect to sign-in when user is not authenticated", async () => { + mockReq.user = undefined; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/sign-in"); + }); + }); +}); diff --git a/libs/verified-pages/src/pages/bulk-unsubscribe/index.ts b/libs/verified-pages/src/pages/bulk-unsubscribe/index.ts new file mode 100644 index 00000000..c5f69219 --- /dev/null +++ b/libs/verified-pages/src/pages/bulk-unsubscribe/index.ts @@ -0,0 +1,136 @@ +import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth"; +import { getCaseSubscriptionsByUserId, getCourtSubscriptionsByUserId } from "@hmcts/subscriptions"; +import type { Request, RequestHandler, Response } from "express"; +import { cy } from "./cy.js"; +import { en } from "./en.js"; + +interface BulkUnsubscribeSession { + selectedIds?: string[]; +} + +declare module "express-session" { + interface SessionData { + bulkUnsubscribe?: BulkUnsubscribeSession; + } +} + +const getHandler = async (req: Request, res: Response) => { + const locale = res.locals.locale || "en"; + const t = locale === "cy" ? cy : en; + + if (!req.user?.id) { + return res.redirect("/sign-in"); + } + + const userId = req.user.id; + + try { + const caseSubscriptions = await getCaseSubscriptionsByUserId(userId, locale); + const courtSubscriptions = await getCourtSubscriptionsByUserId(userId, locale); + + if (!res.locals.navigation) { + res.locals.navigation = {}; + } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + + const previouslySelected = req.session.bulkUnsubscribe?.selectedIds || []; + + res.render("bulk-unsubscribe/index", { + ...t, + caseSubscriptions, + courtSubscriptions, + previouslySelected, + hasCaseSubscriptions: caseSubscriptions.length > 0, + hasCourtSubscriptions: courtSubscriptions.length > 0, + showEmptyState: caseSubscriptions.length === 0 && courtSubscriptions.length === 0, + caseSubscriptionsCount: caseSubscriptions.length, + courtSubscriptionsCount: courtSubscriptions.length, + allSubscriptionsCount: caseSubscriptions.length + courtSubscriptions.length, + csrfToken: (req as any).csrfToken?.() || "" + }); + } catch (error) { + console.error(`Error retrieving subscriptions for bulk unsubscribe for user ${userId}:`, error); + + if (!res.locals.navigation) { + res.locals.navigation = {}; + } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + + res.render("bulk-unsubscribe/index", { + ...t, + caseSubscriptions: [], + courtSubscriptions: [], + previouslySelected: [], + hasCaseSubscriptions: false, + hasCourtSubscriptions: false, + showEmptyState: true, + caseSubscriptionsCount: 0, + courtSubscriptionsCount: 0, + allSubscriptionsCount: 0, + csrfToken: (req as any).csrfToken?.() || "" + }); + } +}; + +const postHandler = async (req: Request, res: Response) => { + const locale = res.locals.locale || "en"; + const t = locale === "cy" ? cy : en; + + if (!req.user?.id) { + return res.redirect("/sign-in"); + } + + let selectedIds: string[] = []; + if (Array.isArray(req.body.subscriptions)) { + selectedIds = req.body.subscriptions; + } else if (req.body.subscriptions) { + selectedIds = [req.body.subscriptions]; + } + + if (selectedIds.length === 0) { + const userId = req.user.id; + + try { + const caseSubscriptions = await getCaseSubscriptionsByUserId(userId, locale); + const courtSubscriptions = await getCourtSubscriptionsByUserId(userId, locale); + + if (!res.locals.navigation) { + res.locals.navigation = {}; + } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + + return res.render("bulk-unsubscribe/index", { + ...t, + caseSubscriptions, + courtSubscriptions, + previouslySelected: [], + hasCaseSubscriptions: caseSubscriptions.length > 0, + hasCourtSubscriptions: courtSubscriptions.length > 0, + showEmptyState: caseSubscriptions.length === 0 && courtSubscriptions.length === 0, + caseSubscriptionsCount: caseSubscriptions.length, + courtSubscriptionsCount: courtSubscriptions.length, + allSubscriptionsCount: caseSubscriptions.length + courtSubscriptions.length, + errors: [ + { + text: t.errorNoSelectionMessage, + href: t.errorNoSelectionHref + } + ], + csrfToken: (req as any).csrfToken?.() || "" + }); + } catch (error) { + console.error("Error rendering validation error:", error); + return res.redirect("/bulk-unsubscribe"); + } + } + + if (!req.session.bulkUnsubscribe) { + req.session.bulkUnsubscribe = {}; + } + req.session.bulkUnsubscribe.selectedIds = selectedIds; + + res.redirect("/confirm-bulk-unsubscribe"); +}; + +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 new file mode 100644 index 00000000..8e7c5263 --- /dev/null +++ b/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/cy.ts @@ -0,0 +1,17 @@ +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?", + 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", + radioYes: "Ydw", + radioNo: "Nac ydw", + continueButton: "Parhau", + errorSummaryTitle: "Mae yna broblem", + errorNoRadioMessage: "Rhaid dewis opsiwn", + errorNoRadioHref: "#confirm" +}; diff --git a/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/en.ts b/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/en.ts new file mode 100644 index 00000000..8ee08626 --- /dev/null +++ b/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/en.ts @@ -0,0 +1,17 @@ +export const en = { + confirmTitle: "Are you sure you want to remove these subscriptions?", + confirmHeading: "Are you sure you want to remove these subscriptions?", + tabSubscriptionsByCase: "Subscriptions by case", + tabSubscriptionsByCourt: "Subscriptions by court or tribunal", + tableHeaderCaseName: "Case name", + tableHeaderPartyName: "Party name(s)", + tableHeaderReferenceNumber: "Reference number", + tableHeaderDateAdded: "Date added", + tableHeaderCourtName: "Court or tribunal name", + radioYes: "Yes", + radioNo: "No", + continueButton: "Continue", + errorSummaryTitle: "There is a problem", + errorNoRadioMessage: "An option must be selected", + errorNoRadioHref: "#confirm" +}; diff --git a/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/index.njk b/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/index.njk new file mode 100644 index 00000000..110d1608 --- /dev/null +++ b/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/index.njk @@ -0,0 +1,99 @@ +{% 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 %} + +{% block pageTitle %} + {{ confirmTitle }} - {{ serviceName }} - {{ govUk }} +{% endblock %} + +{% block page_content %} +
+ + {% if errors %} + {{ govukErrorSummary({ + titleText: errorSummaryTitle, + errorList: errors + }) }} + {% endif %} + +

{{ confirmHeading }}

+ + {% if hasCaseSubscriptions %} +

{{ tabSubscriptionsByCase }}

+ + + + + + + + + + + {% for subscription in caseSubscriptions %} + + + + + + + {% endfor %} + +
{{ tableHeaderCaseName }}{{ tableHeaderPartyName }}{{ tableHeaderReferenceNumber }}{{ tableHeaderDateAdded }}
{{ subscription.caseName }}{{ subscription.partyName }}{{ subscription.referenceNumber }}{{ subscription.dateAdded | date('D MMMM YYYY') }}
+ {% endif %} + + {% if hasCourtSubscriptions %} + {% if hasCaseSubscriptions %} +

{{ tabSubscriptionsByCourt }}

+ {% endif %} + + + + + + + + + {% for subscription in courtSubscriptions %} + + + + + {% endfor %} + +
{{ tableHeaderCourtName }}{{ tableHeaderDateAdded }}
{{ subscription.courtOrTribunalName }}{{ subscription.dateAdded | date('D MMMM YYYY') }}
+ {% endif %} + +
+ + + {{ govukRadios({ + name: "confirm", + fieldset: { + legend: { + text: confirmHeading, + isPageHeading: false, + classes: "govuk-visually-hidden" + } + }, + errorMessage: errors[0] if errors, + items: [ + { + value: "yes", + text: radioYes + }, + { + value: "no", + text: radioNo + } + ] + }) }} + + {{ govukButton({ + text: continueButton + }) }} +
+ +
+{% endblock %} 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 new file mode 100644 index 00000000..16aa7a8e --- /dev/null +++ b/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/index.test.ts @@ -0,0 +1,268 @@ +import * as subscriptionService from "@hmcts/subscriptions"; +import type { Request, Response } from "express"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +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/subscriptions", () => ({ + getSubscriptionDetailsForConfirmation: vi.fn(), + deleteSubscriptionsByIds: vi.fn() +})); + +describe("confirm-bulk-unsubscribe", () => { + let mockReq: Partial; + let mockRes: Partial; + + beforeEach(() => { + mockReq = { + user: { id: "user123" } as any, + path: "/confirm-bulk-unsubscribe", + body: {}, + session: { + bulkUnsubscribe: { + selectedIds: ["sub-1", "sub-2"] + } + } as any + }; + mockRes = { + render: vi.fn(), + redirect: vi.fn(), + locals: { locale: "en", navigation: {} } + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("GET", () => { + it("should render confirmation page with court subscriptions only", async () => { + const mockSubscriptions = [ + { + subscriptionId: "sub-1", + type: "court" as const, + courtOrTribunalName: "Birmingham Crown Court", + locationId: 1, + dateAdded: new Date("2024-01-01") + }, + { + subscriptionId: "sub-2", + type: "court" as const, + courtOrTribunalName: "Manchester Crown Court", + locationId: 2, + dateAdded: new Date("2024-01-02") + } + ]; + + 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", "en"); + expect(mockRes.render).toHaveBeenCalledWith( + "confirm-bulk-unsubscribe/index", + expect.objectContaining({ + courtSubscriptions: mockSubscriptions, + caseSubscriptions: [], + hasCourtSubscriptions: true, + hasCaseSubscriptions: false + }) + ); + }); + + it("should render confirmation page with mixed case and court subscriptions", async () => { + const mockSubscriptions = [ + { + subscriptionId: "sub-1", + type: "case" as const, + courtOrTribunalName: "Case ABC123", + locationId: 1, + dateAdded: new Date("2024-01-01") + }, + { + subscriptionId: "sub-2", + type: "court" as const, + courtOrTribunalName: "Manchester Crown Court", + locationId: 2, + dateAdded: new Date("2024-01-02") + } + ]; + + 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({ + courtSubscriptions: [mockSubscriptions[1]], + caseSubscriptions: [mockSubscriptions[0]], + hasCourtSubscriptions: true, + hasCaseSubscriptions: true + }) + ); + }); + + it("should redirect to bulk-unsubscribe when no subscriptions in session", async () => { + mockReq.session = {} as any; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/bulk-unsubscribe"); + }); + + it("should redirect to bulk-unsubscribe when selectedIds is empty", async () => { + mockReq.session = { + bulkUnsubscribe: { + selectedIds: [] + } + } as any; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/bulk-unsubscribe"); + }); + + it("should redirect to sign-in when user is not authenticated", async () => { + mockReq.user = undefined; + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/sign-in"); + }); + + it("should handle errors gracefully and redirect", async () => { + vi.mocked(subscriptionService.getSubscriptionDetailsForConfirmation).mockRejectedValue(new Error("Database error")); + + await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/bulk-unsubscribe"); + }); + }); + + describe("POST", () => { + it("should delete subscriptions and redirect to success page when confirmed", 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(subscriptionService.deleteSubscriptionsByIds).toHaveBeenCalledWith(["sub-1", "sub-2"], "user123"); + expect(mockReq.session.bulkUnsubscribe).toEqual({}); + expect(mockRes.redirect).toHaveBeenCalledWith("/bulk-unsubscribe-success"); + }); + + it("should redirect to subscription-management 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({}); + expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management"); + }); + + it("should render error when confirm not provided", async () => { + mockReq.body = {}; + vi.mocked(subscriptionService.getSubscriptionDetailsForConfirmation).mockResolvedValue([ + { + subscriptionId: "sub-1", + type: "court" as const, + courtOrTribunalName: "Birmingham Crown Court", + locationId: 1, + dateAdded: new Date() + } + ]); + + 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.any(Array), + courtSubscriptions: expect.any(Array), + caseSubscriptions: expect.any(Array) + }) + ); + }); + + it("should filter case and court subscriptions correctly when confirm not provided", async () => { + mockReq.body = {}; + const mockSubscriptions = [ + { + subscriptionId: "sub-1", + type: "case" as const, + courtOrTribunalName: "Case XYZ789", + locationId: 1, + dateAdded: new Date() + }, + { + subscriptionId: "sub-2", + type: "court" as const, + courtOrTribunalName: "Leeds Crown Court", + locationId: 2, + 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({ + caseSubscriptions: [mockSubscriptions[0]], + courtSubscriptions: [mockSubscriptions[1]], + hasCaseSubscriptions: true, + hasCourtSubscriptions: true + }) + ); + }); + + it("should redirect to bulk-unsubscribe when no subscriptions in session without calling deleteSubscriptionsByIds", async () => { + mockReq.session = {} as any; + mockReq.body = { confirm: "yes" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(subscriptionService.deleteSubscriptionsByIds).not.toHaveBeenCalled(); + expect(mockRes.redirect).toHaveBeenCalledWith("/bulk-unsubscribe"); + }); + + it("should redirect to bulk-unsubscribe when selectedIds is empty without calling deleteSubscriptionsByIds", async () => { + mockReq.session = { + bulkUnsubscribe: { + selectedIds: [] + } + } as any; + mockReq.body = { confirm: "yes" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(subscriptionService.deleteSubscriptionsByIds).not.toHaveBeenCalled(); + expect(mockRes.redirect).toHaveBeenCalledWith("/bulk-unsubscribe"); + }); + + it("should redirect to sign-in when user is not authenticated", async () => { + mockReq.user = undefined; + mockReq.body = { confirm: "yes" }; + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/sign-in"); + }); + + it("should handle deletion errors and redirect to bulk-unsubscribe", async () => { + mockReq.body = { confirm: "yes" }; + vi.mocked(subscriptionService.deleteSubscriptionsByIds).mockRejectedValue(new Error("Unauthorized: User does not own all selected subscriptions")); + + await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + + expect(mockRes.redirect).toHaveBeenCalledWith("/bulk-unsubscribe"); + }); + }); +}); diff --git a/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/index.ts b/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/index.ts new file mode 100644 index 00000000..6c78d115 --- /dev/null +++ b/libs/verified-pages/src/pages/confirm-bulk-unsubscribe/index.ts @@ -0,0 +1,128 @@ +import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth"; +import { deleteSubscriptionsByIds, getSubscriptionDetailsForConfirmation } from "@hmcts/subscriptions"; +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 (!req.user?.id) { + return res.redirect("/sign-in"); + } + + const selectedIds = req.session.bulkUnsubscribe?.selectedIds || []; + const userId = req.user.id; + + if (selectedIds.length === 0) { + return res.redirect("/bulk-unsubscribe"); + } + + try { + const subscriptions = await getSubscriptionDetailsForConfirmation(selectedIds, userId, locale); + + const caseSubscriptions = subscriptions.filter((sub) => sub.type === "case"); + const courtSubscriptions = subscriptions.filter((sub) => sub.type === "court"); + + if (!res.locals.navigation) { + res.locals.navigation = {}; + } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + + res.render("confirm-bulk-unsubscribe/index", { + ...t, + caseSubscriptions, + courtSubscriptions, + hasCaseSubscriptions: caseSubscriptions.length > 0, + hasCourtSubscriptions: courtSubscriptions.length > 0, + csrfToken: (req as any).csrfToken?.() || "" + }); + } catch (error) { + console.error("Error retrieving subscription details for confirmation:", error); + return res.redirect("/bulk-unsubscribe"); + } +}; + +async function renderValidationError(req: Request, res: Response, selectedIds: string[], userId: string, locale: string, t: typeof en) { + try { + const subscriptions = await getSubscriptionDetailsForConfirmation(selectedIds, userId, locale); + const caseSubscriptions = subscriptions.filter((sub) => sub.type === "case"); + const courtSubscriptions = subscriptions.filter((sub) => sub.type === "court"); + + if (!res.locals.navigation) { + res.locals.navigation = {}; + } + res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale); + + return res.render("confirm-bulk-unsubscribe/index", { + ...t, + caseSubscriptions, + courtSubscriptions, + hasCaseSubscriptions: caseSubscriptions.length > 0, + hasCourtSubscriptions: courtSubscriptions.length > 0, + errors: [ + { + text: t.errorNoRadioMessage, + href: t.errorNoRadioHref + } + ], + csrfToken: (req as any).csrfToken?.() || "" + }); + } catch (error) { + console.error("Error rendering validation error:", error); + return res.redirect("/bulk-unsubscribe"); + } +} + +async function processUnsubscribe(req: Request, res: Response, selectedIds: string[], userId: string) { + if (selectedIds.length === 0) { + return res.redirect("/bulk-unsubscribe"); + } + + try { + await deleteSubscriptionsByIds(selectedIds, userId); + + if (req.session.bulkUnsubscribe) { + req.session.bulkUnsubscribe = {}; + } + + return res.redirect("/bulk-unsubscribe-success"); + } catch (error) { + console.error("Error deleting subscriptions:", error); + return res.redirect("/bulk-unsubscribe"); + } +} + +const postHandler = async (req: Request, res: Response) => { + const locale = res.locals.locale || "en"; + const t = locale === "cy" ? cy : en; + + if (!req.user?.id) { + return res.redirect("/sign-in"); + } + + const userId = req.user.id; + const selectedIds = req.session.bulkUnsubscribe?.selectedIds || []; + const confirm = req.body.confirm; + + if (!confirm) { + return renderValidationError(req, res, selectedIds, userId, locale, t); + } + + if (confirm === "no") { + if (req.session.bulkUnsubscribe) { + req.session.bulkUnsubscribe = {}; + } + return res.redirect("/subscription-management"); + } + + if (confirm === "yes") { + return processUnsubscribe(req, res, selectedIds, userId); + } + + return res.redirect("/bulk-unsubscribe"); +}; + +export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler]; +export const POST: RequestHandler[] = [requireAuth(), blockUserAccess(), postHandler]; 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 3b2a4b63..43c61ef6 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 queries from "@hmcts/subscriptions"; +import * as subscriptionService from "@hmcts/subscriptions"; import type { Request, Response } from "express"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GET, POST } from "./index.js"; @@ -10,7 +10,7 @@ vi.mock("@hmcts/auth", () => ({ })); vi.mock("@hmcts/subscriptions", () => ({ - findSubscriptionById: vi.fn() + getSubscriptionById: vi.fn() })); describe("delete-subscription", () => { @@ -53,32 +53,19 @@ describe("delete-subscription", () => { expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management"); }); - it("should redirect if subscription not found", async () => { + it("should redirect if subscription not found or user does not own it", async () => { mockReq.query = { subscriptionId: "550e8400-e29b-41d4-a716-446655440000" }; - vi.mocked(queries.findSubscriptionById).mockResolvedValue(null); - - await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); - - expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management"); - }); - - it("should redirect if user does not own the subscription", async () => { - mockReq.query = { subscriptionId: "550e8400-e29b-41d4-a716-446655440000" }; - vi.mocked(queries.findSubscriptionById).mockResolvedValue({ - subscriptionId: "550e8400-e29b-41d4-a716-446655440000", - userId: "different-user", - locationId: "456", - dateAdded: new Date() - }); + vi.mocked(subscriptionService.getSubscriptionById).mockResolvedValue(null); await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + expect(subscriptionService.getSubscriptionById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440000", "user123"); expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management"); }); it("should render page when user owns the subscription", async () => { mockReq.query = { subscriptionId: "550e8400-e29b-41d4-a716-446655440000" }; - vi.mocked(queries.findSubscriptionById).mockResolvedValue({ + vi.mocked(subscriptionService.getSubscriptionById).mockResolvedValue({ subscriptionId: "550e8400-e29b-41d4-a716-446655440000", userId: "user123", locationId: "456", @@ -87,6 +74,7 @@ describe("delete-subscription", () => { await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + expect(subscriptionService.getSubscriptionById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440000", "user123"); expect(mockRes.render).toHaveBeenCalledWith( "delete-subscription/index", expect.objectContaining({ @@ -97,7 +85,7 @@ describe("delete-subscription", () => { it("should redirect on database error", async () => { mockReq.query = { subscriptionId: "550e8400-e29b-41d4-a716-446655440000" }; - vi.mocked(queries.findSubscriptionById).mockRejectedValue(new Error("DB error")); + vi.mocked(subscriptionService.getSubscriptionById).mockRejectedValue(new Error("DB error")); await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); @@ -110,7 +98,7 @@ describe("delete-subscription", () => { it("should redirect to GET page when no confirmation choice provided", async () => { const validSubscriptionId = "550e8400-e29b-41d4-a716-446655440000"; mockReq.body = { subscriptionId: validSubscriptionId }; - vi.mocked(queries.findSubscriptionById).mockResolvedValue({ + vi.mocked(subscriptionService.getSubscriptionById).mockResolvedValue({ subscriptionId: validSubscriptionId, userId: "user123", locationId: "456", @@ -119,13 +107,14 @@ describe("delete-subscription", () => { await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + expect(subscriptionService.getSubscriptionById).toHaveBeenCalledWith(validSubscriptionId, "user123"); expect(mockRes.redirect).toHaveBeenCalledWith(`/delete-subscription?subscriptionId=${validSubscriptionId}`); }); it("should redirect to subscription-management if user selects no", async () => { const validSubscriptionId = "550e8400-e29b-41d4-a716-446655440000"; mockReq.body = { subscription: validSubscriptionId, "unsubscribe-confirm": "no" }; - vi.mocked(queries.findSubscriptionById).mockResolvedValue({ + vi.mocked(subscriptionService.getSubscriptionById).mockResolvedValue({ subscriptionId: validSubscriptionId, userId: "user123", locationId: "456", @@ -134,6 +123,7 @@ describe("delete-subscription", () => { await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + expect(subscriptionService.getSubscriptionById).toHaveBeenCalledWith(validSubscriptionId, "user123"); expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management"); }); @@ -141,7 +131,7 @@ describe("delete-subscription", () => { const validSubscriptionId = "550e8400-e29b-41d4-a716-446655440000"; mockReq.body = { subscription: validSubscriptionId, "unsubscribe-confirm": "yes" }; mockReq.session = {} as any; - vi.mocked(queries.findSubscriptionById).mockResolvedValue({ + vi.mocked(subscriptionService.getSubscriptionById).mockResolvedValue({ subscriptionId: validSubscriptionId, userId: "user123", locationId: "456", @@ -150,6 +140,7 @@ describe("delete-subscription", () => { await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); + expect(subscriptionService.getSubscriptionById).toHaveBeenCalledWith(validSubscriptionId, "user123"); expect(mockReq.session.emailSubscriptions?.subscriptionToRemove).toBe(validSubscriptionId); expect(mockRes.redirect).toHaveBeenCalledWith("/unsubscribe-confirmation"); }); diff --git a/libs/verified-pages/src/pages/delete-subscription/index.ts b/libs/verified-pages/src/pages/delete-subscription/index.ts index 865363bb..c00f60c4 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 { findSubscriptionById } from "@hmcts/subscriptions"; +import { getSubscriptionById } from "@hmcts/subscriptions"; import type { Request, RequestHandler, Response } from "express"; import { cy } from "./cy.js"; import { en } from "./en.js"; @@ -32,16 +32,12 @@ const getHandler = async (req: Request, res: Response) => { const userId = req.user.id; try { - const subscription = await findSubscriptionById(subscriptionId); + const subscription = await getSubscriptionById(subscriptionId, userId); if (!subscription) { return res.redirect("/subscription-management"); } - if (subscription.userId !== userId) { - return res.redirect("/subscription-management"); - } - if (!res.locals.navigation) { res.locals.navigation = {}; } @@ -83,8 +79,8 @@ const postHandler = async (req: Request, res: Response) => { // Verify user owns the subscription try { - const sub = await findSubscriptionById(subscriptionId); - if (!sub || sub.userId !== userId) { + const sub = await getSubscriptionById(subscriptionId, userId); + if (!sub) { return res.redirect("/subscription-management"); } } catch (error) { 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 39c2316e..494eacd0 100644 --- a/libs/verified-pages/src/pages/pending-subscriptions/index.test.ts +++ b/libs/verified-pages/src/pages/pending-subscriptions/index.test.ts @@ -18,7 +18,7 @@ vi.mock("@hmcts/location", () => ({ })); vi.mock("@hmcts/subscriptions", () => ({ - getSubscriptionsByUserId: vi.fn(), + getAllSubscriptionsByUserId: vi.fn(), replaceUserSubscriptions: vi.fn() })); @@ -118,8 +118,14 @@ describe("pending-subscriptions", () => { it("should confirm subscriptions when action is confirm", async () => { mockReq.body = { action: "confirm" }; - vi.mocked(subscriptionService.getSubscriptionsByUserId).mockResolvedValue([ - { subscriptionId: "sub1", userId: "user123", locationId: 123, dateAdded: new Date() } + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([ + { + subscriptionId: "sub1", + type: "court" as const, + courtOrTribunalName: "Test Court", + locationId: 123, + dateAdded: new Date() + } ]); vi.mocked(subscriptionService.replaceUserSubscriptions).mockResolvedValue({ added: 2, @@ -128,7 +134,7 @@ describe("pending-subscriptions", () => { await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); - expect(subscriptionService.getSubscriptionsByUserId).toHaveBeenCalledWith("user123"); + expect(subscriptionService.getAllSubscriptionsByUserId).toHaveBeenCalledWith("user123"); expect(subscriptionService.replaceUserSubscriptions).toHaveBeenCalledWith("user123", ["123", "456", "789"]); expect(mockReq.session?.emailSubscriptions?.confirmationComplete).toBe(true); expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-confirmed"); @@ -145,7 +151,7 @@ describe("pending-subscriptions", () => { it("should handle error when confirming subscriptions", async () => { mockReq.body = { action: "confirm" }; - vi.mocked(subscriptionService.getSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([]); vi.mocked(subscriptionService.replaceUserSubscriptions).mockRejectedValue(new Error("Test error")); await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn()); @@ -164,8 +170,14 @@ describe("pending-subscriptions", () => { mockReq.body = { action: "confirm" }; mockReq.session = { emailSubscriptions: { pendingSubscriptions: ["456", "789"] } } as any; - vi.mocked(subscriptionService.getSubscriptionsByUserId).mockResolvedValue([ - { subscriptionId: "sub1", userId: "user123", locationId: 123, dateAdded: new Date() } + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([ + { + subscriptionId: "sub1", + type: "court" as const, + courtOrTribunalName: "Test Court", + locationId: 123, + dateAdded: new Date() + } ]); vi.mocked(subscriptionService.replaceUserSubscriptions).mockResolvedValue({ added: 2, diff --git a/libs/verified-pages/src/pages/pending-subscriptions/index.ts b/libs/verified-pages/src/pages/pending-subscriptions/index.ts index b7dbe23d..4e009a9a 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 { getSubscriptionsByUserId, replaceUserSubscriptions } from "@hmcts/subscriptions"; +import { getAllSubscriptionsByUserId, replaceUserSubscriptions } from "@hmcts/subscriptions"; import type { Request, RequestHandler, Response } from "express"; import { cy } from "./cy.js"; import { en } from "./en.js"; @@ -99,7 +99,7 @@ const postHandler = async (req: Request, res: Response) => { } try { - const existingSubscriptions = await getSubscriptionsByUserId(userId); + const existingSubscriptions = await getAllSubscriptionsByUserId(userId); const existingLocationIds = existingSubscriptions.map((sub) => sub.locationId.toString()); const allLocationIds = [...new Set([...existingLocationIds, ...pendingLocationIds])]; diff --git a/libs/verified-pages/src/pages/subscription-management/cy.ts b/libs/verified-pages/src/pages/subscription-management/cy.ts index 77178277..2eecede5 100644 --- a/libs/verified-pages/src/pages/subscription-management/cy.ts +++ b/libs/verified-pages/src/pages/subscription-management/cy.ts @@ -3,6 +3,7 @@ export const cy = { heading: "Eich tanysgrifiadau e-bost", noSubscriptions: "Nid oes gennych unrhyw danysgrifiadau gweithredol", addButton: "Ychwanegu tanysgrifiad e-bost", + bulkUnsubscribeButton: "Dileu swmp o danysgrifiadau", tableHeaderLocation: "Enw llys neu dribiwnlys", tableHeaderDate: "Dyddiad ychwanegu", tableHeaderActions: "Gweithredoedd", diff --git a/libs/verified-pages/src/pages/subscription-management/en.ts b/libs/verified-pages/src/pages/subscription-management/en.ts index de5d4ab8..245fc643 100644 --- a/libs/verified-pages/src/pages/subscription-management/en.ts +++ b/libs/verified-pages/src/pages/subscription-management/en.ts @@ -3,6 +3,7 @@ export const en = { heading: "Your email subscriptions", noSubscriptions: "You do not have any active subscriptions", addButton: "Add email subscription", + bulkUnsubscribeButton: "Bulk unsubscribe", tableHeaderLocation: "Court or tribunal name", tableHeaderDate: "Date added", tableHeaderActions: "Actions", diff --git a/libs/verified-pages/src/pages/subscription-management/index.njk b/libs/verified-pages/src/pages/subscription-management/index.njk index 2ddc6dcf..e686ee4d 100644 --- a/libs/verified-pages/src/pages/subscription-management/index.njk +++ b/libs/verified-pages/src/pages/subscription-management/index.njk @@ -11,10 +11,20 @@

{{ heading }}

- {{ govukButton({ - text: addButton, - href: '/location-name-search' - }) }} +
+ {{ govukButton({ + text: addButton, + href: '/location-name-search' + }) }} + + {% if count > 0 %} + {{ govukButton({ + text: bulkUnsubscribeButton, + href: '/bulk-unsubscribe', + classes: 'govuk-button--secondary' + }) }} + {% endif %} +
{% if count > 0 %} 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 b3a41171..f39021a7 100644 --- a/libs/verified-pages/src/pages/subscription-management/index.test.ts +++ b/libs/verified-pages/src/pages/subscription-management/index.test.ts @@ -9,16 +9,8 @@ vi.mock("@hmcts/auth", () => ({ blockUserAccess: vi.fn(() => (_req: any, _res: any, next: any) => next()) })); -vi.mock("@hmcts/location", () => ({ - getLocationById: vi.fn((id) => ({ - locationId: id, - name: `Location ${id}`, - welshName: `Lleoliad ${id}` - })) -})); - vi.mock("@hmcts/subscriptions", () => ({ - getSubscriptionsByUserId: vi.fn() + getAllSubscriptionsByUserId: vi.fn() })); describe("subscription-management", () => { @@ -44,29 +36,41 @@ describe("subscription-management", () => { describe("GET", () => { it("should render page with subscriptions", async () => { const mockSubscriptions = [ - { subscriptionId: "sub1", userId: "user123", locationId: "456", dateAdded: new Date() }, - { subscriptionId: "sub2", userId: "user123", locationId: "789", dateAdded: new Date() } + { + subscriptionId: "sub1", + type: "court" as const, + courtOrTribunalName: "Birmingham Crown Court", + locationId: 456, + dateAdded: new Date() + }, + { + subscriptionId: "sub2", + type: "court" as const, + courtOrTribunalName: "Manchester Crown Court", + locationId: 789, + dateAdded: new Date() + } ]; - vi.mocked(subscriptionService.getSubscriptionsByUserId).mockResolvedValue(mockSubscriptions); + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue(mockSubscriptions); await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); - expect(subscriptionService.getSubscriptionsByUserId).toHaveBeenCalledWith("user123"); + expect(subscriptionService.getAllSubscriptionsByUserId).toHaveBeenCalledWith("user123", "en"); expect(mockRes.render).toHaveBeenCalledWith( "subscription-management/index", expect.objectContaining({ count: 2, subscriptions: expect.arrayContaining([ - expect.objectContaining({ locationName: "Location 456" }), - expect.objectContaining({ locationName: "Location 789" }) + expect.objectContaining({ locationName: "Birmingham Crown Court" }), + expect.objectContaining({ locationName: "Manchester Crown Court" }) ]) }) ); }); it("should render page with no subscriptions", async () => { - vi.mocked(subscriptionService.getSubscriptionsByUserId).mockResolvedValue([]); + vi.mocked(subscriptionService.getAllSubscriptionsByUserId).mockResolvedValue([]); await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); @@ -84,7 +88,7 @@ describe("subscription-management", () => { await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn()); expect(mockRes.redirect).toHaveBeenCalledWith("/sign-in"); - expect(subscriptionService.getSubscriptionsByUserId).not.toHaveBeenCalled(); + expect(subscriptionService.getAllSubscriptionsByUserId).not.toHaveBeenCalled(); }); }); }); diff --git a/libs/verified-pages/src/pages/subscription-management/index.ts b/libs/verified-pages/src/pages/subscription-management/index.ts index ae59ab25..9ee02cbc 100644 --- a/libs/verified-pages/src/pages/subscription-management/index.ts +++ b/libs/verified-pages/src/pages/subscription-management/index.ts @@ -1,6 +1,5 @@ import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth"; -import { getLocationById } from "@hmcts/location"; -import { getSubscriptionsByUserId } from "@hmcts/subscriptions"; +import { getAllSubscriptionsByUserId } from "@hmcts/subscriptions"; import type { Request, RequestHandler, Response } from "express"; import { cy } from "./cy.js"; import { en } from "./en.js"; @@ -16,25 +15,12 @@ const getHandler = async (req: Request, res: Response) => { const userId = req.user.id; try { - const subscriptions = await getSubscriptionsByUserId(userId); + const subscriptions = await getAllSubscriptionsByUserId(userId, locale); - const subscriptionsWithDetails = await Promise.all( - subscriptions.map(async (sub) => { - try { - const location = await getLocationById(sub.locationId); - return { - ...sub, - locationName: location ? (locale === "cy" ? location.welshName : location.name) : sub.locationId.toString() - }; - } catch (error) { - console.error(`Failed to lookup location ${sub.locationId} for user ${userId}:`, error); - return { - ...sub, - locationName: sub.locationId.toString() - }; - } - }) - ); + const subscriptionsWithDetails = subscriptions.map((sub) => ({ + ...sub, + locationName: sub.courtOrTribunalName + })); if (!res.locals.navigation) { res.locals.navigation = {}; diff --git a/libs/web-core/src/views/components/body-end-scripts.njk b/libs/web-core/src/views/components/body-end-scripts.njk index 6db3e74a..2df26449 100644 --- a/libs/web-core/src/views/components/body-end-scripts.njk +++ b/libs/web-core/src/views/components/body-end-scripts.njk @@ -1,2 +1,2 @@ {# Run JavaScript at end of the , to avoid blocking the initial render. #} - + diff --git a/libs/web-core/src/views/layouts/base-template.njk b/libs/web-core/src/views/layouts/base-template.njk index 9bb5e1dc..185cac4b 100644 --- a/libs/web-core/src/views/layouts/base-template.njk +++ b/libs/web-core/src/views/layouts/base-template.njk @@ -4,7 +4,7 @@ {% set govukRebrand = true %} {% block head %} - + {% include "components/head-analytics.njk" %} {% endblock %}