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 = `
+