diff --git a/apps/postgres/prisma/migrations/20251124104824_add_email_subscriptions/migration.sql b/apps/postgres/prisma/migrations/20251124104824_add_email_subscriptions/migration.sql
new file mode 100644
index 00000000..be059427
--- /dev/null
+++ b/apps/postgres/prisma/migrations/20251124104824_add_email_subscriptions/migration.sql
@@ -0,0 +1,21 @@
+-- CreateTable
+CREATE TABLE "subscription" (
+ "subscription_id" UUID NOT NULL,
+ "user_id" UUID NOT NULL,
+ "location_id" TEXT NOT NULL,
+ "date_added" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "subscription_pkey" PRIMARY KEY ("subscription_id")
+);
+
+-- CreateIndex
+CREATE INDEX "idx_subscription_user" ON "subscription"("user_id");
+
+-- CreateIndex
+CREATE INDEX "idx_subscription_location" ON "subscription"("location_id");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "subscription_user_id_location_id_key" ON "subscription"("user_id", "location_id");
+
+-- AddForeignKey
+ALTER TABLE "subscription" ADD CONSTRAINT "subscription_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("user_id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/apps/postgres/prisma/migrations/20251127162616_change_subscription_location_id_to_int/migration.sql b/apps/postgres/prisma/migrations/20251127162616_change_subscription_location_id_to_int/migration.sql
new file mode 100644
index 00000000..cdbfda08
--- /dev/null
+++ b/apps/postgres/prisma/migrations/20251127162616_change_subscription_location_id_to_int/migration.sql
@@ -0,0 +1,20 @@
+/*
+ Warnings:
+
+ - Changed the type of `location_id` on the `subscription` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
+
+*/
+-- Drop existing indexes and constraints
+DROP INDEX IF EXISTS "idx_subscription_location";
+DROP INDEX IF EXISTS "subscription_user_id_location_id_key";
+
+-- AlterTable - Cast location_id from TEXT to INTEGER
+ALTER TABLE "subscription"
+ ALTER COLUMN "location_id" TYPE INTEGER USING "location_id"::INTEGER;
+
+-- Recreate indexes
+CREATE INDEX "idx_subscription_location" ON "subscription"("location_id");
+CREATE UNIQUE INDEX "subscription_user_id_location_id_key" ON "subscription"("user_id", "location_id");
+
+-- AddForeignKey
+ALTER TABLE "subscription" ADD CONSTRAINT "subscription_location_id_fkey" FOREIGN KEY ("location_id") REFERENCES "location"("location_id") ON DELETE RESTRICT ON UPDATE CASCADE;
diff --git a/apps/postgres/prisma/schema.prisma b/apps/postgres/prisma/schema.prisma
index b5a06937..25139e4b 100644
--- a/apps/postgres/prisma/schema.prisma
+++ b/apps/postgres/prisma/schema.prisma
@@ -26,15 +26,16 @@ model Artefact {
}
model User {
- userId String @id @default(uuid()) @map("user_id") @db.Uuid
- email String @db.VarChar(255)
- firstName String? @map("first_name") @db.VarChar(255)
- surname String? @db.VarChar(255)
- userProvenance String @map("user_provenance") @db.VarChar(20)
- userProvenanceId String @unique @map("user_provenance_id") @db.VarChar(255)
- role String @db.VarChar(20)
- createdDate DateTime @default(now()) @map("created_date")
- lastSignedInDate DateTime? @map("last_signed_in_date")
+ userId String @id @default(uuid()) @map("user_id") @db.Uuid
+ email String @db.VarChar(255)
+ firstName String? @map("first_name") @db.VarChar(255)
+ surname String? @db.VarChar(255)
+ userProvenance String @map("user_provenance") @db.VarChar(20)
+ userProvenanceId String @unique @map("user_provenance_id") @db.VarChar(255)
+ role String @db.VarChar(20)
+ createdDate DateTime @default(now()) @map("created_date")
+ lastSignedInDate DateTime? @map("last_signed_in_date")
+ subscriptions Subscription[]
@@map("user")
}
diff --git a/apps/postgres/src/schema-discovery.test.ts b/apps/postgres/src/schema-discovery.test.ts
index eb129c6c..36d9378b 100644
--- a/apps/postgres/src/schema-discovery.test.ts
+++ b/apps/postgres/src/schema-discovery.test.ts
@@ -12,10 +12,11 @@ describe("Schema Discovery", () => {
expect(Array.isArray(result)).toBe(true);
});
- it("should return schema paths", () => {
+ it("should return array with subscriptions and location schemas", () => {
const result = getPrismaSchemas();
- expect(result.length).toBeGreaterThanOrEqual(0);
- expect(result.every((path) => typeof path === "string")).toBe(true);
+ expect(result.length).toBe(2);
+ expect(result[0]).toContain("subscriptions");
+ expect(result[1]).toContain("location");
});
it("should return a new array on each call", () => {
diff --git a/apps/postgres/src/schema-discovery.ts b/apps/postgres/src/schema-discovery.ts
index a89c85d3..8d2eb66a 100644
--- a/apps/postgres/src/schema-discovery.ts
+++ b/apps/postgres/src/schema-discovery.ts
@@ -1,6 +1,7 @@
// Schema discovery functionality for module integration
import { prismaSchemas as locationSchemas } from "@hmcts/location/config";
+import { prismaSchemas as subscriptionsSchemas } from "@hmcts/subscriptions/config";
export function getPrismaSchemas(): string[] {
- return [locationSchemas];
+ return [subscriptionsSchemas, locationSchemas];
}
diff --git a/apps/web/src/assets/css/index.scss b/apps/web/src/assets/css/index.scss
index 8d0330ca..e3940a95 100644
--- a/apps/web/src/assets/css/index.scss
+++ b/apps/web/src/assets/css/index.scss
@@ -4,6 +4,7 @@
@use "@hmcts/web-core/src/assets/css/search-autocomplete.scss";
@use "@hmcts/web-core/src/assets/css/filter-panel.scss";
@use "@hmcts/web-core/src/assets/css/back-to-top.scss";
+@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;
diff --git a/docs/tickets/VIBE-192/plan.md b/docs/tickets/VIBE-192/plan.md
new file mode 100644
index 00000000..0d3e3511
--- /dev/null
+++ b/docs/tickets/VIBE-192/plan.md
@@ -0,0 +1,1327 @@
+# VIBE-192: Email Subscriptions - Technical Implementation Plan
+
+## Overview
+
+This document provides technical implementation details for the email subscriptions feature, following HMCTS monorepo conventions and GOV.UK Design System patterns.
+
+## Module Structure
+
+### New Module: `@hmcts/email-subscriptions`
+
+```
+libs/email-subscriptions/
+├── package.json
+├── tsconfig.json
+├── prisma/
+│ └── schema.prisma
+└── src/
+ ├── config.ts
+ ├── index.ts
+ │
+ ├── pages/
+ │ └── account/
+ │ └── email-subscriptions/
+ │ ├── index.ts # Dashboard (Page 1)
+ │ ├── index.njk
+ │ ├── add/
+ │ │ ├── index.ts # Add subscription (Page 2)
+ │ │ └── index.njk
+ │ ├── confirm/
+ │ │ ├── index.ts # Confirm (Page 3)
+ │ │ └── index.njk
+ │ └── confirmation/
+ │ ├── index.ts # Success (Page 4)
+ │ └── index.njk
+ │
+ ├── subscription/
+ │ ├── service.ts
+ │ ├── service.test.ts
+ │ ├── queries.ts
+ │ ├── queries.test.ts
+ │ ├── validation.ts
+ │ └── validation.test.ts
+ │
+ └── locales/
+ ├── en.ts
+ └── cy.ts
+```
+
+## Step-by-Step Implementation
+
+### Step 1: Create Module Structure
+
+```bash
+# Create directory structure
+mkdir -p libs/email-subscriptions/src/pages/account/email-subscriptions/{add,confirm,confirmation}
+mkdir -p libs/email-subscriptions/src/subscription
+mkdir -p libs/email-subscriptions/src/locales
+mkdir -p libs/email-subscriptions/prisma
+```
+
+### Step 2: Package Configuration
+
+**File**: `libs/email-subscriptions/package.json`
+
+```json
+{
+ "name": "@hmcts/email-subscriptions",
+ "version": "1.0.0",
+ "type": "module",
+ "exports": {
+ ".": {
+ "production": "./dist/index.js",
+ "default": "./src/index.ts"
+ },
+ "./config": {
+ "production": "./dist/config.js",
+ "default": "./src/config.ts"
+ }
+ },
+ "scripts": {
+ "build": "tsc && yarn build:nunjucks",
+ "build:nunjucks": "mkdir -p dist/pages && cd src/pages && find . -name '*.njk' -exec sh -c 'mkdir -p ../../dist/pages/$(dirname {}) && cp {} ../../dist/pages/{}' \\;",
+ "dev": "tsc --watch",
+ "test": "vitest run",
+ "test:watch": "vitest watch",
+ "format": "biome format --write .",
+ "lint": "biome check .",
+ "lint:fix": "biome check --write ."
+ },
+ "peerDependencies": {
+ "express": "^5.1.0"
+ }
+}
+```
+
+**File**: `libs/email-subscriptions/tsconfig.json`
+
+```json
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "declaration": true,
+ "declarationMap": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["**/*.test.ts", "dist", "node_modules"]
+}
+```
+
+### Step 3: Module Configuration Exports
+
+**File**: `libs/email-subscriptions/src/config.ts`
+
+```typescript
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+export const pageRoutes = { path: path.join(__dirname, "pages") };
+export const prismaSchemas = path.join(__dirname, "../prisma");
+```
+
+**File**: `libs/email-subscriptions/src/index.ts`
+
+```typescript
+export * from "./subscription/service.js";
+export * from "./subscription/validation.js";
+```
+
+### Step 4: Database Schema
+
+**File**: `libs/email-subscriptions/prisma/schema.prisma`
+
+```prisma
+generator client {
+ provider = "prisma-client-js"
+ output = "../../../node_modules/.prisma/client"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL")
+}
+
+model Subscription {
+ subscriptionId String @id @default(uuid()) @map("subscription_id") @db.Uuid
+ userId String @map("user_id")
+ locationId String @map("location_id")
+ subscribedAt DateTime @default(now()) @map("subscribed_at")
+ isActive Boolean @default(true) @map("is_active")
+
+ @@unique([userId, locationId], name: "unique_user_location")
+ @@index([userId], name: "idx_subscription_user")
+ @@index([locationId], name: "idx_subscription_location")
+ @@index([isActive], name: "idx_subscription_active")
+ @@map("subscription")
+}
+```
+
+### Step 5: Database Queries
+
+**File**: `libs/email-subscriptions/src/subscription/queries.ts`
+
+```typescript
+import { prisma } from "@hmcts/postgres";
+
+export async function findActiveSubscriptionsByUserId(userId: string) {
+ return prisma.subscription.findMany({
+ where: {
+ userId,
+ isActive: true
+ },
+ orderBy: {
+ subscribedAt: "desc"
+ }
+ });
+}
+
+export async function findSubscriptionByUserAndLocation(userId: string, locationId: string) {
+ return prisma.subscription.findUnique({
+ where: {
+ unique_user_location: {
+ userId,
+ locationId
+ }
+ }
+ });
+}
+
+export async function countActiveSubscriptionsByUserId(userId: string) {
+ return prisma.subscription.count({
+ where: {
+ userId,
+ isActive: true
+ }
+ });
+}
+
+export async function createSubscriptionRecord(
+ userId: string,
+ locationId: string
+) {
+ return prisma.subscription.create({
+ data: {
+ userId,
+ locationId,
+ isActive: true
+ }
+ });
+}
+
+export async function deactivateSubscriptionRecord(subscriptionId: string) {
+ return prisma.subscription.update({
+ where: { subscriptionId },
+ data: {
+ isActive: false,
+ unsubscribedAt: new Date()
+ }
+ });
+}
+```
+
+### Step 6: Validation Functions
+
+**File**: `libs/email-subscriptions/src/subscription/validation.ts`
+
+```typescript
+import { getLocationById } from "@hmcts/location";
+import { findSubscriptionByUserAndLocation } from "./queries.js";
+
+export async function validateLocationId(locationId: string): Promise {
+ const location = getLocationById(Number.parseInt(locationId, 10));
+ return location !== undefined;
+}
+
+export async function validateDuplicateSubscription(
+ userId: string,
+ locationId: string
+): Promise {
+ const existing = await findSubscriptionByUserAndLocation(userId, locationId);
+ return !existing || !existing.isActive;
+}
+
+export async function validateMinimumSubscriptions(
+ userId: string,
+ excludeLocationId?: string
+): Promise {
+ const subscriptions = await prisma.subscription.findMany({
+ where: {
+ userId,
+ isActive: true,
+ ...(excludeLocationId && { locationId: { not: excludeLocationId } })
+ }
+ });
+ return subscriptions.length > 0;
+}
+```
+
+### Step 7: Business Logic Service
+
+**File**: `libs/email-subscriptions/src/subscription/service.ts`
+
+```typescript
+import {
+ countActiveSubscriptionsByUserId,
+ createSubscriptionRecord,
+ deactivateSubscriptionRecord,
+ findActiveSubscriptionsByUserId,
+ findSubscriptionByUserAndLocation
+} from "./queries.js";
+import { validateDuplicateSubscription, validateLocationId } from "./validation.js";
+
+const MAX_SUBSCRIPTIONS = 50;
+
+export async function createSubscription(userId: string, locationId: string) {
+ const locationValid = await validateLocationId(locationId);
+ if (!locationValid) {
+ throw new Error("Invalid location ID");
+ }
+
+ const existing = await findSubscriptionByUserAndLocation(userId, locationId);
+ if (existing?.isActive) {
+ throw new Error("You are already subscribed to this court");
+ }
+
+ const count = await countActiveSubscriptionsByUserId(userId);
+ if (count >= MAX_SUBSCRIPTIONS) {
+ throw new Error(`Maximum ${MAX_SUBSCRIPTIONS} subscriptions allowed`);
+ }
+
+ if (existing && !existing.isActive) {
+ return prisma.subscription.update({
+ where: { subscriptionId: existing.subscriptionId },
+ data: {
+ isActive: true,
+ subscribedAt: new Date()
+ }
+ });
+ }
+
+ return createSubscriptionRecord(userId, locationId);
+}
+
+export async function getSubscriptionsByUserId(userId: string) {
+ return findActiveSubscriptionsByUserId(userId);
+}
+
+export async function removeSubscription(subscriptionId: string, userId: string) {
+ const subscription = await prisma.subscription.findUnique({
+ where: { subscriptionId }
+ });
+
+ if (!subscription) {
+ throw new Error("Subscription not found");
+ }
+
+ if (subscription.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ if (!subscription.isActive) {
+ throw new Error("Subscription already removed");
+ }
+
+ return deactivateSubscriptionRecord(subscriptionId);
+}
+
+export async function createMultipleSubscriptions(
+ userId: string,
+ locationIds: string[]
+) {
+ const results = await Promise.allSettled(
+ locationIds.map((locationId) => createSubscription(userId, locationId))
+ );
+
+ const succeeded = results.filter((r) => r.status === "fulfilled");
+ const failed = results.filter((r) => r.status === "rejected");
+
+ return {
+ succeeded: succeeded.length,
+ failed: failed.length,
+ errors: failed.map((r) =>
+ r.status === "rejected" ? r.reason.message : "Unknown error"
+ )
+ };
+}
+```
+
+### Step 8: Shared Translations
+
+**File**: `libs/email-subscriptions/src/locales/en.ts`
+
+```typescript
+export const en = {
+ back: "Back",
+ continue: "Continue",
+ cancel: "Cancel",
+ remove: "Remove",
+ search: "Search",
+ errorSummaryTitle: "There is a problem",
+
+ errors: {
+ minSearchLength: "Enter at least 2 characters to search",
+ alreadySubscribed: "You are already subscribed to this court",
+ noResults: "No results found for",
+ atLeastOne: "You must subscribe to at least one court or tribunal",
+ invalidLocation: "Invalid location selected"
+ }
+};
+```
+
+**File**: `libs/email-subscriptions/src/locales/cy.ts`
+
+```typescript
+export const cy = {
+ back: "Yn ôl",
+ continue: "Parhau",
+ cancel: "Canslo",
+ remove: "Dileu",
+ search: "Chwilio",
+ errorSummaryTitle: "Mae problem wedi codi",
+
+ errors: {
+ minSearchLength: "Rhowch o leiaf 2 nod i chwilio",
+ alreadySubscribed: "Rydych eisoes wedi tanysgrifio i'r llys hwn",
+ noResults: "Dim canlyniadau wedi'u canfod ar gyfer",
+ atLeastOne: "Mae'n rhaid i chi danysgrifio i o leiaf un llys neu dribiwnlys",
+ invalidLocation: "Lleoliad annilys wedi'i ddewis"
+ }
+};
+```
+
+### Step 9: Page 1 - Dashboard
+
+**File**: `libs/email-subscriptions/src/pages/account/email-subscriptions/index.ts`
+
+```typescript
+import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth";
+import { getLocationById } from "@hmcts/location";
+import type { Request, RequestHandler, Response } from "express";
+import { getSubscriptionsByUserId, removeSubscription } from "../../../subscription/service.js";
+
+const en = {
+ title: "Your email subscriptions",
+ heading: "Your email subscriptions",
+ noSubscriptions: "You have no email subscriptions",
+ noSubscriptionsMessage: "Subscribe to courts and tribunals to receive email notifications when new hearing publications are available.",
+ subscribedCount: "You are subscribed to {count} courts and tribunals",
+ addButton: "Add subscription",
+ subscribedLabel: "Subscribed:",
+ removeLink: "Remove",
+ successMessage: "Subscription removed successfully"
+};
+
+const cy = {
+ title: "Eich tanysgrifiadau e-bost",
+ heading: "Eich tanysgrifiadau e-bost",
+ noSubscriptions: "Nid oes gennych unrhyw danysgrifiadau e-bost",
+ noSubscriptionsMessage: "Tanysgrifiwch i lysoedd a thribiwnlysoedd i dderbyn hysbysiadau e-bost pan fydd cyhoeddiadau gwrandawiad newydd ar gael.",
+ subscribedCount: "Rydych wedi tanysgrifio i {count} llys a thribiwnlys",
+ addButton: "Ychwanegu tanysgrifiad",
+ subscribedLabel: "Tanysgrifiwyd:",
+ removeLink: "Dileu",
+ successMessage: "Tanysgrifiad wedi'i ddileu yn llwyddiannus"
+};
+
+const getHandler = async (req: Request, res: Response) => {
+ const locale = res.locals.locale || "en";
+ const t = locale === "cy" ? cy : en;
+ const userId = req.user?.id;
+
+ if (!userId) {
+ return res.redirect("/login");
+ }
+
+ const subscriptions = await getSubscriptionsByUserId(userId);
+
+ const subscriptionsWithDetails = subscriptions.map((sub) => {
+ const location = getLocationById(Number.parseInt(sub.locationId, 10));
+ return {
+ ...sub,
+ locationName: location ? (locale === "cy" ? location.welshName : location.name) : sub.locationId
+ };
+ });
+
+ if (!res.locals.navigation) {
+ res.locals.navigation = {};
+ }
+ res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale);
+
+ const successMessage = req.session.successMessage;
+ delete req.session.successMessage;
+
+ res.render("account/email-subscriptions/index", {
+ ...t,
+ subscriptions: subscriptionsWithDetails,
+ count: subscriptions.length,
+ successBanner: successMessage ? { text: successMessage } : undefined
+ });
+};
+
+const postHandler = async (req: Request, res: Response) => {
+ const locale = res.locals.locale || "en";
+ const t = locale === "cy" ? cy : en;
+ const userId = req.user?.id;
+ const { subscriptionId } = req.body;
+
+ if (!userId) {
+ return res.redirect("/login");
+ }
+
+ if (!subscriptionId) {
+ return res.redirect("/account/email-subscriptions");
+ }
+
+ try {
+ await removeSubscription(subscriptionId, userId);
+ req.session.successMessage = t.successMessage;
+ res.redirect("/account/email-subscriptions");
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
+
+ const subscriptions = await getSubscriptionsByUserId(userId);
+ const subscriptionsWithDetails = subscriptions.map((sub) => {
+ const location = getLocationById(Number.parseInt(sub.locationId, 10));
+ return {
+ ...sub,
+ locationName: location ? (locale === "cy" ? location.welshName : location.name) : sub.locationId
+ };
+ });
+
+ if (!res.locals.navigation) {
+ res.locals.navigation = {};
+ }
+ res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale);
+
+ res.render("account/email-subscriptions/index", {
+ ...t,
+ subscriptions: subscriptionsWithDetails,
+ count: subscriptions.length,
+ errors: {
+ titleText: t.errorSummaryTitle || "There is a problem",
+ errorList: [{ text: errorMessage }]
+ }
+ });
+ }
+};
+
+export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler];
+export const POST: RequestHandler[] = [requireAuth(), blockUserAccess(), postHandler];
+```
+
+**File**: `libs/email-subscriptions/src/pages/account/email-subscriptions/index.njk`
+
+```html
+{% extends "layouts/base-template.njk" %}
+{% from "govuk/components/button/macro.njk" import govukButton %}
+{% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner %}
+{% from "govuk/components/warning-text/macro.njk" import govukWarningText %}
+{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %}
+
+{% block pageTitle %}
+ {{ title }} - {{ serviceName }} - {{ govUk }}
+{% endblock %}
+
+{% block page_content %}
+
+{% if errors %}
+ {{ govukErrorSummary({
+ titleText: errors.titleText,
+ errorList: errors.errorList
+ }) }}
+{% endif %}
+
+{% if successBanner %}
+ {{ govukNotificationBanner({
+ type: "success",
+ text: successBanner.text
+ }) }}
+{% endif %}
+
+{{ heading }}
+
+{% if count === 0 %}
+ {{ govukWarningText({
+ text: noSubscriptions,
+ iconFallbackText: "Warning"
+ }) }}
+
+ {{ noSubscriptionsMessage }}
+
+
+ {{ addButton }}
+
+{% else %}
+ {{ subscribedCount.replace('{count}', count) }}
+
+
+ {{ addButton }}
+
+
+
+ {% for subscription in subscriptions %}
+
+
+
{{ subscription.locationName }}
+
+
+
+
+
+
- {{ subscribedLabel }}
+ - {{ subscription.subscribedAt | date('D MMMM YYYY') }}
+
+
+
+
+ {% endfor %}
+
+{% endif %}
+
+{% endblock %}
+```
+
+### Step 10: Page 2 - Add Subscription
+
+**File**: `libs/email-subscriptions/src/pages/account/email-subscriptions/add/index.ts`
+
+```typescript
+import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth";
+import { getLocationsGroupedByLetter, searchLocations } from "@hmcts/location";
+import type { Request, RequestHandler, Response } from "express";
+import { getSubscriptionsByUserId } from "../../../../subscription/service.js";
+
+const en = {
+ title: "Subscribe by court or tribunal name",
+ heading: "Subscribe by court or tribunal name",
+ searchLabel: "Search for a court or tribunal by name",
+ searchButton: "Search",
+ subscribeButton: "Subscribe",
+ browseLink: "Browse A-Z",
+ searchResults: "Search results for \"{query}\"",
+ resultsCount: "{count} results",
+ noResults: "No results found for \"{query}\"",
+ browseHeading: "Browse all courts and tribunals",
+ letterHeading: "Courts and tribunals beginning with '{letter}'",
+ alreadySubscribed: "Already subscribed"
+};
+
+const cy = {
+ title: "Tanysgrifio yn ôl enw llys neu dribiwnlys",
+ heading: "Tanysgrifio yn ôl enw llys neu dribiwnlys",
+ searchLabel: "Chwilio am lys neu dribiwnlys yn ôl enw",
+ searchButton: "Chwilio",
+ subscribeButton: "Tanysgrifio",
+ browseLink: "Pori A-Z",
+ searchResults: "Canlyniadau chwilio ar gyfer \"{query}\"",
+ resultsCount: "{count} canlyniad",
+ noResults: "Dim canlyniadau wedi'u canfod ar gyfer \"{query}\"",
+ browseHeading: "Pori pob llys a thribiwnlys",
+ letterHeading: "Llysoedd a thribiwnlysoedd yn dechrau gyda '{letter}'",
+ alreadySubscribed: "Eisoes wedi tanysgrifio"
+};
+
+const getHandler = async (req: Request, res: Response) => {
+ const locale = res.locals.locale || "en";
+ const t = locale === "cy" ? cy : en;
+ const userId = req.user?.id;
+ const query = req.query.q as string | undefined;
+ const view = req.query.view as string | undefined;
+ const letter = req.query.letter as string | undefined;
+
+ if (!userId) {
+ return res.redirect("/login");
+ }
+
+ const userSubscriptions = await getSubscriptionsByUserId(userId);
+ const subscribedLocationIds = new Set(userSubscriptions.map(s => s.locationId));
+
+ if (!res.locals.navigation) {
+ res.locals.navigation = {};
+ }
+ res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale);
+
+ if (query && query.length >= 2) {
+ const results = searchLocations(query, locale);
+ const resultsWithStatus = results.map(location => ({
+ ...location,
+ isSubscribed: subscribedLocationIds.has(location.locationId.toString())
+ }));
+
+ return res.render("account/email-subscriptions/add/index", {
+ ...t,
+ query,
+ results: resultsWithStatus,
+ showResults: true,
+ resultsCount: results.length
+ });
+ }
+
+ if (view === "browse") {
+ const grouped = getLocationsGroupedByLetter(locale);
+ const targetLetter = letter?.toUpperCase() || "A";
+ const locationsForLetter = grouped[targetLetter] || [];
+
+ const locationsWithStatus = locationsForLetter.map(location => ({
+ ...location,
+ isSubscribed: subscribedLocationIds.has(location.locationId.toString())
+ }));
+
+ return res.render("account/email-subscriptions/add/index", {
+ ...t,
+ showBrowse: true,
+ letters: Object.keys(grouped).sort(),
+ selectedLetter: targetLetter,
+ locations: locationsWithStatus
+ });
+ }
+
+ res.render("account/email-subscriptions/add/index", {
+ ...t,
+ showSearch: true
+ });
+};
+
+const postHandler = async (req: Request, res: Response) => {
+ const userId = req.user?.id;
+ const { locationId } = req.body;
+
+ if (!userId) {
+ return res.redirect("/login");
+ }
+
+ if (!locationId) {
+ return res.redirect("/account/email-subscriptions/add");
+ }
+
+ if (!req.session.emailSubscriptions) {
+ req.session.emailSubscriptions = {};
+ }
+
+ if (!req.session.emailSubscriptions.pendingSubscriptions) {
+ req.session.emailSubscriptions.pendingSubscriptions = [];
+ }
+
+ if (!req.session.emailSubscriptions.pendingSubscriptions.includes(locationId)) {
+ req.session.emailSubscriptions.pendingSubscriptions.push(locationId);
+ }
+
+ res.redirect("/account/email-subscriptions/confirm");
+};
+
+export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler];
+export const POST: RequestHandler[] = [requireAuth(), blockUserAccess(), postHandler];
+```
+
+**File**: `libs/email-subscriptions/src/pages/account/email-subscriptions/add/index.njk`
+
+```html
+{% extends "layouts/base-template.njk" %}
+{% from "govuk/components/button/macro.njk" import govukButton %}
+{% from "govuk/components/input/macro.njk" import govukInput %}
+{% from "govuk/components/back-link/macro.njk" import govukBackLink %}
+
+{% block pageTitle %}
+ {{ title }} - {{ serviceName }} - {{ govUk }}
+{% endblock %}
+
+{% block beforeContent %}
+ {{ govukBackLink({
+ text: back,
+ href: "/account/email-subscriptions"
+ }) }}
+{% endblock %}
+
+{% block page_content %}
+
+{{ heading }}
+
+{% if showSearch or showResults %}
+
+
+
+ {{ browseLink }}
+
+{% endif %}
+
+{% if showResults %}
+
+ {{ searchResults.replace('{query}', query) }}
+
+
+ {{ resultsCount.replace('{count}', resultsCount) }}
+
+ {% if results.length > 0 %}
+ {% for location in results %}
+
+
+
{{ location.name if locale === 'en' else location.welshName }}
+
+
+ {% if location.isSubscribed %}
+
{{ alreadySubscribed }}
+ {% else %}
+
+ {% endif %}
+
+
+ {% endfor %}
+ {% else %}
+ {{ noResults.replace('{query}', query) }}
+ {% endif %}
+{% endif %}
+
+{% if showBrowse %}
+ {{ browseHeading }}
+
+
+ {% for let in letters %}
+
{{ let }}
+ {% endfor %}
+
+
+ {{ letterHeading.replace('{letter}', selectedLetter) }}
+
+
+ {% for location in locations %}
+ -
+ {{ location.name if locale === 'en' else location.welshName }}
+ {% if location.isSubscribed %}
+ ({{ alreadySubscribed }})
+ {% else %}
+
+ {% endif %}
+
+ {% endfor %}
+
+{% endif %}
+
+{% endblock %}
+```
+
+### Step 11: Page 3 - Confirm
+
+**File**: `libs/email-subscriptions/src/pages/account/email-subscriptions/confirm/index.ts`
+
+```typescript
+import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth";
+import { getLocationById } from "@hmcts/location";
+import type { Request, RequestHandler, Response } from "express";
+import { createMultipleSubscriptions } from "../../../../subscription/service.js";
+
+const en = {
+ title: "Confirm your email subscriptions",
+ heading: "Confirm your email subscriptions",
+ reviewMessage: "Review your subscription before confirming:",
+ reviewMessagePlural: "Review your subscriptions before confirming:",
+ confirmButton: "Confirm subscription",
+ confirmButtonPlural: "Confirm subscriptions",
+ cancelLink: "Cancel",
+ removeLink: "Remove",
+ notificationMessage: "You will receive email notifications when new hearing publications are available for this court.",
+ notificationMessagePlural: "You will receive email notifications when new hearing publications are available for these courts.",
+ errorAtLeastOne: "You must subscribe to at least one court or tribunal",
+ backToSearch: "Back to search"
+};
+
+const cy = {
+ title: "Cadarnhau eich tanysgrifiadau e-bost",
+ heading: "Cadarnhau eich tanysgrifiadau e-bost",
+ reviewMessage: "Adolygu eich tanysgrifiad cyn cadarnhau:",
+ reviewMessagePlural: "Adolygu eich tanysgrifiadau cyn cadarnhau:",
+ confirmButton: "Cadarnhau tanysgrifiad",
+ confirmButtonPlural: "Cadarnhau tanysgrifiadau",
+ cancelLink: "Canslo",
+ removeLink: "Dileu",
+ notificationMessage: "Byddwch yn derbyn hysbysiadau e-bost pan fydd cyhoeddiadau gwrandawiad newydd ar gael ar gyfer y llys hwn.",
+ notificationMessagePlural: "Byddwch yn derbyn hysbysiadau e-bost pan fydd cyhoeddiadau gwrandawiad newydd ar gael ar gyfer y llysoedd hyn.",
+ errorAtLeastOne: "Mae'n rhaid i chi danysgrifio i o leiaf un llys neu dribiwnlys",
+ backToSearch: "Yn ôl i chwilio"
+};
+
+const getHandler = async (req: Request, res: Response) => {
+ const locale = res.locals.locale || "en";
+ const t = locale === "cy" ? cy : en;
+ const userId = req.user?.id;
+
+ if (!userId) {
+ return res.redirect("/login");
+ }
+
+ const pendingLocationIds = req.session.emailSubscriptions?.pendingSubscriptions || [];
+
+ if (pendingLocationIds.length === 0) {
+ return res.redirect("/account/email-subscriptions/add");
+ }
+
+ const pendingLocations = pendingLocationIds
+ .map((id: string) => {
+ const location = getLocationById(Number.parseInt(id, 10));
+ return location
+ ? {
+ locationId: id,
+ name: locale === "cy" ? location.welshName : location.name
+ }
+ : null;
+ })
+ .filter(Boolean);
+
+ if (!res.locals.navigation) {
+ res.locals.navigation = {};
+ }
+ res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale);
+
+ const isPlural = pendingLocations.length > 1;
+
+ res.render("account/email-subscriptions/confirm/index", {
+ ...t,
+ locations: pendingLocations,
+ isPlural,
+ reviewMessage: isPlural ? t.reviewMessagePlural : t.reviewMessage,
+ confirmButton: isPlural ? t.confirmButtonPlural : t.confirmButton,
+ notificationMessage: isPlural ? t.notificationMessagePlural : t.notificationMessage
+ });
+};
+
+const postHandler = async (req: Request, res: Response) => {
+ const locale = res.locals.locale || "en";
+ const t = locale === "cy" ? cy : en;
+ const userId = req.user?.id;
+ const { action, locationId } = req.body;
+
+ if (!userId) {
+ return res.redirect("/login");
+ }
+
+ const pendingLocationIds = req.session.emailSubscriptions?.pendingSubscriptions || [];
+
+ if (action === "remove" && locationId) {
+ req.session.emailSubscriptions.pendingSubscriptions = pendingLocationIds.filter(
+ (id: string) => id !== locationId
+ );
+
+ if (req.session.emailSubscriptions.pendingSubscriptions.length === 0) {
+ if (!res.locals.navigation) {
+ res.locals.navigation = {};
+ }
+ res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale);
+
+ return res.render("account/email-subscriptions/confirm/index", {
+ ...t,
+ errors: {
+ titleText: "There is a problem",
+ errorList: [{ text: t.errorAtLeastOne }]
+ },
+ locations: [],
+ showBackToSearch: true
+ });
+ }
+
+ return res.redirect("/account/email-subscriptions/confirm");
+ }
+
+ if (action === "confirm") {
+ if (pendingLocationIds.length === 0) {
+ return res.redirect("/account/email-subscriptions/add");
+ }
+
+ try {
+ const result = await createMultipleSubscriptions(userId, pendingLocationIds);
+
+ req.session.emailSubscriptions.confirmationComplete = true;
+ req.session.emailSubscriptions.confirmedLocations = pendingLocationIds;
+ delete req.session.emailSubscriptions.pendingSubscriptions;
+
+ res.redirect("/account/email-subscriptions/confirmation");
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
+
+ const pendingLocations = pendingLocationIds
+ .map((id: string) => {
+ const location = getLocationById(Number.parseInt(id, 10));
+ return location
+ ? {
+ locationId: id,
+ name: locale === "cy" ? location.welshName : location.name
+ }
+ : null;
+ })
+ .filter(Boolean);
+
+ if (!res.locals.navigation) {
+ res.locals.navigation = {};
+ }
+ res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale);
+
+ const isPlural = pendingLocations.length > 1;
+
+ res.render("account/email-subscriptions/confirm/index", {
+ ...t,
+ errors: {
+ titleText: "There is a problem",
+ errorList: [{ text: errorMessage }]
+ },
+ locations: pendingLocations,
+ isPlural,
+ reviewMessage: isPlural ? t.reviewMessagePlural : t.reviewMessage,
+ confirmButton: isPlural ? t.confirmButtonPlural : t.confirmButton,
+ notificationMessage: isPlural ? t.notificationMessagePlural : t.notificationMessage
+ });
+ }
+ }
+};
+
+export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler];
+export const POST: RequestHandler[] = [requireAuth(), blockUserAccess(), postHandler];
+```
+
+**File**: `libs/email-subscriptions/src/pages/account/email-subscriptions/confirm/index.njk`
+
+```html
+{% extends "layouts/base-template.njk" %}
+{% from "govuk/components/button/macro.njk" import govukButton %}
+{% from "govuk/components/back-link/macro.njk" import govukBackLink %}
+{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %}
+
+{% block pageTitle %}
+ {{ title }} - {{ serviceName }} - {{ govUk }}
+{% endblock %}
+
+{% block beforeContent %}
+ {{ govukBackLink({
+ text: back,
+ href: "/account/email-subscriptions/add"
+ }) }}
+{% endblock %}
+
+{% block page_content %}
+
+{% if errors %}
+ {{ govukErrorSummary({
+ titleText: errors.titleText,
+ errorList: errors.errorList
+ }) }}
+{% endif %}
+
+{{ heading }}
+
+{% if showBackToSearch %}
+
+ {{ backToSearch }}
+
+{% else %}
+ {{ reviewMessage }}
+
+ {% for location in locations %}
+
+
+
{{ location.name }}
+
+
+
+ {% endfor %}
+
+ {{ notificationMessage }}
+
+
+
+
+ {{ cancelLink }}
+
+{% endif %}
+
+{% endblock %}
+```
+
+### Step 12: Page 4 - Confirmation
+
+**File**: `libs/email-subscriptions/src/pages/account/email-subscriptions/confirmation/index.ts`
+
+```typescript
+import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth";
+import { getLocationById } from "@hmcts/location";
+import type { Request, RequestHandler, Response } from "express";
+
+const en = {
+ title: "Subscription confirmed",
+ panelTitle: "Subscription confirmed",
+ panelTitlePlural: "Subscriptions confirmed",
+ message: "You have subscribed to email notifications for:",
+ notificationMessage: "You will receive an email when new hearing publications are available for this court.",
+ notificationMessagePlural: "You will receive emails when new hearing publications are available for these courts.",
+ viewSubscriptionsButton: "View your subscriptions",
+ homeLink: "Back to service home"
+};
+
+const cy = {
+ title: "Tanysgrifiad wedi'i gadarnhau",
+ panelTitle: "Tanysgrifiad wedi'i gadarnhau",
+ panelTitlePlural: "Tanysgrifiadau wedi'u cadarnhau",
+ message: "Rydych wedi tanysgrifio i hysbysiadau e-bost ar gyfer:",
+ notificationMessage: "Byddwch yn derbyn e-bost pan fydd cyhoeddiadau gwrandawiad newydd ar gael ar gyfer y llys hwn.",
+ notificationMessagePlural: "Byddwch yn derbyn e-byst pan fydd cyhoeddiadau gwrandawiad newydd ar gael ar gyfer y llysoedd hyn.",
+ viewSubscriptionsButton: "Gweld eich tanysgrifiadau",
+ homeLink: "Yn ôl i hafan y gwasanaeth"
+};
+
+const getHandler = async (req: Request, res: Response) => {
+ const locale = res.locals.locale || "en";
+ const t = locale === "cy" ? cy : en;
+
+ if (!req.session.emailSubscriptions?.confirmationComplete) {
+ return res.redirect("/account/email-subscriptions");
+ }
+
+ const confirmedLocationIds = req.session.emailSubscriptions.confirmedLocations || [];
+
+ const confirmedLocations = confirmedLocationIds
+ .map((id: string) => {
+ const location = getLocationById(Number.parseInt(id, 10));
+ return location ? (locale === "cy" ? location.welshName : location.name) : null;
+ })
+ .filter(Boolean);
+
+ delete req.session.emailSubscriptions.confirmationComplete;
+ delete req.session.emailSubscriptions.confirmedLocations;
+
+ if (!res.locals.navigation) {
+ res.locals.navigation = {};
+ }
+ res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale);
+
+ const isPlural = confirmedLocations.length > 1;
+
+ res.render("account/email-subscriptions/confirmation/index", {
+ ...t,
+ locations: confirmedLocations,
+ isPlural,
+ panelTitle: isPlural ? t.panelTitlePlural : t.panelTitle,
+ notificationMessage: isPlural ? t.notificationMessagePlural : t.notificationMessage
+ });
+};
+
+export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler];
+```
+
+**File**: `libs/email-subscriptions/src/pages/account/email-subscriptions/confirmation/index.njk`
+
+```html
+{% extends "layouts/base-template.njk" %}
+{% from "govuk/components/panel/macro.njk" import govukPanel %}
+{% from "govuk/components/button/macro.njk" import govukButton %}
+
+{% block pageTitle %}
+ {{ title }} - {{ serviceName }} - {{ govUk }}
+{% endblock %}
+
+{% block page_content %}
+
+{{ govukPanel({
+ titleText: panelTitle,
+ classes: "govuk-panel--confirmation"
+}) }}
+
+{{ message }}
+
+
+ {% for location in locations %}
+ - {{ location }}
+ {% endfor %}
+
+
+{{ notificationMessage }}
+
+
+ {{ viewSubscriptionsButton }}
+
+
+
+ {{ homeLink }}
+
+
+{% endblock %}
+```
+
+### Step 13: Register Module
+
+**Update root `tsconfig.json`**:
+
+```json
+{
+ "compilerOptions": {
+ "paths": {
+ "@hmcts/email-subscriptions": ["libs/email-subscriptions/src"]
+ }
+ }
+}
+```
+
+**Update `apps/web/src/app.ts`**:
+
+```typescript
+import { pageRoutes as emailSubscriptionsPages } from "@hmcts/email-subscriptions/config";
+
+// After authentication routes
+app.use(await createSimpleRouter(emailSubscriptionsPages));
+```
+
+**Update `apps/postgres/src/schema-discovery.ts`**:
+
+```typescript
+import { prismaSchemas as emailSubscriptionsSchemas } from "@hmcts/email-subscriptions/config";
+
+const schemaPaths = [
+ emailSubscriptionsSchemas,
+ // ... other schemas
+];
+```
+
+### Step 14: Database Migration
+
+```bash
+# Generate migration
+yarn db:migrate:dev
+
+# Apply migration
+yarn db:migrate
+
+# Generate Prisma client
+yarn db:generate
+```
+
+### Step 15: Testing
+
+Create test files co-located with source:
+
+- `libs/email-subscriptions/src/subscription/service.test.ts`
+- `libs/email-subscriptions/src/subscription/queries.test.ts`
+- `libs/email-subscriptions/src/subscription/validation.test.ts`
+
+Run tests:
+
+```bash
+yarn test
+```
+
+### Step 16: E2E Testing
+
+Create E2E test file: `e2e-tests/tests/email-subscriptions.spec.ts`
+
+```bash
+yarn test:e2e
+```
+
+## Security Checklist
+
+- [ ] All routes protected with `requireAuth()` middleware
+- [ ] All routes protected with `blockUserAccess()` (verified users only)
+- [ ] User ownership validated on all mutations
+- [ ] CSRF tokens on all POST forms (provided by express-session)
+- [ ] Input validation on all fields
+- [ ] Location IDs validated against known locations
+- [ ] No email addresses stored in subscriptions table (use user_id)
+- [ ] Soft deletes maintain audit trail
+
+## Accessibility Checklist
+
+- [ ] All pages WCAG 2.2 AA compliant
+- [ ] Keyboard navigation functional
+- [ ] Screen reader compatible (test with NVDA/JAWS)
+- [ ] Error summaries linked to form fields
+- [ ] Progressive enhancement (works without JavaScript)
+- [ ] Color contrast ratios meet standards
+- [ ] Focus indicators visible
+
+## Welsh Language Checklist
+
+- [ ] All page content available in Welsh
+- [ ] Language toggle functional
+- [ ] Error messages translated
+- [ ] Date formatting localized
+- [ ] Court names display in correct language
+
+## Performance Targets
+
+- Dashboard load time: < 2 seconds
+- Search results: < 1 second
+- Add/remove operations: < 1 second
+- Support up to 50 subscriptions per user
+
+## Definition of Done
+
+- [ ] All pages implemented and functional
+- [ ] Database schema created and migrated
+- [ ] Unit tests written and passing (>80% coverage)
+- [ ] Integration tests passing
+- [ ] E2E tests passing
+- [ ] Accessibility audit passed
+- [ ] Welsh translations complete and verified
+- [ ] Security review completed
+- [ ] Code review approved
+- [ ] Documentation updated
+- [ ] Deployed to staging and smoke tested
+
+## Out of Scope
+
+The following are NOT included in this ticket:
+
+- Email notification sending
+- Email frequency preferences (immediate/daily/weekly)
+- Unsubscribe via email link
+- Notification queue processing
+- GOV Notify integration
+- Email templates
diff --git a/docs/tickets/VIBE-192/specification.md b/docs/tickets/VIBE-192/specification.md
new file mode 100644
index 00000000..068d61df
--- /dev/null
+++ b/docs/tickets/VIBE-192/specification.md
@@ -0,0 +1,608 @@
+# VIBE-192: Verified User – Email subscriptions
+
+## Overview
+
+Verified users need the ability to subscribe to email notifications for court and tribunal hearing publications. This feature allows users to select one or more court/tribunal locations and receive notifications when new publications are available.
+
+## User Story
+
+**As a** verified user of the Court and Tribunal Hearings service
+**I want to** subscribe to email notifications for specific courts and tribunals
+**So that** I can be automatically informed when new hearing publications are available
+
+## Acceptance Criteria
+
+### Functional Requirements
+
+1. **User Access**
+ - Only verified users can access subscription management
+ - Feature is accessible from user account dashboard
+ - Requires authentication to view or modify subscriptions
+
+2. **Subscription Management**
+ - Users can view their current subscriptions
+ - Users can add new subscriptions by searching/selecting courts
+ - Users can remove existing subscriptions
+ - Users must maintain at least one subscription (cannot remove all)
+ - Duplicate subscriptions prevented (cannot subscribe to same venue twice)
+
+3. **Data Persistence**
+ - Subscriptions stored in database with: subscription_id (UUID), user_id, location_id, date_added
+ - User's subscriptions persist across sessions
+ - Account deletion removes all associated subscriptions
+
+### Non-Functional Requirements
+
+1. **Accessibility**
+ - WCAG 2.2 AA compliant
+ - Fully keyboard navigable
+ - Screen reader compatible
+ - Works without JavaScript (progressive enhancement)
+
+2. **Welsh Language Support**
+ - All UI text available in Welsh and English
+ - Language toggle available on all pages
+ - Content matches user's selected language preference
+
+3. **Security**
+ - Authentication required for all subscription operations
+ - CSRF protection on all forms
+ - Input validation on all fields
+ - Users can only manage their own subscriptions
+
+## Page Flows
+
+### Page 1: Your email subscriptions
+
+**URL**: `/account/email-subscriptions`
+**Methods**: GET, POST
+
+**Purpose**: Display user's current subscriptions with ability to add more or remove existing ones
+
+**Empty State** (no subscriptions):
+```
+Your email subscriptions
+────────────────────────
+
+You have no email subscriptions
+
+Subscribe to courts and tribunals to receive email notifications
+when new hearing publications are available.
+
+[Add subscription] button
+```
+
+**With Subscriptions**:
+```
+Your email subscriptions
+────────────────────────
+
+You are subscribed to 3 courts and tribunals
+
+[Add subscription] button
+
+┌──────────────────────────────────────────────────┐
+│ Birmingham Civil and Family Justice Centre │
+│ Subscribed: 12 January 2025 │
+│ [Remove] link │
+└──────────────────────────────────────────────────┘
+
+┌──────────────────────────────────────────────────┐
+│ Manchester Crown Court │
+│ Subscribed: 5 January 2025 │
+│ [Remove] link │
+└──────────────────────────────────────────────────┘
+
+┌──────────────────────────────────────────────────┐
+│ Cardiff Employment Tribunal │
+│ Subscribed: 28 December 2024 │
+│ [Remove] link │
+└──────────────────────────────────────────────────┘
+```
+
+**GOV.UK Components**:
+- govukButton (Add subscription)
+- govukSummaryList (subscription list)
+- govukWarningText (empty state)
+- govukNotificationBanner (success messages after add/remove)
+
+**Validation**:
+- None on this page (display only, actions redirect to other pages)
+
+---
+
+### Page 2: Subscribe by court or tribunal name
+
+**URL**: `/account/email-subscriptions/add`
+**Methods**: GET, POST
+
+**Purpose**: Search for courts/tribunals and select one to subscribe to
+
+**Layout**:
+```
+Subscribe by court or tribunal name
+────────────────────────────────────
+
+Search for a court or tribunal by name:
+
+[Search input field ]
+[Search] button
+
+Or browse all courts and tribunals alphabetically:
+
+[Browse A-Z] link
+```
+
+**After Search** (query parameter: `?q=birmingham`):
+```
+Subscribe by court or tribunal name
+────────────────────────────────────
+
+Search results for "birmingham" (5 results)
+
+[New search input with "birmingham" pre-filled ]
+[Search] button
+
+┌──────────────────────────────────────────────────┐
+│ Birmingham Civil and Family Justice Centre │
+│ Bull Street, Birmingham, B4 6DS │
+│ [Subscribe] button │
+└──────────────────────────────────────────────────┘
+
+┌──────────────────────────────────────────────────┐
+│ Birmingham Crown Court │
+│ Bull Street, Birmingham, B4 6DS │
+│ [Subscribe] button │
+└──────────────────────────────────────────────────┘
+
+... more results ...
+```
+
+**Browse A-Z View** (URL: `/account/email-subscriptions/add?view=browse`):
+```
+Subscribe by court or tribunal name
+────────────────────────────────────
+
+Browse all courts and tribunals
+
+[A] [B] [C] [D] [E] [F] [G] [H] [I] [J] [K] [L] [M]
+[N] [O] [P] [Q] [R] [S] [T] [U] [V] [W] [X] [Y] [Z]
+
+Courts and tribunals beginning with 'B'
+
+• Birmingham Civil and Family Justice Centre [Subscribe]
+• Birmingham Crown Court [Subscribe]
+• Blackpool Magistrates Court [Subscribe]
+... more ...
+```
+
+**GOV.UK Components**:
+- govukInput (search field)
+- govukButton (Search, Subscribe buttons)
+- govukBackLink (return to dashboard)
+
+**Validation**:
+- Search query minimum 2 characters
+- At least one result must be returned (or show "No results" message)
+- Cannot subscribe to a location user is already subscribed to (show error)
+
+---
+
+### Page 3: Confirm your email subscriptions
+
+**URL**: `/account/email-subscriptions/confirm`
+**Methods**: GET, POST
+
+**Purpose**: Review selected subscription before confirming, with option to remove
+
+**Layout** (single subscription pending):
+```
+Confirm your email subscriptions
+─────────────────────────────────
+
+Review your subscription before confirming:
+
+┌──────────────────────────────────────────────────┐
+│ Birmingham Civil and Family Justice Centre │
+│ Bull Street, Birmingham, B4 6DS │
+│ [Remove] link │
+└──────────────────────────────────────────────────┘
+
+You will receive email notifications when new hearing
+publications are available for this court.
+
+[Confirm subscription] button
+[Cancel] link (returns to add page)
+```
+
+**With Multiple Selections** (if user selected multiple in one session):
+```
+Confirm your email subscriptions
+─────────────────────────────────
+
+Review your subscriptions before confirming:
+
+┌──────────────────────────────────────────────────┐
+│ Birmingham Civil and Family Justice Centre │
+│ [Remove] link │
+└──────────────────────────────────────────────────┘
+
+┌──────────────────────────────────────────────────┐
+│ Manchester Crown Court │
+│ [Remove] link │
+└──────────────────────────────────────────────────┘
+
+[Confirm subscriptions] button
+[Cancel] link
+```
+
+**Error State** (tried to remove all):
+```
+[Error Summary]
+There is a problem
+• You must subscribe to at least one court or tribunal
+
+Confirm your email subscriptions
+─────────────────────────────────
+
+[No subscriptions shown]
+
+[Back to search] link
+```
+
+**GOV.UK Components**:
+- govukSummaryList (subscription list with remove actions)
+- govukButton (Confirm)
+- govukBackLink
+- govukErrorSummary (validation errors)
+
+**Validation**:
+- Must have at least one subscription selected
+- Cannot proceed with zero subscriptions
+- All selected locations must be valid location IDs
+
+---
+
+### Page 4: Subscription confirmation
+
+**URL**: `/account/email-subscriptions/confirmation`
+**Method**: GET (only accessed after successful POST)
+
+**Purpose**: Confirm successful subscription addition
+
+**Layout** (single subscription):
+```
+┌─────────────────────────────────────────────────┐
+│ ✓ Subscription confirmed │
+└─────────────────────────────────────────────────┘
+
+You have subscribed to email notifications for:
+• Birmingham Civil and Family Justice Centre
+
+You will receive an email when new hearing publications
+are available for this court.
+
+[View your subscriptions] button
+[Back to service home] link
+```
+
+**Layout** (multiple subscriptions):
+```
+┌─────────────────────────────────────────────────┐
+│ ✓ Subscriptions confirmed │
+└─────────────────────────────────────────────────┘
+
+You have subscribed to email notifications for:
+• Birmingham Civil and Family Justice Centre
+• Manchester Crown Court
+
+You will receive emails when new hearing publications
+are available for these courts.
+
+[View your subscriptions] button
+[Back to service home] link
+```
+
+**GOV.UK Components**:
+- govukPanel (success confirmation - green banner)
+- govukButton
+- govukBackLink
+
+**Note**: This page should only be accessible after a successful POST to confirm. Direct access should redirect to the subscriptions dashboard.
+
+---
+
+## Database Schema
+
+### New Table: subscription
+
+```prisma
+model Subscription {
+ subscriptionId String @id @default(uuid()) @map("subscription_id") @db.Uuid
+ userId String @map("user_id")
+ locationId String @map("location_id")
+ subscribedAt DateTime @default(now()) @map("subscribed_at")
+ isActive Boolean @default(true) @map("is_active")
+
+ @@unique([userId, locationId], name: "unique_user_location")
+ @@index([userId], name: "idx_subscription_user")
+ @@index([locationId], name: "idx_subscription_location")
+ @@index([isActive], name: "idx_subscription_active")
+ @@map("subscription")
+}
+```
+
+**Field Descriptions**:
+- `subscription_id`: Unique identifier for the subscription (UUID)
+- `user_id`: ID of the user who created the subscription
+- `location_id`: ID of the court/tribunal location
+- `subscribed_at`: Timestamp when subscription was created (maps to "date_added" requirement)
+- `is_active`: Soft delete flag (true = active, false = deleted)
+
+**Indexes**:
+- `userId`: Find all subscriptions for a user (dashboard page)
+- `locationId`: Find all users subscribed to a location (for notifications)
+- `isActive`: Filter active subscriptions efficiently
+- Unique constraint on (userId, locationId): Prevent duplicate subscriptions
+
+---
+
+## Validation Rules
+
+### Page 1: Dashboard
+- No validation required (display only)
+
+### Page 2: Add Subscription
+- Search query: Minimum 2 characters, maximum 100 characters
+- Location ID: Must exist in location data
+- Duplicate check: User cannot subscribe to a location they're already subscribed to
+- Error messages:
+ - "Enter at least 2 characters to search"
+ - "You are already subscribed to this court"
+ - "No results found for '{query}'"
+
+### Page 3: Confirm
+- Must have at least one subscription in the confirmation list
+- All location IDs must be valid
+- Cannot proceed with empty list
+- Error messages:
+ - "You must subscribe to at least one court or tribunal"
+ - "Invalid location selected"
+
+### Page 4: Confirmation
+- No validation (display only)
+- Redirect to dashboard if accessed directly without prior confirmation
+
+---
+
+## URL Structure
+
+| Page | URL | Methods | Auth Required |
+|------|-----|---------|---------------|
+| Dashboard | `/account/email-subscriptions` | GET, POST | Yes (Verified) |
+| Add Subscription | `/account/email-subscriptions/add` | GET, POST | Yes (Verified) |
+| Confirm | `/account/email-subscriptions/confirm` | GET, POST | Yes (Verified) |
+| Confirmation | `/account/email-subscriptions/confirmation` | GET | Yes (Verified) |
+
+**Query Parameters**:
+- `/add?q=search-term`: Search results
+- `/add?view=browse`: Browse A-Z view
+- `/add?view=browse&letter=B`: Browse specific letter
+
+---
+
+## Session Storage
+
+Track pending subscriptions during the add/confirm flow:
+
+```typescript
+interface EmailSubscriptionsSession {
+ pendingSubscriptions?: string[]; // Array of location IDs
+ confirmationComplete?: boolean; // Flag for confirmation page access
+}
+```
+
+**Flow**:
+1. User clicks "Subscribe" on Page 2 → Add location ID to `pendingSubscriptions` array
+2. User navigates to Page 3 → Display all locations in `pendingSubscriptions`
+3. User clicks "Remove" on Page 3 → Remove location ID from array
+4. User clicks "Confirm" on Page 3 → Save to database, set `confirmationComplete` = true
+5. User views Page 4 → Show success, clear session data
+
+---
+
+## Accessibility Requirements
+
+### WCAG 2.2 AA Compliance
+
+1. **Keyboard Navigation**
+ - All interactive elements accessible via Tab key
+ - Logical tab order throughout forms
+ - Focus indicators visible on all interactive elements
+ - Skip links provided for main content
+
+2. **Screen Readers**
+ - Semantic HTML5 elements (nav, main, section)
+ - ARIA labels on search input: "Search for a court or tribunal"
+ - ARIA live region for search results count
+ - Clear link text (avoid "click here")
+ - Error messages linked to form fields via aria-describedby
+
+3. **Visual Design**
+ - Color contrast ratio minimum 4.5:1 for text (GOV.UK Design System compliant)
+ - Text resizable up to 200% without loss of functionality
+ - No information conveyed by color alone
+ - Focus states clearly visible (GOV.UK defaults)
+
+4. **Forms**
+ - Labels associated with inputs via for/id
+ - Error messages in error summary and inline
+ - Error summary receives focus when errors present
+ - Fieldset/legend for grouped controls
+
+5. **Progressive Enhancement**
+ - Core functionality works without JavaScript
+ - Forms submit via standard HTTP POST
+ - Search works with page refresh
+ - JavaScript enhances (e.g., autocomplete) but doesn't replace
+
+---
+
+## Welsh Language Content
+
+All pages must provide Welsh translations for:
+
+### Page 1 - Dashboard
+- EN: "Your email subscriptions", "You have no email subscriptions", "You are subscribed to X courts and tribunals", "Add subscription", "Remove", "Subscribed: {date}"
+- CY: "Eich tanysgrifiadau e-bost", "Nid oes gennych unrhyw danysgrifiadau e-bost", "Rydych wedi tanysgrifio i X llys a thribiwnlys", "Ychwanegu tanysgrifiad", "Dileu", "Tanysgrifiwyd: {dyddiad}"
+
+### Page 2 - Add Subscription
+- EN: "Subscribe by court or tribunal name", "Search for a court or tribunal by name", "Search", "Subscribe", "Browse A-Z", "Search results for", "results"
+- CY: "Tanysgrifio yn ôl enw llys neu dribiwnlys", "Chwilio am lys neu dribiwnlys yn ôl enw", "Chwilio", "Tanysgrifio", "Pori A-Z", "Canlyniadau chwilio ar gyfer", "canlyniad"
+
+### Page 3 - Confirm
+- EN: "Confirm your email subscriptions", "Review your subscription before confirming", "You will receive email notifications...", "Confirm subscription", "Cancel", "Remove"
+- CY: "Cadarnhau eich tanysgrifiadau e-bost", "Adolygu eich tanysgrifiad cyn cadarnhau", "Byddwch yn derbyn hysbysiadau e-bost...", "Cadarnhau tanysgrifiad", "Canslo", "Dileu"
+
+### Page 4 - Confirmation
+- EN: "Subscription confirmed", "You have subscribed to email notifications for:", "View your subscriptions", "Back to service home"
+- CY: "Tanysgrifiad wedi'i gadarnhau", "Rydych wedi tanysgrifio i hysbysiadau e-bost ar gyfer:", "Gweld eich tanysgrifiadau", "Yn ôl i hafan y gwasanaeth"
+
+### Error Messages
+- EN: "There is a problem", "You must subscribe to at least one court or tribunal", "Enter at least 2 characters to search", "You are already subscribed to this court"
+- CY: "Mae problem wedi codi", "Mae'n rhaid i chi danysgrifio i o leiaf un llys neu dribiwnlys", "Rhowch o leiaf 2 nod i chwilio", "Rydych eisoes wedi tanysgrifio i'r llys hwn"
+
+---
+
+## Test Scenarios
+
+### Unit Tests
+
+1. **Subscription Service**
+ - `createSubscription()` creates new subscription successfully
+ - `createSubscription()` prevents duplicate subscriptions
+ - `getSubscriptionsByUserId()` returns user's active subscriptions
+ - `removeSubscription()` deactivates subscription (soft delete)
+ - `removeSubscription()` validates user owns subscription
+
+2. **Validation Functions**
+ - `validateLocationId()` returns true for valid location
+ - `validateLocationId()` returns false for invalid location
+ - `validateMinimumSubscriptions()` prevents removing all subscriptions
+ - `validateDuplicateSubscription()` detects duplicates
+
+### Integration Tests
+
+1. **Complete Add Flow**
+ - User searches for court
+ - User selects court from results
+ - User confirms subscription
+ - Subscription saved to database
+ - Success message displayed
+
+2. **Remove Flow**
+ - User removes subscription from dashboard
+ - Subscription marked inactive in database
+ - Confirmation message shown
+ - Subscription no longer appears in list
+
+### E2E Tests (Playwright)
+
+```typescript
+test('verified user can add email subscription', async ({ page }) => {
+ // Login as verified user
+ await page.goto('/login');
+ // ... authentication ...
+
+ // Navigate to subscriptions
+ await page.goto('/account/email-subscriptions');
+ await expect(page.locator('h1')).toContainText('Your email subscriptions');
+
+ // Add subscription
+ await page.click('text=Add subscription');
+ await page.fill('[name="search"]', 'Birmingham');
+ await page.click('button:has-text("Search")');
+ await page.click('button:has-text("Subscribe")').first();
+
+ // Confirm
+ await page.click('button:has-text("Confirm subscription")');
+
+ // Verify success
+ await expect(page.locator('.govuk-panel__title')).toContainText('Subscription confirmed');
+});
+
+test('user cannot remove all subscriptions', async ({ page }) => {
+ await page.goto('/account/email-subscriptions/confirm');
+
+ // Remove all subscriptions
+ await page.click('a:has-text("Remove")');
+
+ // Try to confirm
+ await page.click('button:has-text("Confirm subscription")');
+
+ // Verify error
+ await expect(page.locator('.govuk-error-summary')).toBeVisible();
+ await expect(page.locator('.govuk-error-message')).toContainText('at least one');
+});
+
+test('subscriptions page is accessible', async ({ page }) => {
+ await page.goto('/account/email-subscriptions');
+ const results = await new AxeBuilder({ page }).analyze();
+ expect(results.violations).toEqual([]);
+});
+
+test('Welsh language toggle works', async ({ page }) => {
+ await page.goto('/account/email-subscriptions');
+ await page.click('a[href*="lng=cy"]');
+ await expect(page.locator('h1')).toContainText('Eich tanysgrifiadau e-bost');
+});
+```
+
+---
+
+## Security Considerations
+
+1. **Authentication & Authorization**
+ - All pages require `requireAuth()` middleware
+ - All pages require `blockUserAccess()` (verified users only)
+ - Users can only view/modify their own subscriptions
+ - Validate user owns subscription before removal
+
+2. **CSRF Protection**
+ - All POST forms include CSRF token
+ - Tokens validated on server side
+
+3. **Input Validation**
+ - Search queries sanitized
+ - Location IDs validated against known locations
+ - Maximum subscription limits enforced
+
+4. **Data Protection**
+ - No email addresses stored in subscriptions (use user_id reference)
+ - Soft deletes preserve audit trail
+ - No sensitive data in URLs or logs
+
+---
+
+## Performance Requirements
+
+- Subscription list page load: < 2 seconds
+- Search results: < 1 second
+- Add/remove operations: < 1 second
+- Support up to 50 subscriptions per user
+- Support 10,000 concurrent verified users
+
+---
+
+## Out of Scope
+
+The following are explicitly NOT included in this ticket:
+
+- Email notification sending (separate ticket)
+- Email frequency preferences (immediate/daily/weekly)
+- Unsubscribe via email link
+- Subscription to specific case types or hearing types
+- Bulk subscription import/export
+- SMS or push notifications
+- Notification history
+- Email templates and GOV Notify integration
diff --git a/docs/tickets/VIBE-192/tasks.md b/docs/tickets/VIBE-192/tasks.md
new file mode 100644
index 00000000..3e64703f
--- /dev/null
+++ b/docs/tickets/VIBE-192/tasks.md
@@ -0,0 +1,425 @@
+# VIBE-192: Verified User – Email subscriptions - Implementation Tasks
+
+## Phase 1: Infrastructure and Database Setup
+
+### 1.1 Create Email Subscriptions Module
+- [x] Create `libs/email-subscriptions/` directory structure
+- [x] Create `libs/email-subscriptions/package.json` with module metadata
+- [x] Create `libs/email-subscriptions/tsconfig.json`
+- [x] Create `libs/email-subscriptions/src/config.ts` with module exports
+- [x] Create `libs/email-subscriptions/src/index.ts` for business logic exports
+- [x] Register module in root `tsconfig.json` paths as `@hmcts/email-subscriptions`
+
+### 1.2 Database Schema
+- [x] Create `libs/email-subscriptions/prisma/schema.prisma`
+- [x] Define `Subscription` model with fields:
+ - subscriptionId (UUID, primary key)
+ - userId (string, indexed)
+ - locationId (string, indexed)
+ - subscribedAt (DateTime)
+ - unsubscribedAt (DateTime, nullable)
+ - isActive (Boolean, indexed)
+ - Unique constraint on (userId, locationId)
+- [ ] Define `NotificationQueue` model with fields (Phase 4):
+ - queueId (UUID, primary key)
+ - subscriptionId (UUID)
+ - artefactId (UUID)
+ - status (enum: PENDING, SENT, FAILED, indexed)
+ - attemptCount (integer)
+ - createdAt (DateTime, indexed)
+ - sentAt (DateTime, nullable)
+ - errorMessage (string, nullable)
+- [ ] Define `EmailLog` model with fields (Phase 4):
+ - logId (UUID, primary key)
+ - userId (string, indexed)
+ - emailAddress (string)
+ - subject (string)
+ - templateId (string)
+ - status (enum: SENT, FAILED, BOUNCED)
+ - sentAt (DateTime, indexed)
+ - errorMessage (string, nullable)
+- [x] Register schema in `apps/postgres/src/schema-discovery.ts`
+- [x] Run `yarn db:migrate:dev` to create migration
+- [x] Run `yarn db:generate` to generate Prisma client
+
+### 1.3 Module Registration
+- [x] Import module config in `apps/web/src/app.ts`
+- [x] Register page routes with `createSimpleRouter()`
+- [x] Register assets in `apps/web/vite.config.ts` (no assets currently)
+- [x] Add module dependency to `apps/web/package.json` (already present)
+
+## Phase 2: Core Business Logic
+
+### 2.1 Subscription Service
+- [x] Create `libs/email-subscriptions/src/subscription/service.ts`
+- [x] Implement `createSubscription(userId, locationId)` function
+ - Validate user and location IDs
+ - Check for duplicate subscriptions
+ - Enforce 50 subscription limit
+ - Create subscription record
+ - Return subscription or error
+- [x] Implement `getSubscriptionsByUserId(userId)` function
+ - Query active subscriptions for user
+ - Return sorted list (most recent first)
+- [x] Implement `removeSubscription(subscriptionId, userId)` function
+ - Validate user owns subscription
+ - Set isActive to false
+ - Set unsubscribedAt timestamp
+ - Return success/error
+- [x] Implement `createMultipleSubscriptions(userId, locationIds)` function
+ - Handle batch subscription creation
+- [ ] Implement `updateEmailFrequency(userId, frequency)` function (Phase 4)
+ - Validate frequency value
+ - Update all user subscriptions
+ - Return success/error
+
+### 2.2 Subscription Queries
+- [x] Create `libs/email-subscriptions/src/subscription/queries.ts`
+- [x] Implement `findSubscriptionByUserAndLocation(userId, locationId)` query
+- [x] Implement `findActiveSubscriptionsByUserId(userId)` query
+- [x] Implement `countActiveSubscriptionsByUserId(userId)` query
+- [x] Implement `createSubscriptionRecord(userId, locationId)` mutation
+- [x] Implement `deactivateSubscriptionRecord(subscriptionId)` mutation
+- [ ] Implement `findSubscriptionsByLocationId(locationId)` query (Phase 4 - for notifications)
+
+### 2.3 Validation
+- [x] Create `libs/email-subscriptions/src/subscription/validation.ts`
+- [x] Implement `validateLocationId(locationId)` function
+- [x] Implement `validateDuplicateSubscription(userId, locationId)` function
+- [ ] Implement `validateEmailFrequency(frequency)` function (Phase 4)
+
+### 2.4 Unit Tests for Business Logic
+- [x] Create `subscription/service.test.ts`
+- [x] Test `createSubscription()` - successful creation
+- [x] Test `createSubscription()` - duplicate prevention
+- [x] Test `createSubscription()` - subscription limit enforcement
+- [x] Test `getSubscriptionsByUserId()` - returns correct data
+- [x] Test `removeSubscription()` - successful removal
+- [x] Test `removeSubscription()` - validation of ownership
+- [x] Test `createMultipleSubscriptions()` - batch operations
+- [x] Create `subscription/validation.test.ts`
+- [x] Test all validation functions with valid/invalid inputs
+- [x] Aim for >80% code coverage (100% achieved)
+
+## Phase 3: Web Interface - Subscriptions Dashboard
+
+### 3.1 Dashboard Page
+- [x] Create `libs/email-subscriptions/src/pages/account/email-subscriptions/index.ts`
+- [x] Implement GET handler:
+ - Require auth with `requireAuth()` and `blockUserAccess()`
+ - Fetch user's subscriptions via service
+ - Build verified user navigation
+ - Render dashboard template
+- [x] Implement POST handler for removing subscriptions
+- [x] Add English translations inline in controller
+ - Page title and heading
+ - Empty state message
+ - Button labels
+ - Subscription list labels
+- [x] Add Welsh translations inline in controller
+ - Welsh translations for all en content
+- [x] Create `libs/email-subscriptions/src/pages/account/email-subscriptions/index.njk`
+ - Extend base template
+ - Show subscription count
+ - "Add subscription" button
+ - List of subscriptions with remove links
+ - Empty state conditional
+- [ ] Create `libs/email-subscriptions/src/assets/css/email-subscriptions.scss` (not needed - using GOV.UK components)
+- [ ] Update account home to link to subscriptions page
+
+### 3.2 Add Subscription Page
+- [x] Create `libs/email-subscriptions/src/pages/account/email-subscriptions/add/index.ts`
+- [x] Implement GET handler:
+ - Require auth with `requireAuth()` and `blockUserAccess()`
+ - Handle search functionality with query parameter
+ - Handle browse A-Z functionality
+ - Filter out already subscribed locations
+ - Render add subscription template
+- [x] Implement POST handler:
+ - Add selected location to session
+ - Redirect to confirm page
+- [x] Add English translations inline in controller
+ - Page title
+ - Search input label
+ - Browse A-Z options
+ - Results display
+- [x] Add Welsh translations inline in controller
+- [x] Create `libs/email-subscriptions/src/pages/account/email-subscriptions/add/index.njk`
+ - Extend base template
+ - Search form with input and button
+ - Browse A-Z alphabetical navigation
+ - Search results display
+ - Location cards with subscribe buttons
+
+### 3.3 Search Results Page
+- [x] Combined with Add Subscription Page (search results shown on same page)
+
+### 3.4 Confirm Subscription
+- [x] Create `libs/email-subscriptions/src/pages/account/email-subscriptions/confirm/index.ts`
+- [x] Implement GET handler:
+ - Get pending subscriptions from session
+ - Fetch location details for each
+ - Render confirmation template
+ - Handle empty state
+- [x] Implement POST handler:
+ - Handle remove action to remove from pending list
+ - Handle confirm action to create subscriptions
+ - Use `createMultipleSubscriptions` service
+ - Handle validation errors
+ - Redirect to confirmation success page
+ - Show errors if creation fails
+- [x] Add English translations inline in controller
+- [x] Add Welsh translations inline in controller
+- [x] Create `libs/email-subscriptions/src/pages/account/email-subscriptions/confirm/index.njk`
+ - Extend base template
+ - Show pending subscriptions with remove links
+ - Confirm button
+ - Cancel link
+
+### 3.5 Confirmation Success Page
+- [x] Create `libs/email-subscriptions/src/pages/account/email-subscriptions/confirmation/index.ts`
+- [x] Implement GET handler:
+ - Check confirmation completed flag in session
+ - Get confirmed locations from session
+ - Display success message
+ - Clear session data
+- [x] Add English translations inline in controller
+- [x] Add Welsh translations inline in controller
+- [x] Create `libs/email-subscriptions/src/pages/account/email-subscriptions/confirmation/index.njk`
+ - GOV.UK confirmation panel
+ - List of subscribed locations
+ - Links to dashboard and home
+
+### 3.6 Remove Subscription
+- [x] Implemented inline in dashboard POST handler (no separate page needed)
+
+### 3.7 Update Preferences
+- [ ] Add email frequency preferences (Phase 4 - email notifications)
+
+### 3.7 Unit Tests for Page Controllers
+- [ ] Test dashboard GET handler
+- [ ] Test add subscription GET handler
+- [ ] Test search GET handler with various queries
+- [ ] Test confirm POST handler - success case
+- [ ] Test confirm POST handler - validation errors
+- [ ] Test remove POST handler
+- [ ] Test preferences POST handler
+
+## Phase 4: Email Notification System
+
+### 4.1 Email Service
+- [ ] Create `libs/email-subscriptions/src/email/service.ts`
+- [ ] Implement `queueNotification(subscriptionId, artefactId)` function
+ - Create NotificationQueue record
+ - Set status to PENDING
+- [ ] Implement `processNotificationQueue()` function
+ - Query pending notifications
+ - Batch process (max 100 at a time)
+ - Send emails via GOV Notify
+ - Update status and log results
+ - Handle retries (max 3 attempts)
+- [ ] Implement `sendNotificationEmail(userId, subscription, artefact)` function
+ - Get user profile (email, locale)
+ - Build email content
+ - Send via GOV Notify API
+ - Create EmailLog record
+ - Return success/error
+- [ ] Implement `generateUnsubscribeToken(subscriptionId)` function
+ - Create time-limited secure token
+ - Store in Redis with 7 day expiry
+ - Return token
+- [ ] Implement error handling and retry logic
+
+### 4.2 Email Templates
+- [ ] Create email template for immediate notifications (English)
+- [ ] Create email template for immediate notifications (Welsh)
+- [ ] Create email template for daily digest (English)
+- [ ] Create email template for daily digest (Welsh)
+- [ ] Create email template for weekly digest (English)
+- [ ] Create email template for weekly digest (Welsh)
+- [ ] Include unsubscribe link in all templates
+- [ ] Test templates in GOV Notify dashboard
+
+### 4.3 Notification Trigger
+- [ ] Create `libs/email-subscriptions/src/notification/trigger.ts`
+- [ ] Implement `onPublicationCreated(artefact)` function
+ - Query subscriptions for artefact location
+ - Filter by emailFrequency (IMMEDIATE only for now)
+ - Queue notifications for each subscription
+- [ ] Hook into publication creation flow
+- [ ] Add appropriate logging
+
+### 4.4 Scheduled Jobs
+- [ ] Create `libs/email-subscriptions/src/jobs/process-notifications.ts`
+- [ ] Implement job to process notification queue
+- [ ] Create `libs/email-subscriptions/src/jobs/send-daily-digest.ts`
+- [ ] Implement job to send daily digest emails
+- [ ] Create `libs/email-subscriptions/src/jobs/send-weekly-digest.ts`
+- [ ] Implement job to send weekly digest emails
+- [ ] Configure cron schedules (5 min for queue, 8am for digests)
+
+### 4.5 Unsubscribe Flow
+- [ ] Create `libs/email-subscriptions/src/pages/unsubscribe/[token]/index.ts`
+- [ ] Implement GET handler:
+ - Validate unsubscribe token
+ - Get subscription from token
+ - Render confirmation page
+- [ ] Implement POST handler:
+ - Remove subscription
+ - Clear token from Redis
+ - Show success message
+- [ ] Create unsubscribe page templates and content
+
+### 4.6 Unit Tests for Email System
+- [ ] Test `queueNotification()` function
+- [ ] Test `processNotificationQueue()` - success case
+- [ ] Test `processNotificationQueue()` - retry logic
+- [ ] Test `sendNotificationEmail()` - success case
+- [ ] Test `sendNotificationEmail()` - failure handling
+- [ ] Test `generateUnsubscribeToken()` function
+- [ ] Test `onPublicationCreated()` trigger
+- [ ] Mock GOV Notify API responses
+
+## Phase 5: Integration and Testing
+
+### 5.1 Integration Testing
+- [ ] Test complete subscription flow end-to-end
+- [ ] Test notification trigger when publication created
+- [ ] Test email sending with GOV Notify sandbox
+- [ ] Test unsubscribe flow from email link
+- [ ] Test Welsh language throughout
+- [ ] Test error handling and recovery
+
+### 5.2 E2E Tests (Playwright)
+- [ ] Create `e2e-tests/tests/email-subscriptions.spec.ts`
+- [ ] Test: User can view subscriptions dashboard
+- [ ] Test: User can search for courts
+- [ ] Test: User can add subscription
+- [ ] Test: User cannot add duplicate subscription
+- [ ] Test: User can remove subscription
+- [ ] Test: User can update email frequency
+- [ ] Test: Dashboard shows empty state when no subscriptions
+- [ ] Test: Error shown when subscription limit reached
+- [ ] Test: All pages are accessible (Axe checks)
+- [ ] Test: Keyboard navigation works throughout
+- [ ] Test: Welsh language content displays correctly
+
+### 5.3 Accessibility Testing
+- [ ] Run Axe accessibility checker on all pages
+- [ ] Test keyboard-only navigation
+- [ ] Test with NVDA screen reader (Windows)
+- [ ] Test with VoiceOver (macOS)
+- [ ] Verify color contrast ratios
+- [ ] Verify focus indicators visible
+- [ ] Verify form labels properly associated
+- [ ] Verify error messages properly linked
+- [ ] Test with 200% text zoom
+
+### 5.4 Performance Testing
+- [ ] Load test subscriptions dashboard with 50 subscriptions
+- [ ] Load test search with large result sets
+- [ ] Measure notification queue processing time
+- [ ] Measure email sending latency
+- [ ] Verify database query performance with indexes
+
+### 5.5 Security Testing
+- [ ] Verify authentication required on all pages
+- [ ] Verify CSRF tokens on all forms
+- [ ] Verify users can only remove their own subscriptions
+- [ ] Verify unsubscribe tokens expire after 7 days
+- [ ] Verify rate limiting works
+- [ ] Verify SQL injection prevention
+- [ ] Verify no sensitive data in logs
+- [ ] Verify email addresses not exposed
+
+## Phase 6: Documentation and Deployment
+
+### 6.1 Documentation
+- [ ] Update README with email subscriptions feature
+- [ ] Document email notification setup
+- [ ] Document GOV Notify integration
+- [ ] Document scheduled job configuration
+- [ ] Add ADR (Architecture Decision Record) for email system design
+
+### 6.2 Configuration
+- [ ] Add GOV_NOTIFY_API_KEY environment variable
+- [ ] Add GOV_NOTIFY_TEMPLATE_IDS for all templates
+- [ ] Add SUBSCRIPTION_LIMIT environment variable
+- [ ] Add EMAIL_RATE_LIMIT configuration
+- [ ] Update properties volume configuration
+
+### 6.3 Monitoring Setup
+- [ ] Add Application Insights custom metrics for:
+ - Subscription additions/removals
+ - Email sends (success/failure)
+ - Notification queue depth
+ - Processing time
+- [ ] Set up alerts for email delivery failures
+- [ ] Set up alerts for queue processing delays
+- [ ] Set up dashboard for subscription metrics
+
+### 6.4 Deployment
+- [ ] Run database migrations in staging
+- [ ] Deploy to staging environment
+- [ ] Smoke test all functionality in staging
+- [ ] Verify email sending works in staging
+- [ ] Run full E2E test suite
+- [ ] Get sign-off from product owner
+- [ ] Deploy to production
+- [ ] Monitor for errors in first 24 hours
+
+## Success Criteria
+
+- [ ] All unit tests passing with >80% coverage
+- [ ] All E2E tests passing
+- [ ] All accessibility tests passing (WCAG 2.2 AA)
+- [ ] Welsh translations complete and tested
+- [ ] Performance targets met (page load < 2s, operations < 1s)
+- [ ] Email notifications sent within 15 minutes
+- [ ] Subscriptions persist across sessions
+- [ ] Rate limiting enforced
+- [ ] Monitoring and alerts configured
+- [ ] Documentation complete
+- [ ] Code reviewed and approved
+
+## Estimated Effort
+
+- Phase 1 (Infrastructure): 1-2 days
+- Phase 2 (Business Logic): 2-3 days
+- Phase 3 (Web Interface): 3-4 days
+- Phase 4 (Email System): 3-4 days
+- Phase 5 (Testing): 2-3 days
+- Phase 6 (Docs & Deploy): 1 day
+
+**Total: 12-17 days (2.5-3.5 weeks)**
+
+## Dependencies
+
+- `@hmcts/auth` module for authentication
+- `@hmcts/location` module for court data
+- `@hmcts/publication` module for publication data
+- GOV Notify API account and templates
+- Redis for rate limiting and token storage
+- PostgreSQL database with Prisma ORM
+
+## Risks and Mitigation
+
+1. **GOV Notify API Limits**
+ - Risk: May hit API rate limits with many users
+ - Mitigation: Implement batching and queue processing
+
+2. **Email Deliverability**
+ - Risk: Emails marked as spam
+ - Mitigation: Proper SPF/DKIM/DMARC configuration, GOV Notify handles this
+
+3. **Performance with Large Subscription Lists**
+ - Risk: Dashboard slow with many subscriptions
+ - Mitigation: Database indexes, pagination if needed
+
+4. **Notification Queue Backlog**
+ - Risk: Queue grows faster than processing
+ - Mitigation: Horizontal scaling of job processing, alerting
+
+5. **Duplicate Notifications**
+ - Risk: Users receive multiple emails for same publication
+ - Mitigation: Unique constraints, idempotent processing
diff --git a/e2e-tests/tests/account-home.spec.ts b/e2e-tests/tests/account-home.spec.ts
index 205ace99..0f3ad8cd 100644
--- a/e2e-tests/tests/account-home.spec.ts
+++ b/e2e-tests/tests/account-home.spec.ts
@@ -153,7 +153,7 @@ test.describe("Account Home Page", () => {
test("should have email subscriptions box as clickable link", async ({ page }) => {
await page.goto("/account-home");
const box = page.locator(".verified-tile").nth(2);
- await expect(box).toHaveAttribute("href", "/");
+ await expect(box).toHaveAttribute("href", "/subscription-management");
});
test("should display description paragraph", async ({ page }) => {
@@ -164,11 +164,11 @@ test.describe("Account Home Page", () => {
await expect(description).toHaveText("Get emails about hearings from different courts and tribunals and manage your subscriptions.");
});
- test("should navigate to home page when email subscriptions box is clicked", async ({ page }) => {
+ test("should navigate to subscription management when email subscriptions box is clicked", async ({ page }) => {
await page.goto("/account-home");
const box = page.locator(".verified-tile").nth(2);
await box.click();
- await expect(page).toHaveURL("/");
+ await expect(page).toHaveURL("/subscription-management");
});
});
@@ -401,7 +401,7 @@ test.describe("Account Home Page", () => {
const boxes = page.locator(".verified-tile");
await expect(boxes.nth(0)).toHaveAttribute("href", "/search");
await expect(boxes.nth(1)).toHaveAttribute("href", "/summary-of-publications?locationId=9");
- await expect(boxes.nth(2)).toHaveAttribute("href", "/");
+ await expect(boxes.nth(2)).toHaveAttribute("href", "/subscription-management");
});
});
diff --git a/e2e-tests/tests/email-subscriptions.spec.ts b/e2e-tests/tests/email-subscriptions.spec.ts
new file mode 100644
index 00000000..2a9e9200
--- /dev/null
+++ b/e2e-tests/tests/email-subscriptions.spec.ts
@@ -0,0 +1,630 @@
+import { expect, test } from "@playwright/test";
+import AxeBuilder from "@axe-core/playwright";
+import { loginWithCftIdam } from "../utils/cft-idam-helpers.js";
+
+test.describe("Email Subscriptions", () => {
+ // Authenticate before each test
+ test.beforeEach(async ({ page }) => {
+ // Navigate to sign-in page
+ await page.goto("/sign-in");
+
+ // Select HMCTS account option
+ const hmctsRadio = page.getByRole("radio", { name: /with a myhmcts account/i });
+ await hmctsRadio.check();
+
+ // Click continue
+ const continueButton = page.getByRole("button", { name: /continue/i });
+ await continueButton.click();
+
+ // Perform CFT IDAM login
+ await loginWithCftIdam(
+ page,
+ process.env.CFT_VALID_TEST_ACCOUNT!,
+ process.env.CFT_VALID_TEST_ACCOUNT_PASSWORD!
+ );
+
+ // Should be redirected to account-home after successful login
+ await expect(page).toHaveURL(/\/account-home/);
+ });
+
+ test.describe("Subscription Management Page", () => {
+ test("should load subscription management page", async ({ page }) => {
+ await page.goto("/subscription-management");
+
+ // Check page title
+ await expect(page).toHaveTitle(/Your email subscriptions/i);
+
+ // Check main heading
+ const heading = page.locator("h1");
+ await expect(heading).toBeVisible();
+ await expect(heading).toHaveText(/your email subscriptions/i);
+ });
+
+ test("should display navigation to subscription management", async ({ page }) => {
+ await page.goto("/account-home");
+
+ // Click email subscriptions tile
+ const emailSubsTile = page.locator(".verified-tile").nth(2);
+ await emailSubsTile.click();
+
+ // Should navigate to subscription management
+ await expect(page).toHaveURL("/subscription-management");
+ });
+
+ test("should display page heading", async ({ page }) => {
+ await page.goto("/subscription-management");
+
+ // Check main heading
+ const heading = page.locator("h1");
+ await expect(heading).toBeVisible();
+ await expect(heading).toHaveText(/your email subscriptions/i);
+ });
+
+ test("should display add subscription button", async ({ page }) => {
+ await page.goto("/subscription-management");
+
+ const addButton = page.getByRole("button", { name: /add email subscription/i });
+ await expect(addButton).toBeVisible();
+ });
+
+ test("should navigate to location search when add subscription clicked", async ({ page }) => {
+ await page.goto("/subscription-management");
+
+ const addButton = page.getByRole("button", { name: /add email subscription/i });
+ await addButton.click();
+
+ await expect(page).toHaveURL("/location-name-search");
+ });
+
+ test("should be accessible", async ({ page }) => {
+ await page.goto("/subscription-management");
+
+ const accessibilityScanResults = await new AxeBuilder({ page })
+ .disableRules(["region"])
+ .analyze();
+
+ expect(accessibilityScanResults.violations).toEqual([]);
+ });
+
+ test("should support Welsh language", async ({ page }) => {
+ await page.goto("/subscription-management?lng=cy");
+
+ const heading = page.locator("h1");
+ await expect(heading).toBeVisible();
+ await expect(heading).toHaveText(/eich tanysgrifiadau e-bost/i);
+ });
+ });
+
+ test.describe("Location Name Search Page", () => {
+ test("should load location search page", async ({ page }) => {
+ await page.goto("/location-name-search");
+
+ // Check main heading
+ const heading = page.locator("h1");
+ await expect(heading).toBeVisible();
+ await expect(heading).toHaveText(/subscribe by court or tribunal name/i);
+ });
+
+ test("should display filter options", async ({ page }) => {
+ await page.goto("/location-name-search");
+
+ // Check for jurisdiction filter
+ const jurisdictionLabel = page.getByText(/jurisdiction/i).first();
+ await expect(jurisdictionLabel).toBeVisible();
+
+ // Check for region filter
+ const regionLabel = page.getByText(/region/i).first();
+ await expect(regionLabel).toBeVisible();
+ });
+
+ test("should display location results", async ({ page }) => {
+ await page.goto("/location-name-search");
+
+ // Wait for page to load
+ await page.waitForLoadState("networkidle");
+
+ // Location results should be displayed (using a more general selector)
+ const locationCheckboxes = page.locator("input[type='checkbox']");
+ await expect(locationCheckboxes.first()).toBeVisible({ timeout: 10000 });
+ });
+
+ test("should allow selecting locations", async ({ page }) => {
+ await page.goto("/location-name-search");
+
+ // Find first checkbox in accordion
+ const firstCheckbox = page.locator("input[type='checkbox']").first();
+ await firstCheckbox.check();
+
+ await expect(firstCheckbox).toBeChecked();
+ });
+
+ test("should have continue button", async ({ page }) => {
+ await page.goto("/location-name-search");
+
+ const continueButton = page.getByRole("button", { name: /continue/i });
+ await expect(continueButton).toBeVisible();
+ });
+
+ test("should navigate back to subscription management", async ({ page }) => {
+ await page.goto("/subscription-management");
+ const addButton = page.getByRole("button", { name: /add email subscription/i });
+ await addButton.click();
+ await expect(page).toHaveURL("/location-name-search");
+
+ const backLink = page.locator(".govuk-back-link");
+ await backLink.click();
+
+ await expect(page).toHaveURL("/subscription-management");
+ });
+
+ test("should be accessible", async ({ page }) => {
+ await page.goto("/location-name-search");
+
+ const accessibilityScanResults = await new AxeBuilder({ page })
+ .disableRules(["region"])
+ .analyze();
+
+ expect(accessibilityScanResults.violations).toEqual([]);
+ });
+
+ test("should support Welsh language", async ({ page }) => {
+ await page.goto("/location-name-search?lng=cy");
+
+ const heading = page.locator("h1");
+ await expect(heading).toBeVisible();
+ await expect(heading).toHaveText(/tanysgrifio yn ôl enw llys neu dribiwnlys/i);
+ });
+ });
+
+ test.describe("Pending Subscriptions Page", () => {
+ test("should require at least one selected location", async ({ page }) => {
+ await page.goto("/location-name-search");
+
+ // Click continue without selecting any locations
+ const continueButton = page.getByRole("button", { name: /continue/i });
+ await continueButton.click();
+
+ // Should show error on pending subscriptions page
+ await expect(page).toHaveURL("/pending-subscriptions");
+
+ const errorSummary = page.locator(".govuk-error-summary");
+ await expect(errorSummary).toBeVisible();
+ });
+
+ test("should display selected locations", async ({ page }) => {
+ await page.goto("/location-name-search");
+
+ // Select a location
+ const firstCheckbox = page.locator("input[type='checkbox']").first();
+ await firstCheckbox.check();
+
+ // Continue to pending subscriptions
+ const continueButton = page.getByRole("button", { name: /continue/i });
+ await continueButton.click();
+
+ await expect(page).toHaveURL("/pending-subscriptions");
+
+ // Check heading
+ const heading = page.locator("h1");
+ await expect(heading).toBeVisible();
+ });
+
+ test("should have confirm and remove buttons", async ({ page }) => {
+ await page.goto("/location-name-search");
+ await page.waitForLoadState("networkidle");
+
+ // Select a location
+ const postForm = page.locator("form[method='post']");
+ const firstCheckbox = postForm.locator("input[type='checkbox']").first();
+ await firstCheckbox.waitFor({ state: "visible" });
+ await firstCheckbox.check();
+ await expect(firstCheckbox).toBeChecked();
+
+ // Click continue button within the POST form
+ const continueButton = postForm.getByRole("button", { name: /continue/i });
+ await continueButton.click();
+
+ await expect(page).toHaveURL("/pending-subscriptions");
+
+ // Should have confirm button
+ const confirmButton = page.getByRole("button", { name: /confirm/i });
+ await expect(confirmButton).toBeVisible();
+
+ // Should have remove buttons
+ const removeButtons = page.getByRole("button", { name: /remove/i });
+ await expect(removeButtons.first()).toBeVisible();
+ });
+
+ test("should navigate back to location search", async ({ page }) => {
+ await page.goto("/location-name-search");
+
+ const firstCheckbox = page.locator("input[type='checkbox']").first();
+ await firstCheckbox.check();
+
+ const continueButton = page.getByRole("button", { name: /continue/i });
+ await continueButton.click();
+
+ await expect(page).toHaveURL("/pending-subscriptions");
+
+ const backLink = page.getByRole("link", { name: /back/i });
+ await backLink.click();
+
+ await expect(page).toHaveURL("/location-name-search");
+ });
+
+ test("should be accessible", async ({ page }) => {
+ await page.goto("/location-name-search");
+
+ const firstCheckbox = page.locator("input[type='checkbox']").first();
+ await firstCheckbox.check();
+
+ const continueButton = page.getByRole("button", { name: /continue/i });
+ await continueButton.click();
+
+ const accessibilityScanResults = await new AxeBuilder({ page })
+ .disableRules(["region"])
+ .analyze();
+
+ expect(accessibilityScanResults.violations).toEqual([]);
+ });
+
+ test("should support Welsh language", async ({ page }) => {
+ await page.goto("/pending-subscriptions?lng=cy");
+
+ // Will show error for no selections, but should be in Welsh
+ const errorSummary = page.locator(".govuk-error-summary");
+ await expect(errorSummary).toBeVisible();
+ });
+ });
+
+ test.describe("Subscription Confirmed Page", () => {
+ test("should redirect if no confirmation in session", async ({ page }) => {
+ // Try to access confirmation page directly
+ await page.goto("/subscription-confirmed");
+
+ // Should redirect to subscription management
+ await expect(page).toHaveURL("/subscription-management");
+ });
+
+ test("should display success message after confirming subscriptions", async ({ page }) => {
+ await page.goto("/location-name-search");
+ await page.waitForLoadState("networkidle");
+
+ // Select a location
+ const postForm = page.locator("form[method='post']");
+ const firstCheckbox = postForm.locator("input[type='checkbox']").first();
+ await firstCheckbox.waitFor({ state: "visible" });
+ await firstCheckbox.check();
+ await expect(firstCheckbox).toBeChecked();
+
+ // Continue
+ const continueButton = postForm.getByRole("button", { name: /continue/i });
+ await continueButton.click();
+
+ // Confirm subscription
+ await expect(page).toHaveURL("/pending-subscriptions");
+ const confirmButton = page.getByRole("button", { name: /confirm/i });
+ await confirmButton.click();
+
+ // Should show confirmation page
+ await expect(page).toHaveURL("/subscription-confirmed", { timeout: 10000 });
+
+ // Check for success panel
+ const panel = page.locator(".govuk-panel--confirmation");
+ await expect(panel).toBeVisible();
+ });
+
+ test("should have link to manage subscriptions", async ({ page }) => {
+ await page.goto("/location-name-search");
+ await page.waitForLoadState("networkidle");
+
+ const postForm = page.locator("form[method='post']");
+ const firstCheckbox = postForm.locator("input[type='checkbox']").first();
+ await firstCheckbox.waitFor({ state: "visible" });
+ await firstCheckbox.check();
+ await expect(firstCheckbox).toBeChecked();
+
+ const continueButton = postForm.getByRole("button", { name: /continue/i });
+ await continueButton.click();
+
+ const confirmButton = page.getByRole("button", { name: /confirm/i });
+ await confirmButton.click();
+
+ await expect(page).toHaveURL("/subscription-confirmed", { timeout: 10000 });
+
+ const manageLink = page.getByRole("link", { name: /manage.*subscriptions/i });
+ await expect(manageLink).toBeVisible();
+ });
+
+ test("should be accessible", async ({ page }) => {
+ await page.goto("/location-name-search");
+ await page.waitForLoadState("networkidle");
+
+ const postForm = page.locator("form[method='post']");
+ const firstCheckbox = postForm.locator("input[type='checkbox']").first();
+ await firstCheckbox.waitFor({ state: "visible" });
+ await firstCheckbox.check();
+ await expect(firstCheckbox).toBeChecked();
+
+ const continueButton = postForm.getByRole("button", { name: /continue/i });
+ await continueButton.click();
+
+ const confirmButton = page.getByRole("button", { name: /confirm/i });
+ await confirmButton.click();
+
+ await expect(page).toHaveURL("/subscription-confirmed", { timeout: 10000 });
+
+ const accessibilityScanResults = await new AxeBuilder({ page })
+ .disableRules(["region"])
+ .analyze();
+
+ expect(accessibilityScanResults.violations).toEqual([]);
+ });
+ });
+
+ test.describe("Delete Subscription Page", () => {
+ test("should redirect for invalid subscription ID", async ({ page }) => {
+ await page.goto("/delete-subscription?subscriptionId=invalid-id");
+
+ // Should redirect or show error
+ await page.waitForTimeout(1000);
+
+ // Either shows 400 error or redirects to subscription management
+ const is400Error = await page.locator("text=400").isVisible().catch(() => false);
+ const isSubManagement = page.url().includes("/subscription-management");
+
+ expect(is400Error || isSubManagement).toBeTruthy();
+ });
+
+ test("should display confirmation question", async ({ page }) => {
+ // This test assumes there's at least one subscription to delete
+ // In a real test, you'd create a subscription first
+ await page.goto("/subscription-management");
+
+ // Check if there are any subscriptions to delete
+ const deleteLinks = page.getByRole("button", { name: /remove/i });
+ const count = await deleteLinks.count();
+
+ if (count > 0) {
+ // Click first delete link
+ await deleteLinks.first().click();
+
+ // Should be on delete subscription page
+ await expect(page).toHaveURL(/\/delete-subscription/);
+
+ // Check for radio buttons
+ const yesRadio = page.getByRole("radio", { name: /yes/i });
+ const noRadio = page.getByRole("radio", { name: /no/i });
+
+ await expect(yesRadio).toBeVisible();
+ await expect(noRadio).toBeVisible();
+ }
+ });
+
+ test("should require selection before continuing", async ({ page }) => {
+ await page.goto("/subscription-management");
+
+ const deleteLinks = page.getByRole("button", { name: /remove/i });
+ const count = await deleteLinks.count();
+
+ if (count > 0) {
+ await deleteLinks.first().click();
+
+ // Try to continue without selecting
+ const continueButton = page.getByRole("button", { name: /continue/i });
+ await continueButton.click();
+
+ // Should show error (inline error message, not error summary)
+ const errorMessage = page.getByText(/select yes if you want to remove this subscription/i);
+ await expect(errorMessage).toBeVisible();
+ }
+ });
+
+ test("should return to subscription management when selecting no", async ({ page }) => {
+ await page.goto("/subscription-management");
+
+ const deleteLinks = page.getByRole("button", { name: /remove/i });
+ const count = await deleteLinks.count();
+
+ if (count > 0) {
+ await deleteLinks.first().click();
+
+ // Select No
+ const noRadio = page.getByRole("radio", { name: /no/i });
+ await noRadio.check();
+
+ // Continue
+ const continueButton = page.getByRole("button", { name: /continue/i });
+ await continueButton.click();
+
+ // Should return to subscription management
+ await expect(page).toHaveURL("/subscription-management");
+ }
+ });
+
+ test("should support Welsh language", async ({ page }) => {
+ await page.goto("/subscription-management?lng=cy");
+
+ const deleteLinks = page.getByRole("button", { name: /dileu/i });
+ const count = await deleteLinks.count();
+
+ if (count > 0) {
+ await deleteLinks.first().click();
+
+ const heading = page.locator("h1");
+ await expect(heading).toBeVisible();
+ }
+ });
+ });
+
+ test.describe("Unsubscribe Confirmation Page", () => {
+ test("should redirect if no subscription to remove in session", async ({ page }) => {
+ await page.goto("/unsubscribe-confirmation");
+
+ // Should redirect to subscription management
+ await expect(page).toHaveURL("/subscription-management");
+ });
+
+ test("should display success message after removing subscription", async ({ page }) => {
+ await page.goto("/subscription-management");
+
+ const deleteLinks = page.getByRole("button", { name: /remove/i });
+ const count = await deleteLinks.count();
+
+ if (count > 0) {
+ await deleteLinks.first().click();
+
+ // Select Yes
+ const yesRadio = page.getByRole("radio", { name: /yes/i });
+ await yesRadio.check();
+
+ // Continue
+ const continueButton = page.getByRole("button", { name: /continue/i });
+ await continueButton.click();
+
+ // Should show unsubscribe confirmation
+ await expect(page).toHaveURL("/unsubscribe-confirmation");
+
+ // Check for success panel
+ const panel = page.locator(".govuk-panel--confirmation");
+ await expect(panel).toBeVisible();
+ }
+ });
+
+ test("should have link back to subscription management", async ({ page }) => {
+ await page.goto("/subscription-management");
+
+ const deleteLinks = page.getByRole("button", { name: /remove/i });
+ const count = await deleteLinks.count();
+
+ if (count > 0) {
+ await deleteLinks.first().click();
+
+ const yesRadio = page.getByRole("radio", { name: /yes/i });
+ await yesRadio.check();
+
+ const continueButton = page.getByRole("button", { name: /continue/i });
+ await continueButton.click();
+
+ // Should show unsubscribe confirmation page
+ await expect(page).toHaveURL("/unsubscribe-confirmation");
+
+ const manageLink = page.getByRole("link", { name: /manage.*subscriptions/i });
+ await expect(manageLink).toBeVisible();
+ }
+ });
+
+ test("should be accessible", async ({ page }) => {
+ await page.goto("/subscription-management");
+
+ const deleteLinks = page.getByRole("button", { name: /remove/i });
+ const count = await deleteLinks.count();
+
+ if (count > 0) {
+ await deleteLinks.first().click();
+
+ const yesRadio = page.getByRole("radio", { name: /yes/i });
+ await yesRadio.check();
+
+ const continueButton = page.getByRole("button", { name: /continue/i });
+ await continueButton.click();
+
+ const accessibilityScanResults = await new AxeBuilder({ page })
+ .disableRules(["region"])
+ .analyze();
+
+ expect(accessibilityScanResults.violations).toEqual([]);
+ }
+ });
+ });
+
+ test.describe("Authentication Protection", () => {
+ test("should require authentication for subscription management", async ({ page, context }) => {
+ // Create new context without authentication
+ await context.clearCookies();
+ await page.goto("/subscription-management");
+
+ // Should redirect to sign-in
+ await expect(page).toHaveURL(/\/sign-in/);
+ });
+
+ test("should require authentication for location search", async ({ page, context }) => {
+ await context.clearCookies();
+ await page.goto("/location-name-search");
+
+ await expect(page).toHaveURL(/\/sign-in/);
+ });
+
+ test("should require authentication for pending subscriptions", async ({ page, context }) => {
+ await context.clearCookies();
+ await page.goto("/pending-subscriptions");
+
+ await expect(page).toHaveURL(/\/sign-in/);
+ });
+
+ test("should require authentication for subscription confirmed", async ({ page, context }) => {
+ await context.clearCookies();
+ await page.goto("/subscription-confirmed");
+
+ await expect(page).toHaveURL(/\/sign-in/);
+ });
+
+ test("should require authentication for delete subscription", async ({ page, context }) => {
+ await context.clearCookies();
+ await page.goto("/delete-subscription?subscriptionId=550e8400-e29b-41d4-a716-446655440000");
+
+ await expect(page).toHaveURL(/\/sign-in/);
+ });
+
+ test("should require authentication for unsubscribe confirmation", async ({ page, context }) => {
+ await context.clearCookies();
+ await page.goto("/unsubscribe-confirmation");
+
+ await expect(page).toHaveURL(/\/sign-in/);
+ });
+ });
+
+ test.describe("Complete Subscription Flow", () => {
+ test("should complete full subscription journey", async ({ page }) => {
+ // Start from account home
+ await page.goto("/account-home");
+
+ // Navigate to subscription management
+ const emailSubsTile = page.locator(".verified-tile").nth(2);
+ await emailSubsTile.click();
+ await expect(page).toHaveURL("/subscription-management");
+
+ // Click add subscription
+ const addButton = page.getByRole("button", { name: /add email subscription/i });
+ await addButton.click();
+ await expect(page).toHaveURL("/location-name-search");
+
+ // Select a location
+ await page.waitForLoadState("networkidle");
+ const postForm = page.locator("form[method='post']");
+ const firstCheckbox = postForm.locator("input[type='checkbox']").first();
+ await firstCheckbox.waitFor({ state: "visible" });
+ await firstCheckbox.check();
+ await expect(firstCheckbox).toBeChecked();
+
+ // Continue to pending subscriptions
+ const continueButton = postForm.getByRole("button", { name: /continue/i });
+ await continueButton.click();
+ await expect(page).toHaveURL("/pending-subscriptions");
+
+ // Confirm subscription
+ const confirmButton = page.getByRole("button", { name: /confirm/i });
+ await confirmButton.click();
+ await expect(page).toHaveURL("/subscription-confirmed", { timeout: 10000 });
+
+ // Verify success
+ const panel = page.locator(".govuk-panel--confirmation");
+ await expect(panel).toBeVisible();
+
+ // Navigate back to subscription management
+ const manageLink = page.getByRole("link", { name: /manage.*subscriptions/i });
+ await manageLink.click();
+ await expect(page).toHaveURL("/subscription-management");
+ });
+ });
+});
diff --git a/libs/auth/src/middleware/navigation-helper.test.ts b/libs/auth/src/middleware/navigation-helper.test.ts
index 7fe50822..ca3f3220 100644
--- a/libs/auth/src/middleware/navigation-helper.test.ts
+++ b/libs/auth/src/middleware/navigation-helper.test.ts
@@ -99,7 +99,7 @@ describe("buildNavigationItems", () => {
expect(items[0].attributes?.["data-test"]).toBe("dashboard-link");
expect(items[1].text).toBe("Email subscriptions");
- expect(items[1].href).toBe("/");
+ expect(items[1].href).toBe("/subscription-management");
expect(items[1].current).toBe(false);
expect(items[1].attributes?.["data-test"]).toBe("email-subscriptions-link");
});
@@ -113,7 +113,7 @@ describe("buildNavigationItems", () => {
});
it("should mark the correct item as current based on path", () => {
- const items = buildVerifiedUserNavigation("/", "en");
+ const items = buildVerifiedUserNavigation("/subscription-management", "en");
expect(items[0].current).toBe(false);
expect(items[1].current).toBe(true);
diff --git a/libs/auth/src/middleware/navigation-helper.ts b/libs/auth/src/middleware/navigation-helper.ts
index cca95323..9ba52b20 100644
--- a/libs/auth/src/middleware/navigation-helper.ts
+++ b/libs/auth/src/middleware/navigation-helper.ts
@@ -89,8 +89,8 @@ export function buildVerifiedUserNavigation(currentPath: string, locale: string
},
{
text: t.emailSubscriptions,
- href: "/",
- current: currentPath === "/",
+ href: "/subscription-management",
+ current: currentPath === "/subscription-management",
attributes: {
"data-test": "email-subscriptions-link"
}
diff --git a/libs/auth/src/middleware/navigation.test.ts b/libs/auth/src/middleware/navigation.test.ts
index 8959083d..10fd4948 100644
--- a/libs/auth/src/middleware/navigation.test.ts
+++ b/libs/auth/src/middleware/navigation.test.ts
@@ -201,4 +201,97 @@ describe("authNavigationMiddleware", () => {
expect(res.locals.navigation.signOut).toBe("Sign out");
expect(res.locals.navigation.verifiedItems).toHaveLength(2);
});
+
+ it("should add navigation items for VERIFIED users (media users) in English", () => {
+ const middleware = authNavigationMiddleware();
+
+ const req = {
+ isAuthenticated: () => true,
+ user: { role: "VERIFIED" },
+ path: "/account-home"
+ } as Request;
+
+ const res = {
+ locals: {
+ locale: "en"
+ }
+ } as Response;
+
+ const next = vi.fn();
+
+ middleware(req, res, next);
+
+ expect(res.locals.navigation).toBeDefined();
+ expect(res.locals.navigation.verifiedItems).toHaveLength(2);
+ expect(res.locals.navigation.verifiedItems[0]).toEqual({
+ text: "Dashboard",
+ href: "/account-home",
+ current: true,
+ attributes: { "data-test": "dashboard-link" }
+ });
+ expect(res.locals.navigation.verifiedItems[1]).toEqual({
+ text: "Email subscriptions",
+ href: "/subscription-management",
+ current: false,
+ attributes: { "data-test": "email-subscriptions-link" }
+ });
+ });
+
+ it("should add navigation items for VERIFIED users (media users) in Welsh", () => {
+ const middleware = authNavigationMiddleware();
+
+ const req = {
+ isAuthenticated: () => true,
+ user: { role: "VERIFIED" },
+ path: "/subscription-management"
+ } as Request;
+
+ const res = {
+ locals: {
+ locale: "cy"
+ }
+ } as Response;
+
+ const next = vi.fn();
+
+ middleware(req, res, next);
+
+ expect(res.locals.navigation).toBeDefined();
+ expect(res.locals.navigation.verifiedItems).toHaveLength(2);
+ expect(res.locals.navigation.verifiedItems[0]).toEqual({
+ text: "Dangosfwrdd",
+ href: "/account-home",
+ current: false,
+ attributes: { "data-test": "dashboard-link" }
+ });
+ expect(res.locals.navigation.verifiedItems[1]).toEqual({
+ text: "Tanysgrifiadau e-bost",
+ href: "/subscription-management",
+ current: true,
+ attributes: { "data-test": "email-subscriptions-link" }
+ });
+ });
+
+ it("should default to English for VERIFIED users when locale is not set", () => {
+ const middleware = authNavigationMiddleware();
+
+ const req = {
+ isAuthenticated: () => true,
+ user: { role: "VERIFIED" },
+ path: "/account-home"
+ } as Request;
+
+ const res = {
+ locals: {}
+ } as Response;
+
+ const next = vi.fn();
+
+ middleware(req, res, next);
+
+ expect(res.locals.navigation).toBeDefined();
+ expect(res.locals.navigation.verifiedItems).toHaveLength(2);
+ expect(res.locals.navigation.verifiedItems[0].text).toBe("Dashboard");
+ expect(res.locals.navigation.verifiedItems[1].text).toBe("Email subscriptions");
+ });
});
diff --git a/libs/auth/src/middleware/navigation.ts b/libs/auth/src/middleware/navigation.ts
index 70d8eca4..577d0387 100644
--- a/libs/auth/src/middleware/navigation.ts
+++ b/libs/auth/src/middleware/navigation.ts
@@ -1,5 +1,5 @@
import type { NextFunction, Request, Response } from "express";
-import { buildNavigationItems } from "./navigation-helper.js";
+import { buildNavigationItems, buildVerifiedUserNavigation } from "./navigation-helper.js";
/**
* Middleware to set navigation state based on authentication status
@@ -15,9 +15,16 @@ export function authNavigationMiddleware() {
res.locals.navigation = {};
}
- // Add role-based navigation items for authenticated users with SSO role
+ // Add role-based navigation items for authenticated users
if (req.isAuthenticated() && req.user?.role) {
- res.locals.navigation.verifiedItems = buildNavigationItems(req.user.role, req.path);
+ // For VERIFIED users (media users), show Dashboard and Email subscriptions
+ if (req.user.role === "VERIFIED") {
+ const locale = res.locals.locale || "en";
+ res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale);
+ } else {
+ // For SSO admin roles, show admin navigation
+ res.locals.navigation.verifiedItems = buildNavigationItems(req.user.role, req.path);
+ }
} else {
// Clear navigation items when user is not authenticated or has no role
res.locals.navigation.verifiedItems = undefined;
diff --git a/libs/auth/src/pages/cft-callback/index.test.ts b/libs/auth/src/pages/cft-callback/index.test.ts
index f205a14b..b9c89c31 100644
--- a/libs/auth/src/pages/cft-callback/index.test.ts
+++ b/libs/auth/src/pages/cft-callback/index.test.ts
@@ -1,3 +1,4 @@
+import * as accountQuery from "@hmcts/account/repository/query";
import type { Request, Response } from "express";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as tokenClient from "../../cft-idam/token-client.js";
@@ -8,9 +9,7 @@ import { GET } from "./index.js";
vi.mock("../../cft-idam/token-client.js");
vi.mock("../../config/cft-idam-config.js");
vi.mock("../../role-service/index.js");
-vi.mock("@hmcts/account/repository/query", () => ({
- createOrUpdateUser: vi.fn()
-}));
+vi.mock("@hmcts/account/repository/query");
describe("CFT Login Return Handler", () => {
let mockReq: Partial;
@@ -43,6 +42,18 @@ describe("CFT Login Return Handler", () => {
authorizationEndpoint: "https://idam.example.com/o/authorize",
tokenEndpoint: "https://idam.example.com/o/token"
});
+
+ vi.mocked(accountQuery.createOrUpdateUser).mockResolvedValue({
+ userId: "user-123",
+ email: "test@example.com",
+ firstName: "Test",
+ surname: "User",
+ userProvenance: "CFT_IDAM",
+ userProvenanceId: "cft-id-123",
+ role: "VERIFIED",
+ createdDate: new Date(),
+ lastSignedInDate: null
+ });
});
it("should successfully authenticate valid user and redirect to account-home", async () => {
diff --git a/libs/auth/src/pages/cft-callback/index.ts b/libs/auth/src/pages/cft-callback/index.ts
index 18c822c2..ebbadb2b 100644
--- a/libs/auth/src/pages/cft-callback/index.ts
+++ b/libs/auth/src/pages/cft-callback/index.ts
@@ -26,25 +26,10 @@ export const GET = async (req: Request, res: Response) => {
return res.redirect(`/cft-rejected?lng=${lng}`);
}
- const user: UserProfile = {
- id: userInfo.id,
- email: userInfo.email,
- displayName: userInfo.displayName,
- role: "VERIFIED",
- provenance: "CFT"
- };
-
- console.log("CFT IDAM: Creating user session with:", {
- id: user.id,
- email: user.email,
- displayName: user.displayName,
- role: user.role,
- provenance: user.provenance
- });
-
// Create or update user record in database
+ let dbUser: Awaited>;
try {
- await createOrUpdateUser({
+ dbUser = await createOrUpdateUser({
email: userInfo.email,
firstName: userInfo.firstName,
surname: userInfo.surname,
@@ -61,6 +46,22 @@ export const GET = async (req: Request, res: Response) => {
return res.redirect(`/sign-in?error=db_error&lng=${lng}`);
}
+ const user: UserProfile = {
+ id: dbUser.userId,
+ email: userInfo.email,
+ displayName: userInfo.displayName,
+ role: "VERIFIED",
+ provenance: "CFT"
+ };
+
+ console.log("CFT IDAM: Creating user session with:", {
+ id: user.id,
+ email: user.email,
+ displayName: user.displayName,
+ role: user.role,
+ provenance: user.provenance
+ });
+
req.session.regenerate((err: Error | null) => {
if (err) {
console.error("CFT IDAM callback: Session regeneration failed", err);
diff --git a/libs/location/prisma/schema.prisma b/libs/location/prisma/schema.prisma
index c124361c..2f12e1e3 100644
--- a/libs/location/prisma/schema.prisma
+++ b/libs/location/prisma/schema.prisma
@@ -49,6 +49,7 @@ model Location {
locationRegions LocationRegion[]
locationSubJurisdictions LocationSubJurisdiction[]
+ subscriptions Subscription[]
@@map("location")
}
diff --git a/libs/subscriptions/package.json b/libs/subscriptions/package.json
new file mode 100644
index 00000000..65c65086
--- /dev/null
+++ b/libs/subscriptions/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@hmcts/subscriptions",
+ "version": "1.0.0",
+ "type": "module",
+ "exports": {
+ ".": {
+ "production": "./dist/index.js",
+ "default": "./src/index.ts"
+ },
+ "./config": {
+ "production": "./dist/config.js",
+ "default": "./src/config.ts"
+ }
+ },
+ "scripts": {
+ "build": "tsc",
+ "dev": "tsc --watch",
+ "test": "vitest run",
+ "test:watch": "vitest watch",
+ "format": "biome format --write .",
+ "lint": "biome check .",
+ "lint:fix": "biome check --write ."
+ },
+ "dependencies": {
+ "@hmcts/auth": "workspace:*",
+ "@hmcts/location": "workspace:*",
+ "@hmcts/postgres": "workspace:*"
+ },
+ "peerDependencies": {
+ "express": "^5.1.0"
+ }
+}
diff --git a/libs/subscriptions/prisma/schema.prisma b/libs/subscriptions/prisma/schema.prisma
new file mode 100644
index 00000000..c08051e3
--- /dev/null
+++ b/libs/subscriptions/prisma/schema.prisma
@@ -0,0 +1,24 @@
+generator client {
+ provider = "prisma-client-js"
+ output = "../../../node_modules/.prisma/client"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL")
+}
+
+model Subscription {
+ subscriptionId String @id @default(uuid()) @map("subscription_id") @db.Uuid
+ userId String @map("user_id") @db.Uuid
+ locationId Int @map("location_id") @db.Integer
+ dateAdded DateTime @default(now()) @map("date_added")
+
+ user User @relation(fields: [userId], references: [userId], onDelete: Cascade, onUpdate: Cascade)
+ location Location @relation(fields: [locationId], references: [locationId])
+
+ @@unique([userId, locationId], name: "unique_user_location")
+ @@index([userId], name: "idx_subscription_user")
+ @@index([locationId], name: "idx_subscription_location")
+ @@map("subscription")
+}
diff --git a/libs/subscriptions/src/config.ts b/libs/subscriptions/src/config.ts
new file mode 100644
index 00000000..eb6da966
--- /dev/null
+++ b/libs/subscriptions/src/config.ts
@@ -0,0 +1,7 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+export const prismaSchemas = path.join(__dirname, "../prisma");
diff --git a/libs/subscriptions/src/index.ts b/libs/subscriptions/src/index.ts
new file mode 100644
index 00000000..8797aab9
--- /dev/null
+++ b/libs/subscriptions/src/index.ts
@@ -0,0 +1,3 @@
+export * from "./repository/queries.js";
+export * from "./repository/service.js";
+export * from "./validation/validation.js";
diff --git a/libs/subscriptions/src/locales/cy.ts b/libs/subscriptions/src/locales/cy.ts
new file mode 100644
index 00000000..aa5997d0
--- /dev/null
+++ b/libs/subscriptions/src/locales/cy.ts
@@ -0,0 +1,7 @@
+export const cy = {
+ back: "Yn ôl",
+ continue: "Parhau",
+ cancel: "Canslo",
+ remove: "Dileu",
+ search: "Chwilio"
+};
diff --git a/libs/subscriptions/src/locales/en.ts b/libs/subscriptions/src/locales/en.ts
new file mode 100644
index 00000000..35d150ae
--- /dev/null
+++ b/libs/subscriptions/src/locales/en.ts
@@ -0,0 +1,7 @@
+export const en = {
+ back: "Back",
+ continue: "Continue",
+ cancel: "Cancel",
+ remove: "Remove",
+ search: "Search"
+};
diff --git a/libs/subscriptions/src/repository/queries.test.ts b/libs/subscriptions/src/repository/queries.test.ts
new file mode 100644
index 00000000..e5f557dd
--- /dev/null
+++ b/libs/subscriptions/src/repository/queries.test.ts
@@ -0,0 +1,218 @@
+import { prisma } from "@hmcts/postgres";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ countSubscriptionsByUserId,
+ createSubscriptionRecord,
+ deleteSubscriptionRecord,
+ findSubscriptionById,
+ findSubscriptionByUserAndLocation,
+ findSubscriptionsByUserId
+} from "./queries.js";
+
+vi.mock("@hmcts/postgres", () => ({
+ prisma: {
+ subscription: {
+ findMany: vi.fn(),
+ findUnique: vi.fn(),
+ count: vi.fn(),
+ create: vi.fn(),
+ delete: vi.fn()
+ }
+ }
+}));
+
+describe("Subscription Queries", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe("findSubscriptionsByUserId", () => {
+ it("should find all subscriptions for a user", async () => {
+ const userId = "user123";
+ const mockSubscriptions = [
+ {
+ subscriptionId: "sub1",
+ userId,
+ locationId: 456,
+ dateAdded: new Date()
+ },
+ {
+ subscriptionId: "sub2",
+ userId,
+ locationId: 789,
+ dateAdded: new Date()
+ }
+ ];
+
+ vi.mocked(prisma.subscription.findMany).mockResolvedValue(mockSubscriptions);
+
+ const result = await findSubscriptionsByUserId(userId);
+
+ expect(result).toEqual(mockSubscriptions);
+ expect(prisma.subscription.findMany).toHaveBeenCalledWith({
+ where: {
+ userId
+ },
+ orderBy: {
+ dateAdded: "desc"
+ }
+ });
+ });
+
+ it("should return empty array when no subscriptions found", async () => {
+ const userId = "user123";
+
+ vi.mocked(prisma.subscription.findMany).mockResolvedValue([]);
+
+ const result = await findSubscriptionsByUserId(userId);
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe("findSubscriptionByUserAndLocation", () => {
+ it("should find subscription by user and location", async () => {
+ const userId = "user123";
+ const locationId = 456;
+ const mockSubscription = {
+ subscriptionId: "sub1",
+ userId,
+ locationId,
+ dateAdded: new Date()
+ };
+
+ vi.mocked(prisma.subscription.findUnique).mockResolvedValue(mockSubscription);
+
+ const result = await findSubscriptionByUserAndLocation(userId, locationId);
+
+ expect(result).toEqual(mockSubscription);
+ expect(prisma.subscription.findUnique).toHaveBeenCalledWith({
+ where: {
+ unique_user_location: {
+ userId,
+ locationId
+ }
+ }
+ });
+ });
+
+ it("should return null when subscription not found", async () => {
+ const userId = "user123";
+ const locationId = 456;
+
+ vi.mocked(prisma.subscription.findUnique).mockResolvedValue(null);
+
+ const result = await findSubscriptionByUserAndLocation(userId, locationId);
+
+ expect(result).toBeNull();
+ });
+ });
+
+ describe("findSubscriptionById", () => {
+ it("should find subscription by ID", async () => {
+ const subscriptionId = "sub123";
+ const mockSubscription = {
+ subscriptionId,
+ userId: "user123",
+ locationId: 456,
+ dateAdded: new Date()
+ };
+
+ vi.mocked(prisma.subscription.findUnique).mockResolvedValue(mockSubscription);
+
+ const result = await findSubscriptionById(subscriptionId);
+
+ expect(result).toEqual(mockSubscription);
+ expect(prisma.subscription.findUnique).toHaveBeenCalledWith({
+ where: { subscriptionId }
+ });
+ });
+
+ it("should return null when subscription not found", async () => {
+ const subscriptionId = "sub123";
+
+ vi.mocked(prisma.subscription.findUnique).mockResolvedValue(null);
+
+ const result = await findSubscriptionById(subscriptionId);
+
+ expect(result).toBeNull();
+ });
+ });
+
+ describe("countSubscriptionsByUserId", () => {
+ it("should count subscriptions for a user", async () => {
+ const userId = "user123";
+
+ vi.mocked(prisma.subscription.count).mockResolvedValue(5);
+
+ const result = await countSubscriptionsByUserId(userId);
+
+ expect(result).toBe(5);
+ expect(prisma.subscription.count).toHaveBeenCalledWith({
+ where: {
+ userId
+ }
+ });
+ });
+
+ it("should return 0 when no subscriptions found", async () => {
+ const userId = "user123";
+
+ vi.mocked(prisma.subscription.count).mockResolvedValue(0);
+
+ const result = await countSubscriptionsByUserId(userId);
+
+ expect(result).toBe(0);
+ });
+ });
+
+ describe("createSubscriptionRecord", () => {
+ it("should create a new subscription", async () => {
+ const userId = "user123";
+ const locationId = 456;
+ const mockSubscription = {
+ subscriptionId: "sub1",
+ userId,
+ locationId,
+ dateAdded: new Date()
+ };
+
+ vi.mocked(prisma.subscription.create).mockResolvedValue(mockSubscription);
+
+ const result = await createSubscriptionRecord(userId, locationId);
+
+ expect(result).toEqual(mockSubscription);
+ expect(prisma.subscription.create).toHaveBeenCalledWith({
+ data: {
+ userId,
+ locationId
+ }
+ });
+ });
+ });
+
+ describe("deleteSubscriptionRecord", () => {
+ it("should delete a subscription", async () => {
+ const subscriptionId = "sub1";
+ const mockSubscription = {
+ subscriptionId,
+ userId: "user123",
+ locationId: 456,
+ dateAdded: new Date()
+ };
+
+ vi.mocked(prisma.subscription.delete).mockResolvedValue(mockSubscription);
+
+ const result = await deleteSubscriptionRecord(subscriptionId);
+
+ expect(result).toEqual(mockSubscription);
+ expect(prisma.subscription.delete).toHaveBeenCalledWith({
+ where: { subscriptionId }
+ });
+ });
+ });
+});
diff --git a/libs/subscriptions/src/repository/queries.ts b/libs/subscriptions/src/repository/queries.ts
new file mode 100644
index 00000000..570473cb
--- /dev/null
+++ b/libs/subscriptions/src/repository/queries.ts
@@ -0,0 +1,52 @@
+import { prisma } from "@hmcts/postgres";
+
+export async function findSubscriptionsByUserId(userId: string) {
+ return prisma.subscription.findMany({
+ where: {
+ userId
+ },
+ orderBy: {
+ dateAdded: "desc"
+ }
+ });
+}
+
+export async function findSubscriptionByUserAndLocation(userId: string, locationId: number) {
+ return prisma.subscription.findUnique({
+ where: {
+ unique_user_location: {
+ userId,
+ locationId
+ }
+ }
+ });
+}
+
+export async function countSubscriptionsByUserId(userId: string) {
+ return prisma.subscription.count({
+ where: {
+ userId
+ }
+ });
+}
+
+export async function createSubscriptionRecord(userId: string, locationId: number) {
+ return prisma.subscription.create({
+ data: {
+ userId,
+ locationId
+ }
+ });
+}
+
+export async function findSubscriptionById(subscriptionId: string) {
+ return prisma.subscription.findUnique({
+ where: { subscriptionId }
+ });
+}
+
+export async function deleteSubscriptionRecord(subscriptionId: string) {
+ return prisma.subscription.delete({
+ where: { subscriptionId }
+ });
+}
diff --git a/libs/subscriptions/src/repository/service.test.ts b/libs/subscriptions/src/repository/service.test.ts
new file mode 100644
index 00000000..9b1cc7f3
--- /dev/null
+++ b/libs/subscriptions/src/repository/service.test.ts
@@ -0,0 +1,293 @@
+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()
+ }
+ }
+}));
+
+vi.mock("./queries.js");
+vi.mock("../validation/validation.js");
+
+describe("Subscription Service", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe("createSubscription", () => {
+ const userId = "user123";
+ const locationId = "456";
+
+ it("should create a new subscription successfully", async () => {
+ vi.mocked(validation.validateLocationId).mockResolvedValue(true);
+ vi.mocked(queries.findSubscriptionByUserAndLocation).mockResolvedValue(null);
+ vi.mocked(queries.countSubscriptionsByUserId).mockResolvedValue(5);
+ vi.mocked(queries.createSubscriptionRecord).mockResolvedValue({
+ subscriptionId: "sub123",
+ userId,
+ locationId: 456,
+ dateAdded: new Date()
+ });
+
+ const result = await createSubscription(userId, locationId);
+
+ expect(result).toBeDefined();
+ expect(result.userId).toBe(userId);
+ expect(result.locationId).toBe(456);
+ expect(validation.validateLocationId).toHaveBeenCalledWith(locationId);
+ expect(queries.createSubscriptionRecord).toHaveBeenCalledWith(userId, 456);
+ });
+
+ it("should throw error if location is invalid", async () => {
+ vi.mocked(validation.validateLocationId).mockResolvedValue(false);
+
+ await expect(createSubscription(userId, locationId)).rejects.toThrow("Invalid location ID");
+ });
+
+ it("should throw error if already subscribed", async () => {
+ vi.mocked(validation.validateLocationId).mockResolvedValue(true);
+ vi.mocked(queries.findSubscriptionByUserAndLocation).mockResolvedValue({
+ subscriptionId: "sub123",
+ userId,
+ locationId: 456,
+ dateAdded: new Date()
+ });
+
+ await expect(createSubscription(userId, locationId)).rejects.toThrow("You are already subscribed to this court");
+ });
+
+ it("should throw error if subscription limit reached", async () => {
+ vi.mocked(validation.validateLocationId).mockResolvedValue(true);
+ vi.mocked(queries.findSubscriptionByUserAndLocation).mockResolvedValue(null);
+ vi.mocked(queries.countSubscriptionsByUserId).mockResolvedValue(50);
+
+ await expect(createSubscription(userId, locationId)).rejects.toThrow("Maximum 50 subscriptions allowed");
+ });
+ });
+
+ 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";
+
+ it("should remove subscription successfully", async () => {
+ const subscription = {
+ subscriptionId,
+ userId,
+ locationId: 456,
+ dateAdded: new Date()
+ };
+
+ vi.mocked(prisma.subscription.findUnique).mockResolvedValue(subscription);
+ vi.mocked(queries.deleteSubscriptionRecord).mockResolvedValue(subscription);
+
+ const result = await removeSubscription(subscriptionId, userId);
+
+ expect(result).toBeDefined();
+ expect(queries.deleteSubscriptionRecord).toHaveBeenCalledWith(subscriptionId);
+ });
+
+ it("should throw error if subscription not found", async () => {
+ vi.mocked(prisma.subscription.findUnique).mockResolvedValue(null);
+
+ await expect(removeSubscription(subscriptionId, userId)).rejects.toThrow("Subscription not found");
+ });
+
+ it("should throw error if user is not the owner", async () => {
+ const subscription = {
+ subscriptionId,
+ userId: "differentUser",
+ locationId: 456,
+ dateAdded: new Date()
+ };
+
+ vi.mocked(prisma.subscription.findUnique).mockResolvedValue(subscription);
+
+ await expect(removeSubscription(subscriptionId, userId)).rejects.toThrow("Unauthorized");
+ });
+ });
+
+ describe("createMultipleSubscriptions", () => {
+ it("should create multiple subscriptions successfully", async () => {
+ const userId = "user123";
+ const locationIds = ["456", "789"];
+
+ vi.mocked(validation.validateLocationId).mockResolvedValue(true);
+ vi.mocked(queries.findSubscriptionByUserAndLocation).mockResolvedValue(null);
+ vi.mocked(queries.countSubscriptionsByUserId).mockResolvedValue(5);
+ vi.mocked(queries.createSubscriptionRecord).mockResolvedValue({
+ subscriptionId: "sub123",
+ userId,
+ locationId: 456,
+ dateAdded: new Date()
+ });
+
+ const result = await createMultipleSubscriptions(userId, locationIds);
+
+ expect(result.succeeded).toBe(2);
+ expect(result.failed).toBe(0);
+ expect(result.errors).toHaveLength(0);
+ });
+
+ it("should handle partial failures", async () => {
+ const userId = "user123";
+ const locationIds = ["456", "789"];
+
+ vi.mocked(validation.validateLocationId).mockResolvedValueOnce(true).mockResolvedValueOnce(false);
+
+ vi.mocked(queries.findSubscriptionByUserAndLocation).mockResolvedValue(null);
+ vi.mocked(queries.countSubscriptionsByUserId).mockResolvedValue(5);
+ vi.mocked(queries.createSubscriptionRecord).mockResolvedValue({
+ subscriptionId: "sub123",
+ userId,
+ locationId: 456,
+ dateAdded: new Date()
+ });
+
+ const result = await createMultipleSubscriptions(userId, locationIds);
+
+ expect(result.succeeded).toBe(1);
+ expect(result.failed).toBe(1);
+ expect(result.errors).toHaveLength(1);
+ expect(result.errors[0]).toBe("Invalid location ID");
+ });
+ });
+
+ describe("replaceUserSubscriptions", () => {
+ const userId = "user123";
+
+ it("should replace subscriptions by adding new and removing old", async () => {
+ const existingSubscriptions = [
+ { subscriptionId: "sub1", userId, locationId: 456, dateAdded: new Date() },
+ { subscriptionId: "sub2", userId, locationId: 789, dateAdded: new Date() }
+ ];
+ const newLocationIds = ["789", "101"];
+
+ vi.mocked(queries.findSubscriptionsByUserId).mockResolvedValue(existingSubscriptions);
+ vi.mocked(validation.validateLocationId).mockResolvedValue(true);
+ vi.mocked(queries.deleteSubscriptionRecord).mockResolvedValue(existingSubscriptions[0]);
+ vi.mocked(queries.createSubscriptionRecord).mockResolvedValue({
+ subscriptionId: "sub3",
+ userId,
+ locationId: 101,
+ dateAdded: new Date()
+ });
+
+ const result = await replaceUserSubscriptions(userId, newLocationIds);
+
+ expect(result.added).toBe(1);
+ expect(result.removed).toBe(1);
+ expect(queries.deleteSubscriptionRecord).toHaveBeenCalledWith("sub1");
+ expect(queries.createSubscriptionRecord).toHaveBeenCalledWith(userId, 101);
+ });
+
+ it("should only add subscriptions when no existing ones", async () => {
+ const newLocationIds = ["456", "789"];
+
+ vi.mocked(queries.findSubscriptionsByUserId).mockResolvedValue([]);
+ vi.mocked(validation.validateLocationId).mockResolvedValue(true);
+ vi.mocked(queries.createSubscriptionRecord).mockResolvedValue({
+ subscriptionId: "sub1",
+ userId,
+ locationId: 456,
+ dateAdded: new Date()
+ });
+
+ const result = await replaceUserSubscriptions(userId, newLocationIds);
+
+ expect(result.added).toBe(2);
+ expect(result.removed).toBe(0);
+ expect(queries.deleteSubscriptionRecord).not.toHaveBeenCalled();
+ });
+
+ it("should only remove subscriptions when new list is empty", async () => {
+ const existingSubscriptions = [
+ { subscriptionId: "sub1", userId, locationId: 456, dateAdded: new Date() },
+ { subscriptionId: "sub2", userId, locationId: 789, dateAdded: new Date() }
+ ];
+
+ vi.mocked(queries.findSubscriptionsByUserId).mockResolvedValue(existingSubscriptions);
+ vi.mocked(queries.deleteSubscriptionRecord).mockResolvedValue(existingSubscriptions[0]);
+
+ const result = await replaceUserSubscriptions(userId, []);
+
+ expect(result.added).toBe(0);
+ expect(result.removed).toBe(2);
+ expect(queries.deleteSubscriptionRecord).toHaveBeenCalledTimes(2);
+ expect(queries.createSubscriptionRecord).not.toHaveBeenCalled();
+ });
+
+ it("should throw error when exceeding max subscriptions", async () => {
+ const existingSubscriptions = Array.from({ length: 48 }, (_, i) => ({
+ subscriptionId: `sub${i}`,
+ userId,
+ locationId: i,
+ dateAdded: new Date()
+ }));
+ // Keep all existing 48 and try to add 3 new ones = 51 total
+ const newLocationIds = [...Array.from({ length: 48 }, (_, i) => `${i}`), "456", "789", "101"];
+
+ vi.mocked(queries.findSubscriptionsByUserId).mockResolvedValue(existingSubscriptions);
+ vi.mocked(validation.validateLocationId).mockResolvedValue(true);
+
+ await expect(replaceUserSubscriptions(userId, newLocationIds)).rejects.toThrow("Maximum 50 subscriptions allowed");
+ });
+
+ it("should throw error for invalid location ID", async () => {
+ const newLocationIds = ["456", "invalid"];
+
+ vi.mocked(queries.findSubscriptionsByUserId).mockResolvedValue([]);
+ vi.mocked(validation.validateLocationId).mockResolvedValue(false);
+
+ await expect(replaceUserSubscriptions(userId, newLocationIds)).rejects.toThrow("Invalid location ID");
+ });
+
+ it("should keep same subscriptions when lists match", async () => {
+ const existingSubscriptions = [
+ { subscriptionId: "sub1", userId, locationId: 456, dateAdded: new Date() },
+ { subscriptionId: "sub2", userId, locationId: 789, dateAdded: new Date() }
+ ];
+ const newLocationIds = ["456", "789"];
+
+ vi.mocked(queries.findSubscriptionsByUserId).mockResolvedValue(existingSubscriptions);
+
+ const result = await replaceUserSubscriptions(userId, newLocationIds);
+
+ expect(result.added).toBe(0);
+ expect(result.removed).toBe(0);
+ expect(queries.deleteSubscriptionRecord).not.toHaveBeenCalled();
+ expect(queries.createSubscriptionRecord).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/libs/subscriptions/src/repository/service.ts b/libs/subscriptions/src/repository/service.ts
new file mode 100644
index 00000000..0296da90
--- /dev/null
+++ b/libs/subscriptions/src/repository/service.ts
@@ -0,0 +1,102 @@
+import { prisma } from "@hmcts/postgres";
+import { validateLocationId } from "../validation/validation.js";
+import {
+ countSubscriptionsByUserId,
+ createSubscriptionRecord,
+ deleteSubscriptionRecord,
+ findSubscriptionByUserAndLocation,
+ findSubscriptionsByUserId
+} from "./queries.js";
+
+const MAX_SUBSCRIPTIONS = 50;
+
+export async function createSubscription(userId: string, locationId: string) {
+ const locationValid = await validateLocationId(locationId);
+ if (!locationValid) {
+ throw new Error("Invalid location ID");
+ }
+
+ const locationIdNumber = Number.parseInt(locationId, 10);
+ const existing = await findSubscriptionByUserAndLocation(userId, locationIdNumber);
+ if (existing) {
+ throw new Error("You are already subscribed to this court");
+ }
+
+ const count = await countSubscriptionsByUserId(userId);
+ if (count >= MAX_SUBSCRIPTIONS) {
+ throw new Error(`Maximum ${MAX_SUBSCRIPTIONS} subscriptions allowed`);
+ }
+
+ return createSubscriptionRecord(userId, locationIdNumber);
+}
+
+export async function getSubscriptionsByUserId(userId: string) {
+ return findSubscriptionsByUserId(userId);
+}
+
+export async function removeSubscription(subscriptionId: string, userId: string) {
+ const subscription = await prisma.subscription.findUnique({
+ where: { subscriptionId }
+ });
+
+ if (!subscription) {
+ throw new Error("Subscription not found");
+ }
+
+ if (subscription.userId !== userId) {
+ throw new Error("Unauthorized");
+ }
+
+ return deleteSubscriptionRecord(subscriptionId);
+}
+
+export async function createMultipleSubscriptions(userId: string, locationIds: string[]) {
+ const results = await Promise.allSettled(locationIds.map((locationId) => createSubscription(userId, locationId)));
+
+ const succeeded = results.filter((r) => r.status === "fulfilled");
+ const failed = results.filter((r) => r.status === "rejected");
+
+ return {
+ succeeded: succeeded.length,
+ failed: failed.length,
+ errors: failed.map((r) => (r.status === "rejected" ? r.reason.message : "Unknown error"))
+ };
+}
+
+export async function replaceUserSubscriptions(userId: string, newLocationIds: string[]) {
+ const existingSubscriptions = await findSubscriptionsByUserId(userId);
+ const existingLocationIds = existingSubscriptions.map((sub) => sub.locationId);
+
+ const newLocationIdNumbers = newLocationIds.map((id) => Number.parseInt(id, 10));
+ const newLocationIdSet = new Set(newLocationIdNumbers);
+ const existingLocationIdSet = new Set(existingLocationIds);
+
+ const toDelete = existingSubscriptions.filter((sub) => !newLocationIdSet.has(sub.locationId));
+ const toAdd = newLocationIdNumbers.filter((locId) => !existingLocationIdSet.has(locId));
+
+ if (toAdd.length > 0) {
+ const currentCount = existingLocationIds.length - toDelete.length;
+ if (currentCount + toAdd.length > MAX_SUBSCRIPTIONS) {
+ throw new Error(`Maximum ${MAX_SUBSCRIPTIONS} subscriptions allowed`);
+ }
+ }
+
+ // Validate all location IDs before performing any mutations
+ const validationResults = await Promise.all(toAdd.map((locationId) => validateLocationId(locationId.toString())));
+
+ const invalidLocations = toAdd.filter((_, index) => !validationResults[index]);
+ if (invalidLocations.length > 0) {
+ throw new Error(`Invalid location ID: ${invalidLocations[0]}`);
+ }
+
+ // Perform deletions and creations after validation passes
+ await Promise.all([
+ ...toDelete.map((sub) => deleteSubscriptionRecord(sub.subscriptionId)),
+ ...toAdd.map((locationId) => createSubscriptionRecord(userId, locationId))
+ ]);
+
+ return {
+ added: toAdd.length,
+ removed: toDelete.length
+ };
+}
diff --git a/libs/subscriptions/src/validation/validation.test.ts b/libs/subscriptions/src/validation/validation.test.ts
new file mode 100644
index 00000000..b2cb88b5
--- /dev/null
+++ b/libs/subscriptions/src/validation/validation.test.ts
@@ -0,0 +1,69 @@
+import * as location from "@hmcts/location";
+import { describe, expect, it, vi } from "vitest";
+import * as queries from "../repository/queries.js";
+import { validateDuplicateSubscription, validateLocationId } from "./validation.js";
+
+vi.mock("@hmcts/location");
+vi.mock("../repository/queries.js");
+
+describe("Validation Functions", () => {
+ describe("validateLocationId", () => {
+ it("should return true for valid location ID", async () => {
+ vi.mocked(location.getLocationById).mockResolvedValue({
+ locationId: 456,
+ name: "Test Court",
+ welshName: "Llys Prawf",
+ region: "England"
+ });
+
+ const result = await validateLocationId("456");
+
+ expect(result).toBe(true);
+ expect(location.getLocationById).toHaveBeenCalledWith(456);
+ });
+
+ it("should return false for invalid location ID", async () => {
+ vi.mocked(location.getLocationById).mockResolvedValue(undefined);
+
+ const result = await validateLocationId("999");
+
+ expect(result).toBe(false);
+ expect(location.getLocationById).toHaveBeenCalledWith(999);
+ });
+
+ it("should return false for non-numeric location ID", async () => {
+ vi.mocked(location.getLocationById).mockResolvedValue(undefined);
+
+ const result = await validateLocationId("invalid");
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe("validateDuplicateSubscription", () => {
+ const userId = "user123";
+ const locationId = "456";
+
+ it("should return true if no existing subscription", async () => {
+ vi.mocked(queries.findSubscriptionByUserAndLocation).mockResolvedValue(null);
+
+ const result = await validateDuplicateSubscription(userId, locationId);
+
+ expect(result).toBe(true);
+ expect(queries.findSubscriptionByUserAndLocation).toHaveBeenCalledWith(userId, 456);
+ });
+
+ it("should return false if subscription exists", async () => {
+ vi.mocked(queries.findSubscriptionByUserAndLocation).mockResolvedValue({
+ subscriptionId: "sub123",
+ userId,
+ locationId: 456,
+ dateAdded: new Date()
+ });
+
+ const result = await validateDuplicateSubscription(userId, locationId);
+
+ expect(result).toBe(false);
+ });
+ });
+});
diff --git a/libs/subscriptions/src/validation/validation.ts b/libs/subscriptions/src/validation/validation.ts
new file mode 100644
index 00000000..69e13aa0
--- /dev/null
+++ b/libs/subscriptions/src/validation/validation.ts
@@ -0,0 +1,13 @@
+import { getLocationById } from "@hmcts/location";
+import { findSubscriptionByUserAndLocation } from "../repository/queries.js";
+
+export async function validateLocationId(locationId: string): Promise {
+ const location = await getLocationById(Number.parseInt(locationId, 10));
+ return location !== undefined;
+}
+
+export async function validateDuplicateSubscription(userId: string, locationId: string): Promise {
+ const locationIdNumber = Number.parseInt(locationId, 10);
+ const existing = await findSubscriptionByUserAndLocation(userId, locationIdNumber);
+ return !existing;
+}
diff --git a/libs/subscriptions/tsconfig.json b/libs/subscriptions/tsconfig.json
new file mode 100644
index 00000000..bc2be746
--- /dev/null
+++ b/libs/subscriptions/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "declaration": true,
+ "declarationMap": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules"]
+}
diff --git a/libs/verified-pages/package.json b/libs/verified-pages/package.json
index 399b3776..d68a91f2 100644
--- a/libs/verified-pages/package.json
+++ b/libs/verified-pages/package.json
@@ -25,6 +25,9 @@
"lint": "biome check .",
"lint:fix": "biome check --write ."
},
+ "dependencies": {
+ "@hmcts/subscriptions": "workspace:*"
+ },
"peerDependencies": {
"express": "^5.1.0"
}
diff --git a/libs/verified-pages/src/pages/account-home/index.njk b/libs/verified-pages/src/pages/account-home/index.njk
index 78580c18..e64ce878 100644
--- a/libs/verified-pages/src/pages/account-home/index.njk
+++ b/libs/verified-pages/src/pages/account-home/index.njk
@@ -28,7 +28,7 @@
-
+
{{ sections.emailSubscriptions.title }}
diff --git a/libs/verified-pages/src/pages/account-home/index.test.ts b/libs/verified-pages/src/pages/account-home/index.test.ts
index d36078f7..5158da34 100644
--- a/libs/verified-pages/src/pages/account-home/index.test.ts
+++ b/libs/verified-pages/src/pages/account-home/index.test.ts
@@ -11,7 +11,12 @@ vi.mock("@hmcts/auth", () => ({
const t = locale === "cy" ? translations.cy : translations.en;
return [
{ text: t.dashboard, href: "/account-home", current: currentPath === "/account-home", attributes: { "data-test": "dashboard-link" } },
- { text: t.emailSubscriptions, href: "/", current: currentPath === "/", attributes: { "data-test": "email-subscriptions-link" } }
+ {
+ text: t.emailSubscriptions,
+ href: "/subscription-management",
+ current: currentPath === "/subscription-management",
+ attributes: { "data-test": "email-subscriptions-link" }
+ }
];
}),
requireAuth: vi.fn(() => (_req: any, _res: any, next: any) => next()),
@@ -186,7 +191,7 @@ describe("account-home controller", () => {
const navigation = (mockResponse as any).locals.navigation;
expect(navigation.verifiedItems[0].href).toBe("/account-home");
- expect(navigation.verifiedItems[1].href).toBe("/");
+ expect(navigation.verifiedItems[1].href).toBe("/subscription-management");
});
it("should maintain same navigation structure for Welsh locale", async () => {
@@ -201,7 +206,7 @@ describe("account-home controller", () => {
expect(navigation.verifiedItems).toHaveLength(2);
expect(navigation.verifiedItems[0].href).toBe("/account-home");
- expect(navigation.verifiedItems[1].href).toBe("/");
+ expect(navigation.verifiedItems[1].href).toBe("/subscription-management");
expect(navigation.verifiedItems[0].current).toBe(true);
expect(navigation.verifiedItems[1].current).toBe(false);
});
diff --git a/libs/verified-pages/src/pages/delete-subscription/cy.ts b/libs/verified-pages/src/pages/delete-subscription/cy.ts
new file mode 100644
index 00000000..d7ad34c3
--- /dev/null
+++ b/libs/verified-pages/src/pages/delete-subscription/cy.ts
@@ -0,0 +1,9 @@
+export const cy = {
+ title: "Dileu tanysgrifiad",
+ header: "Ydych chi'n siŵr eich bod am ddileu'r tanysgrifiad hwn?",
+ radio1: "Ydw",
+ radio2: "Nac ydw",
+ continueButton: "Parhau",
+ errorSummaryTitle: "Mae problem wedi codi",
+ errorNoSelection: "Dewiswch 'ydw' os ydych chi eisiau dileu'r tanysgrifiad hwn"
+};
diff --git a/libs/verified-pages/src/pages/delete-subscription/en.ts b/libs/verified-pages/src/pages/delete-subscription/en.ts
new file mode 100644
index 00000000..e61771d3
--- /dev/null
+++ b/libs/verified-pages/src/pages/delete-subscription/en.ts
@@ -0,0 +1,9 @@
+export const en = {
+ title: "Remove subscription",
+ header: "Are you sure you want to remove this subscription?",
+ radio1: "Yes",
+ radio2: "No",
+ continueButton: "Continue",
+ errorSummaryTitle: "There is a problem",
+ errorNoSelection: "Select yes if you want to remove this subscription"
+};
diff --git a/libs/verified-pages/src/pages/delete-subscription/index.njk b/libs/verified-pages/src/pages/delete-subscription/index.njk
new file mode 100644
index 00000000..18a63c90
--- /dev/null
+++ b/libs/verified-pages/src/pages/delete-subscription/index.njk
@@ -0,0 +1,86 @@
+{% extends "layouts/base-template.njk" %}
+{% from "govuk/components/radios/macro.njk" import govukRadios %}
+{% from "govuk/components/input/macro.njk" import govukInput %}
+{% from "govuk/components/button/macro.njk" import govukButton %}
+{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %}
+
+{% block pageTitle %}
+ {% if errors %}
+ There is a problem - {{ title }} - {{ serviceName }} - {{ govUk }}
+ {% else %}
+ {{ title }} - {{ serviceName }} - {{ govUk }}
+ {% endif %}
+{% endblock %}
+
+{% block page_content %}
+
+
+ {% if errors %}
+ {{ govukErrorSummary({
+ titleText: errors.titleText,
+ errorList: errors.errorList
+ }) }}
+ {% endif %}
+
+
{{ header }}
+
+
+{% endblock %}
+
+{% block bodyEnd %}
+ {{ super() }}
+
+{% endblock %}
diff --git a/libs/verified-pages/src/pages/delete-subscription/index.test.ts b/libs/verified-pages/src/pages/delete-subscription/index.test.ts
new file mode 100644
index 00000000..3b2a4b63
--- /dev/null
+++ b/libs/verified-pages/src/pages/delete-subscription/index.test.ts
@@ -0,0 +1,165 @@
+import * as queries 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", () => ({
+ findSubscriptionById: vi.fn()
+}));
+
+describe("delete-subscription", () => {
+ let mockReq: Partial;
+ let mockRes: Partial;
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+
+ beforeEach(() => {
+ mockReq = {
+ query: {},
+ body: {},
+ session: {} as any,
+ user: { id: "user123" } as any
+ };
+ mockRes = {
+ render: vi.fn(),
+ redirect: vi.fn(),
+ status: vi.fn().mockReturnThis(),
+ send: vi.fn(),
+ locals: {}
+ };
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("GET", () => {
+ it("should redirect if no subscriptionId provided", async () => {
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management");
+ });
+
+ it("should redirect if subscriptionId is not a valid UUID", async () => {
+ mockReq.query = { subscriptionId: "invalid-uuid" };
+
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management");
+ });
+
+ it("should redirect if subscription not found", 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()
+ });
+
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ 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({
+ subscriptionId: "550e8400-e29b-41d4-a716-446655440000",
+ userId: "user123",
+ locationId: "456",
+ dateAdded: new Date()
+ });
+
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.render).toHaveBeenCalledWith(
+ "delete-subscription/index",
+ expect.objectContaining({
+ subscriptionId: "550e8400-e29b-41d4-a716-446655440000"
+ })
+ );
+ });
+
+ it("should redirect on database error", async () => {
+ mockReq.query = { subscriptionId: "550e8400-e29b-41d4-a716-446655440000" };
+ vi.mocked(queries.findSubscriptionById).mockRejectedValue(new Error("DB error"));
+
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith("Error fetching subscription:", expect.any(Error));
+ expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management");
+ });
+ });
+
+ describe("POST", () => {
+ 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({
+ subscriptionId: validSubscriptionId,
+ userId: "user123",
+ locationId: "456",
+ dateAdded: new Date()
+ });
+
+ await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.redirect).toHaveBeenCalledWith(`/delete-subscription?subscriptionId=${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({
+ subscriptionId: validSubscriptionId,
+ userId: "user123",
+ locationId: "456",
+ dateAdded: new Date()
+ });
+
+ await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management");
+ });
+
+ it("should store subscription in session and redirect if user selects yes", async () => {
+ const validSubscriptionId = "550e8400-e29b-41d4-a716-446655440000";
+ mockReq.body = { subscription: validSubscriptionId, "unsubscribe-confirm": "yes" };
+ mockReq.session = {} as any;
+ vi.mocked(queries.findSubscriptionById).mockResolvedValue({
+ subscriptionId: validSubscriptionId,
+ userId: "user123",
+ locationId: "456",
+ dateAdded: new Date()
+ });
+
+ await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockReq.session.emailSubscriptions?.subscriptionToRemove).toBe(validSubscriptionId);
+ expect(mockRes.redirect).toHaveBeenCalledWith("/unsubscribe-confirmation");
+ });
+
+ it("should redirect to subscription-management if no subscription provided", async () => {
+ mockReq.body = { "unsubscribe-confirm": "yes" };
+
+ await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management");
+ });
+ });
+});
diff --git a/libs/verified-pages/src/pages/delete-subscription/index.ts b/libs/verified-pages/src/pages/delete-subscription/index.ts
new file mode 100644
index 00000000..865363bb
--- /dev/null
+++ b/libs/verified-pages/src/pages/delete-subscription/index.ts
@@ -0,0 +1,135 @@
+import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth";
+import { findSubscriptionById } from "@hmcts/subscriptions";
+import type { Request, RequestHandler, Response } from "express";
+import { cy } from "./cy.js";
+import { en } from "./en.js";
+
+const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+function isValidUUID(value: string): boolean {
+ return UUID_REGEX.test(value);
+}
+
+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 rawSubscriptionId = req.query.subscriptionId as string;
+ const subscriptionId = rawSubscriptionId?.trim();
+
+ if (!subscriptionId) {
+ return res.redirect("/subscription-management");
+ }
+
+ if (!isValidUUID(subscriptionId)) {
+ return res.redirect("/subscription-management");
+ }
+
+ const userId = req.user.id;
+
+ try {
+ const subscription = await findSubscriptionById(subscriptionId);
+
+ if (!subscription) {
+ return res.redirect("/subscription-management");
+ }
+
+ if (subscription.userId !== userId) {
+ return res.redirect("/subscription-management");
+ }
+
+ if (!res.locals.navigation) {
+ res.locals.navigation = {};
+ }
+ res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale);
+
+ res.render("delete-subscription/index", {
+ ...t,
+ subscriptionId,
+ csrfToken: (req as any).csrfToken?.() || ""
+ });
+ } catch (error) {
+ console.error("Error fetching subscription:", error);
+ return res.redirect("/subscription-management");
+ }
+};
+
+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 { subscription, subscriptionId: bodySubscriptionId, "unsubscribe-confirm": unsubscribeConfirm } = req.body;
+ const subscriptionId = subscription || bodySubscriptionId;
+
+ // If no subscriptionId in body, redirect to subscription management
+ if (!subscriptionId) {
+ return res.redirect("/subscription-management");
+ }
+
+ // Validate UUID format
+ if (!isValidUUID(subscriptionId)) {
+ return res.redirect("/subscription-management");
+ }
+
+ const userId = req.user.id;
+
+ // Verify user owns the subscription
+ try {
+ const sub = await findSubscriptionById(subscriptionId);
+ if (!sub || sub.userId !== userId) {
+ return res.redirect("/subscription-management");
+ }
+ } catch (error) {
+ console.error("Error validating subscription ownership:", error);
+ return res.redirect("/subscription-management");
+ }
+
+ // If this is a direct POST from subscription-management (no confirmation yet)
+ if (!unsubscribeConfirm) {
+ // Redirect to GET to show confirmation page
+ return res.redirect(`/delete-subscription?subscriptionId=${subscriptionId}`);
+ }
+
+ // Handle confirmation page submission
+ if (unsubscribeConfirm === "no") {
+ return res.redirect("/subscription-management");
+ }
+
+ if (unsubscribeConfirm === "yes") {
+ req.session.emailSubscriptions = req.session.emailSubscriptions || {};
+ req.session.emailSubscriptions.subscriptionToRemove = subscriptionId;
+ return res.redirect("/unsubscribe-confirmation");
+ }
+
+ // If we get here with an invalid choice, show error
+ if (!res.locals.navigation) {
+ res.locals.navigation = {};
+ }
+ res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale);
+
+ return res.render("delete-subscription/index", {
+ ...t,
+ subscriptionId,
+ csrfToken: (req as any).csrfToken?.() || "",
+ errors: {
+ titleText: t.errorSummaryTitle,
+ errorList: [
+ {
+ text: t.errorNoSelection,
+ href: "#unsubscribe-confirm"
+ }
+ ]
+ }
+ });
+};
+
+export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler];
+export const POST: RequestHandler[] = [requireAuth(), blockUserAccess(), postHandler];
diff --git a/libs/verified-pages/src/pages/location-name-search/cy.ts b/libs/verified-pages/src/pages/location-name-search/cy.ts
new file mode 100644
index 00000000..aecff22b
--- /dev/null
+++ b/libs/verified-pages/src/pages/location-name-search/cy.ts
@@ -0,0 +1,24 @@
+export const cy = {
+ title: "Tanysgrifio yn ôl enw llys neu dribiwnlys",
+ heading: "Tanysgrifio yn ôl enw llys neu dribiwnlys",
+ description: "Tanysgrifiwch i dderbyn rhestr gwrandawiadau yn ôl llys neu dribiwnlys",
+ continueButton: "Parhau",
+ back: "Yn ôl",
+ backToTop: "Yn ôl i'r brig",
+ totalSelected: "Cyfanswm a ddewiswyd",
+ selected: "wedi'u dewis",
+ filterHeading: "Hidlo",
+ selectedFiltersHeading: "Hidlyddion a ddewiswyd",
+ clearFilters: "Clirio hidlyddion",
+ applyFilters: "Cymhwyso hidlyddion",
+ hideFilters: "Cuddio hidlyddion",
+ showFilters: "Dangos hidlyddion",
+ jurisdictionHeading: "Awdurdodaeth",
+ regionHeading: "Rhanbarth",
+ subJurisdictionLabels: {
+ 1: "Math o lys",
+ 2: "Math o dribiwnlys",
+ 3: "Math o leoliad",
+ 4: "Math o faes gwasanaeth"
+ }
+};
diff --git a/libs/verified-pages/src/pages/location-name-search/en.ts b/libs/verified-pages/src/pages/location-name-search/en.ts
new file mode 100644
index 00000000..3f7140fa
--- /dev/null
+++ b/libs/verified-pages/src/pages/location-name-search/en.ts
@@ -0,0 +1,24 @@
+export const en = {
+ title: "Subscribe by court or tribunal name",
+ heading: "Subscribe by court or tribunal name",
+ description: "Subscribe to receive hearings list by court or tribunal",
+ continueButton: "Continue",
+ back: "Back",
+ backToTop: "Back to top",
+ totalSelected: "Total selected",
+ selected: "selected",
+ filterHeading: "Filter",
+ selectedFiltersHeading: "Selected filters",
+ clearFilters: "Clear filters",
+ applyFilters: "Apply filters",
+ hideFilters: "Hide filters",
+ showFilters: "Show filters",
+ jurisdictionHeading: "Jurisdiction",
+ regionHeading: "Region",
+ subJurisdictionLabels: {
+ 1: "Court type",
+ 2: "Tribunal type",
+ 3: "Venue type",
+ 4: "Service area type"
+ }
+};
diff --git a/libs/verified-pages/src/pages/location-name-search/index.njk b/libs/verified-pages/src/pages/location-name-search/index.njk
new file mode 100644
index 00000000..e6ff468e
--- /dev/null
+++ b/libs/verified-pages/src/pages/location-name-search/index.njk
@@ -0,0 +1,200 @@
+{% extends "layouts/base-template.njk" %}
+{% from "govuk/components/button/macro.njk" import govukButton %}
+{% from "govuk/components/checkboxes/macro.njk" import govukCheckboxes %}
+{% from "govuk/components/back-link/macro.njk" import govukBackLink %}
+
+{% block pageTitle %}
+ {{ title }} - {{ serviceName }} - {{ govUk }}
+{% endblock %}
+
+{% block beforeContent %}
+ {{ govukBackLink({
+ text: back,
+ href: "/subscription-management"
+ }) }}
+{% endblock %}
+
+{% block page_content %}
+
+
+
{{ heading }}
+
{{ description }}
+
+
+
+
+
+
+
+ {# Filter Panel Title #}
+
+
{{ filterHeading }}
+
+
+ {# Selected Filters Section #}
+
+
{{ selectedFiltersHeading }}
+
{{ clearFilters }}
+ {% if selectedJurisdictions.length > 0 or selectedRegions.length > 0 or selectedSubJurisdictions.length > 0 %}
+
+ {% endif %}
+
+
+ {# Filter Controls Section #}
+
+
+
+
+
+
+ {# Mobile filter toggle button #}
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/libs/verified-pages/src/pages/location-name-search/index.test.ts b/libs/verified-pages/src/pages/location-name-search/index.test.ts
new file mode 100644
index 00000000..4b89e167
--- /dev/null
+++ b/libs/verified-pages/src/pages/location-name-search/index.test.ts
@@ -0,0 +1,115 @@
+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/location", () => ({
+ buildJurisdictionItems: vi.fn(() => []),
+ buildRegionItems: vi.fn(() => []),
+ buildSubJurisdictionItemsByJurisdiction: vi.fn(() => ({})),
+ getAllJurisdictions: vi.fn(() => []),
+ getAllRegions: vi.fn(() => []),
+ getAllSubJurisdictions: vi.fn(() => []),
+ getLocationsGroupedByLetter: vi.fn(() => ({ A: [] })),
+ getSubJurisdictionsForJurisdiction: vi.fn(() => [])
+}));
+
+describe("location-name-search", () => {
+ let mockReq: Partial;
+ let mockRes: Partial;
+
+ beforeEach(() => {
+ mockReq = {
+ query: {},
+ body: {},
+ session: {
+ save: vi.fn((callback: (err: Error | null) => void) => callback(null))
+ } as any,
+ path: "/location-name-search"
+ };
+ mockRes = {
+ render: vi.fn(),
+ redirect: vi.fn(),
+ locals: {}
+ };
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("GET", () => {
+ it("should render page with default filters", async () => {
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.render).toHaveBeenCalledWith("location-name-search/index", expect.any(Object));
+ });
+
+ it("should handle jurisdiction filter", async () => {
+ mockReq.query = { jurisdiction: "1" };
+
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.render).toHaveBeenCalled();
+ });
+
+ it("should handle region filter", async () => {
+ mockReq.query = { region: "2" };
+
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.render).toHaveBeenCalled();
+ });
+
+ it("should handle multiple filters", async () => {
+ mockReq.query = { jurisdiction: "1", region: "2", subJurisdiction: "3" };
+
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.render).toHaveBeenCalled();
+ });
+ });
+
+ describe("POST", () => {
+ it("should handle single location selection", async () => {
+ mockReq.body = { locationIds: "123" };
+ mockReq.session = {
+ save: vi.fn((callback: (err: Error | null) => void) => callback(null))
+ } as any;
+
+ await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockReq.session.emailSubscriptions?.pendingSubscriptions).toEqual(["123"]);
+ expect(mockRes.redirect).toHaveBeenCalledWith("/pending-subscriptions");
+ });
+
+ it("should handle multiple location selections", async () => {
+ mockReq.body = { locationIds: ["123", "456"] };
+ mockReq.session = {
+ save: vi.fn((callback: (err: Error | null) => void) => callback(null))
+ } as any;
+
+ await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockReq.session.emailSubscriptions?.pendingSubscriptions).toEqual(["123", "456"]);
+ expect(mockRes.redirect).toHaveBeenCalledWith("/pending-subscriptions");
+ });
+
+ it("should handle no selection", async () => {
+ mockReq.body = {};
+ mockReq.session = {
+ save: vi.fn((callback: (err: Error | null) => void) => callback(null))
+ } as any;
+
+ await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockReq.session.emailSubscriptions?.pendingSubscriptions).toEqual([]);
+ expect(mockRes.redirect).toHaveBeenCalledWith("/pending-subscriptions");
+ });
+ });
+});
diff --git a/libs/verified-pages/src/pages/location-name-search/index.ts b/libs/verified-pages/src/pages/location-name-search/index.ts
new file mode 100644
index 00000000..78dd2aa9
--- /dev/null
+++ b/libs/verified-pages/src/pages/location-name-search/index.ts
@@ -0,0 +1,221 @@
+import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth";
+import {
+ buildJurisdictionItems,
+ buildRegionItems,
+ buildSubJurisdictionItemsByJurisdiction,
+ getAllJurisdictions,
+ getAllRegions,
+ getAllSubJurisdictions,
+ getLocationsGroupedByLetter,
+ getSubJurisdictionsForJurisdiction,
+ type Location
+} from "@hmcts/location";
+import type { Request, RequestHandler, Response } from "express";
+import { cy } from "./cy.js";
+import { en } from "./en.js";
+
+interface TableRow {
+ letter: string;
+ location: Location;
+ isFirst: boolean;
+}
+
+const getHandler = async (req: Request, res: Response) => {
+ const locale = res.locals.locale || "en";
+ const content = locale === "cy" ? cy : en;
+
+ const jurisdictionParam = req.query.jurisdiction;
+ const regionParam = req.query.region;
+ const subJurisdictionParam = req.query.subJurisdiction;
+
+ const selectedJurisdictions = Array.isArray(jurisdictionParam)
+ ? jurisdictionParam.map(Number).filter(Number.isFinite)
+ : jurisdictionParam && Number.isFinite(Number(jurisdictionParam))
+ ? [Number(jurisdictionParam)]
+ : [];
+
+ const selectedRegions = Array.isArray(regionParam)
+ ? regionParam.map(Number).filter(Number.isFinite)
+ : regionParam && Number.isFinite(Number(regionParam))
+ ? [Number(regionParam)]
+ : [];
+
+ const selectedSubJurisdictions = Array.isArray(subJurisdictionParam)
+ ? subJurisdictionParam.map(Number).filter(Number.isFinite)
+ : subJurisdictionParam && Number.isFinite(Number(subJurisdictionParam))
+ ? [Number(subJurisdictionParam)]
+ : [];
+
+ const allJurisdictions = await getAllJurisdictions();
+ const allRegions = await getAllRegions();
+ const allSubJurisdictions = await getAllSubJurisdictions();
+
+ // If jurisdictions are selected but no sub-jurisdictions, include all sub-jurisdictions from selected jurisdictions
+ const effectiveSubJurisdictions = [...selectedSubJurisdictions];
+ if (selectedJurisdictions.length > 0 && selectedSubJurisdictions.length === 0) {
+ for (const jurisdictionId of selectedJurisdictions) {
+ const subJuris = await getSubJurisdictionsForJurisdiction(jurisdictionId);
+ effectiveSubJurisdictions.push(...subJuris);
+ }
+ }
+
+ if (!res.locals.navigation) {
+ res.locals.navigation = {};
+ }
+ res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale);
+
+ // Recalculate grouped locations with effective filters
+ const filteredGroupedLocations: Record = await getLocationsGroupedByLetter(locale, {
+ regions: selectedRegions.length > 0 ? selectedRegions : undefined,
+ subJurisdictions: effectiveSubJurisdictions.length > 0 ? effectiveSubJurisdictions : undefined
+ });
+
+ // Build jurisdiction items
+ const jurisdictionItems = await buildJurisdictionItems(selectedJurisdictions, locale, content.subJurisdictionLabels);
+
+ // Build region items
+ const regionItems = await buildRegionItems(selectedRegions, locale);
+
+ // Build sub-jurisdiction items grouped by jurisdiction
+ const subJurisdictionItemsByJurisdiction = await buildSubJurisdictionItemsByJurisdiction(selectedSubJurisdictions, locale);
+
+ // Build display name maps
+ const jurisdictionMap: Record = {};
+ allJurisdictions.forEach((j) => {
+ jurisdictionMap[j.jurisdictionId] = locale === "cy" ? j.welshName : j.name;
+ });
+
+ const regionMap: Record = {};
+ allRegions.forEach((r) => {
+ regionMap[r.regionId] = locale === "cy" ? r.welshName : r.name;
+ });
+
+ const subJurisdictionMap: Record = {};
+ const subJurisdictionToJurisdiction: Record = {};
+ allSubJurisdictions.forEach((s) => {
+ subJurisdictionMap[s.subJurisdictionId] = locale === "cy" ? s.welshName : s.name;
+ subJurisdictionToJurisdiction[s.subJurisdictionId] = s.jurisdictionId;
+ });
+
+ // Map selected values to display names
+ const selectedJurisdictionsDisplay = selectedJurisdictions.map((j) => jurisdictionMap[j] || j.toString());
+ const selectedRegionsDisplay = selectedRegions.map((r) => regionMap[r] || r.toString());
+ const selectedSubJurisdictionsDisplay = selectedSubJurisdictions.map((s) => subJurisdictionMap[s] || s.toString());
+
+ // Build remove URLs for each filter
+ const buildRemoveUrl = (type: string, index: number) => {
+ const params = new URLSearchParams();
+
+ let removedJurisdiction: number | null = null;
+
+ if (type === "jurisdiction") {
+ selectedJurisdictions.forEach((j, i) => {
+ if (i !== index) {
+ params.append("jurisdiction", j.toString());
+ } else {
+ removedJurisdiction = j;
+ }
+ });
+ } else {
+ selectedJurisdictions.forEach((j) => {
+ params.append("jurisdiction", j.toString());
+ });
+ }
+
+ if (type === "subJurisdiction") {
+ selectedSubJurisdictions.forEach((s, i) => {
+ if (i !== index) {
+ params.append("subJurisdiction", s.toString());
+ }
+ });
+ } else {
+ selectedSubJurisdictions.forEach((s) => {
+ // If we're removing a jurisdiction, also remove its associated sub-jurisdictions
+ if (removedJurisdiction && subJurisdictionToJurisdiction[s] === removedJurisdiction) {
+ return;
+ }
+ params.append("subJurisdiction", s.toString());
+ });
+ }
+
+ if (type === "region") {
+ selectedRegions.forEach((r, i) => {
+ if (i !== index) {
+ params.append("region", r.toString());
+ }
+ });
+ } else {
+ selectedRegions.forEach((r) => {
+ params.append("region", r.toString());
+ });
+ }
+
+ const queryString = params.toString();
+ return `/location-name-search${queryString ? `?${queryString}` : ""}`;
+ };
+
+ const jurisdictionRemoveUrls = selectedJurisdictions.map((_, i) => buildRemoveUrl("jurisdiction", i));
+ const subJurisdictionRemoveUrls = selectedSubJurisdictions.map((_, i) => buildRemoveUrl("subJurisdiction", i));
+ const regionRemoveUrls = selectedRegions.map((_, i) => buildRemoveUrl("region", i));
+
+ // Get available letters
+ const availableLetters = Object.keys(filteredGroupedLocations);
+
+ // Build table rows for location listings
+ const tableRows: TableRow[] = [];
+ Object.entries(filteredGroupedLocations).forEach(([letter, locations]: [string, Location[]]) => {
+ locations.forEach((location: Location, index: number) => {
+ tableRows.push({
+ letter: index === 0 ? letter : "",
+ location,
+ isFirst: index === 0
+ });
+ });
+ });
+
+ res.render("location-name-search/index", {
+ ...content,
+ locale,
+ selectedJurisdictions,
+ selectedRegions,
+ selectedSubJurisdictions,
+ selectedJurisdictionsDisplay,
+ selectedRegionsDisplay,
+ selectedSubJurisdictionsDisplay,
+ jurisdictionRemoveUrls,
+ subJurisdictionRemoveUrls,
+ regionRemoveUrls,
+ jurisdictionItems,
+ regionItems,
+ subJurisdictionItemsByJurisdiction,
+ availableLetters,
+ tableRows,
+ csrfToken: (req as any).csrfToken?.() || ""
+ });
+};
+
+const postHandler = async (req: Request, res: Response) => {
+ const locationIds = req.body.locationIds;
+
+ // Handle both single and multiple selections
+ const selectedLocationIds = Array.isArray(locationIds) ? locationIds : locationIds ? [locationIds] : [];
+
+ if (!req.session.emailSubscriptions) {
+ req.session.emailSubscriptions = {};
+ }
+
+ // Replace pending subscriptions entirely with new selection
+ req.session.emailSubscriptions.pendingSubscriptions = selectedLocationIds;
+
+ // Save session before redirect to ensure data persists
+ req.session.save((err: Error | null) => {
+ if (err) {
+ console.error("Error saving session:", err);
+ return res.redirect("/location-name-search");
+ }
+ res.redirect("/pending-subscriptions");
+ });
+};
+
+export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler];
+export const POST: RequestHandler[] = [requireAuth(), blockUserAccess(), postHandler];
diff --git a/libs/verified-pages/src/pages/pending-subscriptions/cy.ts b/libs/verified-pages/src/pages/pending-subscriptions/cy.ts
new file mode 100644
index 00000000..5f373b45
--- /dev/null
+++ b/libs/verified-pages/src/pages/pending-subscriptions/cy.ts
@@ -0,0 +1,12 @@
+export const cy = {
+ title: "Cadarnhau eich tanysgrifiadau e-bost",
+ heading: "Cadarnhau eich tanysgrifiadau e-bost",
+ confirmButton: "Cadarnhau tanysgrifiad",
+ confirmButtonPlural: "Cadarnhau tanysgrifiadau",
+ removeLink: "Dileu",
+ addAnotherSubscription: "Ychwanegu tanysgrifiad e-bost arall",
+ errorSummaryTitle: "Mae problem wedi codi",
+ errorAtLeastOne: "Mae angen o leiaf un tanysgrifiad.",
+ addSubscriptions: "Ychwanegu Tanysgrifiadau",
+ back: "Yn ôl"
+};
diff --git a/libs/verified-pages/src/pages/pending-subscriptions/en.ts b/libs/verified-pages/src/pages/pending-subscriptions/en.ts
new file mode 100644
index 00000000..2a269d07
--- /dev/null
+++ b/libs/verified-pages/src/pages/pending-subscriptions/en.ts
@@ -0,0 +1,12 @@
+export const en = {
+ title: "Confirm your email subscriptions",
+ heading: "Confirm your email subscriptions",
+ confirmButton: "Confirm subscription",
+ confirmButtonPlural: "Confirm subscriptions",
+ removeLink: "Remove",
+ addAnotherSubscription: "Add another email Subscription",
+ errorSummaryTitle: "There is a problem",
+ errorAtLeastOne: "At least one subscription is needed.",
+ addSubscriptions: "Add Subscriptions",
+ back: "Back"
+};
diff --git a/libs/verified-pages/src/pages/pending-subscriptions/index.njk b/libs/verified-pages/src/pages/pending-subscriptions/index.njk
new file mode 100644
index 00000000..8ca45988
--- /dev/null
+++ b/libs/verified-pages/src/pages/pending-subscriptions/index.njk
@@ -0,0 +1,101 @@
+{% extends "layouts/base-template.njk" %}
+{% from "govuk/components/table/macro.njk" import govukTable %}
+{% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %}
+{% from "govuk/components/button/macro.njk" import govukButton %}
+{% from "govuk/components/back-link/macro.njk" import govukBackLink %}
+
+{% set displayError = locations.length == 0 %}
+
+{% block pageTitle %}
+ {% if displayError %}
+ There is a problem - {{ serviceName }} - {{ govUk }}
+ {% else %}
+ {{ title }} - {{ serviceName }} - {{ govUk }}
+ {% endif %}
+{% endblock %}
+
+{% block beforeContent %}
+ {{ govukBackLink({
+ text: back,
+ href: "/location-name-search"
+ }) }}
+{% endblock %}
+
+{% block page_content %}
+
+ {% if displayError %}
+
{{ heading }}
+
+
+ {{ govukErrorSummary({
+ titleText: errors.titleText,
+ errorList: errors.errorList
+ }) }}
+
+
+
+ {% else %}
+
{{ heading }}
+
+ {% if errors %}
+ {{ govukErrorSummary({
+ titleText: errors.titleText,
+ errorList: errors.errorList
+ }) }}
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+ {% for location in locations %}
+
+ | {{ location.name }} |
+
+
+ |
+
+ {% endfor %}
+
+
+
+
+ {{ addAnotherSubscription }}
+
+
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/libs/verified-pages/src/pages/pending-subscriptions/index.test.ts b/libs/verified-pages/src/pages/pending-subscriptions/index.test.ts
new file mode 100644
index 00000000..39c2316e
--- /dev/null
+++ b/libs/verified-pages/src/pages/pending-subscriptions/index.test.ts
@@ -0,0 +1,181 @@
+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/location", () => ({
+ getLocationById: vi.fn((id) => ({
+ locationId: id,
+ name: `Location ${id}`,
+ welshName: `Lleoliad ${id}`
+ }))
+}));
+
+vi.mock("@hmcts/subscriptions", () => ({
+ getSubscriptionsByUserId: vi.fn(),
+ replaceUserSubscriptions: vi.fn()
+}));
+
+describe("pending-subscriptions", () => {
+ let mockReq: Partial;
+ let mockRes: Partial;
+
+ beforeEach(() => {
+ mockReq = {
+ user: { id: "user123" } as any,
+ body: {},
+ session: {
+ emailSubscriptions: {
+ pendingSubscriptions: ["456", "789"]
+ }
+ } as any,
+ path: "/pending-subscriptions"
+ };
+ mockRes = {
+ render: vi.fn(),
+ redirect: vi.fn(),
+ locals: {}
+ };
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("GET", () => {
+ it("should render page with pending subscriptions", async () => {
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.render).toHaveBeenCalledWith(
+ "pending-subscriptions/index",
+ expect.objectContaining({
+ locations: expect.arrayContaining([
+ expect.objectContaining({ locationId: "456", name: "Location 456" }),
+ expect.objectContaining({ locationId: "789", name: "Location 789" })
+ ]),
+ isPlural: true
+ })
+ );
+ });
+
+ it("should render error when no pending subscriptions", async () => {
+ mockReq.session = { emailSubscriptions: { pendingSubscriptions: [] } } as any;
+
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.render).toHaveBeenCalledWith(
+ "pending-subscriptions/index",
+ expect.objectContaining({
+ errors: expect.any(Object),
+ locations: []
+ })
+ );
+ });
+
+ it("should handle single pending subscription", async () => {
+ mockReq.session = { emailSubscriptions: { pendingSubscriptions: ["456"] } } as any;
+
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.render).toHaveBeenCalledWith(
+ "pending-subscriptions/index",
+ expect.objectContaining({
+ isPlural: false
+ })
+ );
+ });
+ });
+
+ describe("POST", () => {
+ it("should remove location when action is remove", async () => {
+ mockReq.body = { action: "remove", locationId: "456" };
+
+ await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockReq.session?.emailSubscriptions?.pendingSubscriptions).toEqual(["789"]);
+ expect(mockRes.redirect).toHaveBeenCalledWith("/pending-subscriptions");
+ });
+
+ it("should show error when removing last location", async () => {
+ mockReq.session = { emailSubscriptions: { pendingSubscriptions: ["456"] } } as any;
+ mockReq.body = { action: "remove", locationId: "456" };
+
+ await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.render).toHaveBeenCalledWith(
+ "pending-subscriptions/index",
+ expect.objectContaining({
+ errors: expect.any(Object)
+ })
+ );
+ });
+
+ 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.replaceUserSubscriptions).mockResolvedValue({
+ added: 2,
+ removed: 0
+ });
+
+ await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(subscriptionService.getSubscriptionsByUserId).toHaveBeenCalledWith("user123");
+ expect(subscriptionService.replaceUserSubscriptions).toHaveBeenCalledWith("user123", ["123", "456", "789"]);
+ expect(mockReq.session?.emailSubscriptions?.confirmationComplete).toBe(true);
+ expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-confirmed");
+ });
+
+ it("should redirect when confirming with no pending subscriptions", async () => {
+ mockReq.session = { emailSubscriptions: { pendingSubscriptions: [] } } as any;
+ mockReq.body = { action: "confirm" };
+
+ await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.redirect).toHaveBeenCalledWith("/location-name-search");
+ });
+
+ it("should handle error when confirming subscriptions", async () => {
+ mockReq.body = { action: "confirm" };
+ vi.mocked(subscriptionService.getSubscriptionsByUserId).mockResolvedValue([]);
+ vi.mocked(subscriptionService.replaceUserSubscriptions).mockRejectedValue(new Error("Test error"));
+
+ await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.render).toHaveBeenCalledWith(
+ "pending-subscriptions/index",
+ expect.objectContaining({
+ errors: expect.objectContaining({
+ errorList: expect.arrayContaining([expect.objectContaining({ text: "Test error" })])
+ })
+ })
+ );
+ });
+
+ it("should preserve existing subscriptions when adding new ones", async () => {
+ 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.replaceUserSubscriptions).mockResolvedValue({
+ added: 2,
+ removed: 0
+ });
+
+ await POST[POST.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(subscriptionService.replaceUserSubscriptions).toHaveBeenCalledWith("user123", ["123", "456", "789"]);
+ expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-confirmed");
+ });
+ });
+});
diff --git a/libs/verified-pages/src/pages/pending-subscriptions/index.ts b/libs/verified-pages/src/pages/pending-subscriptions/index.ts
new file mode 100644
index 00000000..b7dbe23d
--- /dev/null
+++ b/libs/verified-pages/src/pages/pending-subscriptions/index.ts
@@ -0,0 +1,152 @@
+import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth";
+import { getLocationById } from "@hmcts/location";
+import { getSubscriptionsByUserId, replaceUserSubscriptions } 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;
+
+ const pendingLocationIds = req.session.emailSubscriptions?.pendingSubscriptions || [];
+
+ if (pendingLocationIds.length === 0) {
+ if (!res.locals.navigation) {
+ res.locals.navigation = {};
+ }
+ res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale);
+
+ return res.render("pending-subscriptions/index", {
+ ...t,
+ errors: {
+ titleText: t.errorSummaryTitle,
+ errorList: [{ text: t.errorAtLeastOne, href: "#" }]
+ },
+ locations: [],
+ showBackToSearch: true
+ });
+ }
+
+ const pendingLocations = (
+ await Promise.all(
+ pendingLocationIds.map(async (id: string) => {
+ const location = await getLocationById(Number.parseInt(id, 10));
+ return location
+ ? {
+ locationId: id,
+ name: locale === "cy" ? location.welshName : location.name
+ }
+ : null;
+ })
+ )
+ ).filter(Boolean);
+
+ if (!res.locals.navigation) {
+ res.locals.navigation = {};
+ }
+ res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale);
+
+ const isPlural = pendingLocations.length > 1;
+
+ res.render("pending-subscriptions/index", {
+ ...t,
+ locations: pendingLocations,
+ isPlural,
+ confirmButton: isPlural ? t.confirmButtonPlural : t.confirmButton
+ });
+};
+
+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 { action, locationId } = req.body;
+
+ const pendingLocationIds = req.session.emailSubscriptions?.pendingSubscriptions || [];
+
+ if (action === "remove" && locationId) {
+ req.session.emailSubscriptions.pendingSubscriptions = pendingLocationIds.filter((id: string) => id !== locationId);
+
+ if (req.session.emailSubscriptions.pendingSubscriptions.length === 0) {
+ if (!res.locals.navigation) {
+ res.locals.navigation = {};
+ }
+ res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale);
+
+ return res.render("pending-subscriptions/index", {
+ ...t,
+ errors: {
+ titleText: t.errorSummaryTitle,
+ errorList: [{ text: t.errorAtLeastOne, href: "#" }]
+ },
+ locations: [],
+ showBackToSearch: true
+ });
+ }
+
+ return res.redirect("/pending-subscriptions");
+ }
+
+ if (action === "confirm") {
+ if (pendingLocationIds.length === 0) {
+ return res.redirect("/location-name-search");
+ }
+
+ try {
+ const existingSubscriptions = await getSubscriptionsByUserId(userId);
+ const existingLocationIds = existingSubscriptions.map((sub) => sub.locationId.toString());
+ const allLocationIds = [...new Set([...existingLocationIds, ...pendingLocationIds])];
+
+ await replaceUserSubscriptions(userId, allLocationIds);
+
+ req.session.emailSubscriptions.confirmationComplete = true;
+ req.session.emailSubscriptions.confirmedLocations = pendingLocationIds;
+ delete req.session.emailSubscriptions.pendingSubscriptions;
+
+ res.redirect("/subscription-confirmed");
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
+
+ const pendingLocations = (
+ await Promise.all(
+ pendingLocationIds.map(async (id: string) => {
+ const location = await getLocationById(Number.parseInt(id, 10));
+ return location
+ ? {
+ locationId: id,
+ name: locale === "cy" ? location.welshName : location.name
+ }
+ : null;
+ })
+ )
+ ).filter(Boolean);
+
+ if (!res.locals.navigation) {
+ res.locals.navigation = {};
+ }
+ res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale);
+
+ const isPlural = pendingLocations.length > 1;
+
+ res.render("pending-subscriptions/index", {
+ ...t,
+ errors: {
+ titleText: t.errorSummaryTitle,
+ errorList: [{ text: errorMessage }]
+ },
+ locations: pendingLocations,
+ isPlural,
+ confirmButton: isPlural ? t.confirmButtonPlural : t.confirmButton
+ });
+ }
+ }
+};
+
+export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler];
+export const POST: RequestHandler[] = [requireAuth(), blockUserAccess(), postHandler];
diff --git a/libs/verified-pages/src/pages/subscription-confirmed/cy.ts b/libs/verified-pages/src/pages/subscription-confirmed/cy.ts
new file mode 100644
index 00000000..ea21a43e
--- /dev/null
+++ b/libs/verified-pages/src/pages/subscription-confirmed/cy.ts
@@ -0,0 +1,11 @@
+export const cy = {
+ title: "Tanysgrifiad wedi'i gadarnhau",
+ panelTitle: "Tanysgrifiad wedi'i gadarnhau",
+ panelTitlePlural: "Tanysgrifiadau wedi'u cadarnhau",
+ continueText: "I barhau, gallwch fynd i",
+ yourAccountLink: "eich cyfrif",
+ inOrderTo: "er mwyn",
+ addNewSubscriptionLink: "ychwanegu tanysgrifiad e-bost newydd",
+ manageSubscriptionsLink: "rheoli eich tanysgrifiadau e-bost cyfredol",
+ findCourtLink: "dod o hyd i lys neu dribiwnlys"
+};
diff --git a/libs/verified-pages/src/pages/subscription-confirmed/en.ts b/libs/verified-pages/src/pages/subscription-confirmed/en.ts
new file mode 100644
index 00000000..9ddc16e2
--- /dev/null
+++ b/libs/verified-pages/src/pages/subscription-confirmed/en.ts
@@ -0,0 +1,11 @@
+export const en = {
+ title: "Subscription confirmed",
+ panelTitle: "Subscription confirmed",
+ panelTitlePlural: "Subscriptions confirmed",
+ continueText: "To continue, you can go to",
+ yourAccountLink: "your account",
+ inOrderTo: "in order to",
+ addNewSubscriptionLink: "add a new email subscription",
+ manageSubscriptionsLink: "manage your current email subscriptions",
+ findCourtLink: "find a court or tribunal"
+};
diff --git a/libs/verified-pages/src/pages/subscription-confirmed/index.njk b/libs/verified-pages/src/pages/subscription-confirmed/index.njk
new file mode 100644
index 00000000..b2dba632
--- /dev/null
+++ b/libs/verified-pages/src/pages/subscription-confirmed/index.njk
@@ -0,0 +1,32 @@
+{% extends "layouts/base-template.njk" %}
+{% from "govuk/components/panel/macro.njk" import govukPanel %}
+{% from "govuk/components/button/macro.njk" import govukButton %}
+
+{% block pageTitle %}
+ {{ title }} - {{ serviceName }} - {{ govUk }}
+{% endblock %}
+
+{% block page_content %}
+
+{{ govukPanel({
+ titleText: panelTitle,
+ classes: "govuk-panel--confirmation"
+}) }}
+
+
+ {{ continueText }} {{ yourAccountLink }} {{ inOrderTo }}:
+
+
+
+
+{% endblock %}
diff --git a/libs/verified-pages/src/pages/subscription-confirmed/index.test.ts b/libs/verified-pages/src/pages/subscription-confirmed/index.test.ts
new file mode 100644
index 00000000..477e12eb
--- /dev/null
+++ b/libs/verified-pages/src/pages/subscription-confirmed/index.test.ts
@@ -0,0 +1,103 @@
+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())
+}));
+
+vi.mock("@hmcts/location", () => ({
+ getLocationById: vi.fn((id) => ({
+ locationId: id,
+ name: `Location ${id}`,
+ welshName: `Lleoliad ${id}`
+ }))
+}));
+
+describe("subscription-confirmed", () => {
+ let mockReq: Partial;
+ let mockRes: Partial;
+
+ beforeEach(() => {
+ mockReq = {
+ session: {
+ emailSubscriptions: {
+ confirmationComplete: true,
+ confirmedLocations: ["456", "789"]
+ }
+ } as any,
+ path: "/subscription-confirmed"
+ };
+ mockRes = {
+ render: vi.fn(),
+ redirect: vi.fn(),
+ locals: {}
+ };
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("GET", () => {
+ it("should render page with confirmed locations", async () => {
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.render).toHaveBeenCalledWith(
+ "subscription-confirmed/index",
+ expect.objectContaining({
+ locations: expect.arrayContaining(["Location 456", "Location 789"]),
+ isPlural: true
+ })
+ );
+ expect(mockReq.session?.emailSubscriptions?.confirmationComplete).toBeUndefined();
+ expect(mockReq.session?.emailSubscriptions?.confirmedLocations).toBeUndefined();
+ });
+
+ it("should redirect if confirmation not complete", async () => {
+ mockReq.session = { emailSubscriptions: {} } as any;
+
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management");
+ });
+
+ it("should handle single confirmed location", async () => {
+ mockReq.session = {
+ emailSubscriptions: {
+ confirmationComplete: true,
+ confirmedLocations: ["456"]
+ }
+ } as any;
+
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.render).toHaveBeenCalledWith(
+ "subscription-confirmed/index",
+ expect.objectContaining({
+ isPlural: false
+ })
+ );
+ });
+
+ it("should handle empty confirmed locations", async () => {
+ mockReq.session = {
+ emailSubscriptions: {
+ confirmationComplete: true,
+ confirmedLocations: []
+ }
+ } as any;
+
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.render).toHaveBeenCalledWith(
+ "subscription-confirmed/index",
+ expect.objectContaining({
+ locations: []
+ })
+ );
+ });
+ });
+});
diff --git a/libs/verified-pages/src/pages/subscription-confirmed/index.ts b/libs/verified-pages/src/pages/subscription-confirmed/index.ts
new file mode 100644
index 00000000..af8b53b0
--- /dev/null
+++ b/libs/verified-pages/src/pages/subscription-confirmed/index.ts
@@ -0,0 +1,44 @@
+import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth";
+import { getLocationById } from "@hmcts/location";
+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.session.emailSubscriptions?.confirmationComplete) {
+ return res.redirect("/subscription-management");
+ }
+
+ const confirmedLocationIds = req.session.emailSubscriptions.confirmedLocations || [];
+
+ const confirmedLocations = (
+ await Promise.all(
+ confirmedLocationIds.map(async (id: string) => {
+ const location = await getLocationById(Number.parseInt(id, 10));
+ return location ? (locale === "cy" ? location.welshName : location.name) : null;
+ })
+ )
+ ).filter(Boolean);
+
+ delete req.session.emailSubscriptions.confirmationComplete;
+ delete req.session.emailSubscriptions.confirmedLocations;
+
+ if (!res.locals.navigation) {
+ res.locals.navigation = {};
+ }
+ res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale);
+
+ const isPlural = confirmedLocations.length > 1;
+
+ res.render("subscription-confirmed/index", {
+ ...t,
+ locations: confirmedLocations,
+ isPlural,
+ panelTitle: isPlural ? t.panelTitlePlural : t.panelTitle
+ });
+};
+
+export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler];
diff --git a/libs/verified-pages/src/pages/subscription-management/cy.ts b/libs/verified-pages/src/pages/subscription-management/cy.ts
new file mode 100644
index 00000000..77178277
--- /dev/null
+++ b/libs/verified-pages/src/pages/subscription-management/cy.ts
@@ -0,0 +1,10 @@
+export const cy = {
+ title: "Eich tanysgrifiadau e-bost",
+ heading: "Eich tanysgrifiadau e-bost",
+ noSubscriptions: "Nid oes gennych unrhyw danysgrifiadau gweithredol",
+ addButton: "Ychwanegu tanysgrifiad e-bost",
+ tableHeaderLocation: "Enw llys neu dribiwnlys",
+ tableHeaderDate: "Dyddiad ychwanegu",
+ tableHeaderActions: "Gweithredoedd",
+ removeLink: "Dad-danysgrifio"
+};
diff --git a/libs/verified-pages/src/pages/subscription-management/en.ts b/libs/verified-pages/src/pages/subscription-management/en.ts
new file mode 100644
index 00000000..de5d4ab8
--- /dev/null
+++ b/libs/verified-pages/src/pages/subscription-management/en.ts
@@ -0,0 +1,10 @@
+export const en = {
+ title: "Your email subscriptions",
+ heading: "Your email subscriptions",
+ noSubscriptions: "You do not have any active subscriptions",
+ addButton: "Add email subscription",
+ tableHeaderLocation: "Court or tribunal name",
+ tableHeaderDate: "Date added",
+ tableHeaderActions: "Actions",
+ removeLink: "Unsubscribe"
+};
diff --git a/libs/verified-pages/src/pages/subscription-management/index.njk b/libs/verified-pages/src/pages/subscription-management/index.njk
new file mode 100644
index 00000000..2ddc6dcf
--- /dev/null
+++ b/libs/verified-pages/src/pages/subscription-management/index.njk
@@ -0,0 +1,51 @@
+{% extends "layouts/base-template.njk" %}
+{% from "govuk/components/button/macro.njk" import govukButton %}
+{% from "govuk/components/input/macro.njk" import govukInput %}
+
+{% block pageTitle %}
+ {{ title }} - {{ serviceName }} - {{ govUk }}
+{% endblock %}
+
+{% block page_content %}
+
+
+
{{ heading }}
+
+ {{ govukButton({
+ text: addButton,
+ href: '/location-name-search'
+ }) }}
+
+ {% if count > 0 %}
+
+
+
+
+
+
+
+
+
+ {% for subscription in subscriptions %}
+
+ | {{ subscription.locationName }} |
+ {{ subscription.dateAdded | date('D MMMM YYYY') }} |
+
+
+ |
+
+ {% endfor %}
+
+
+ {% else %}
+
{{ noSubscriptions }}
+ {% endif %}
+
+
+{% endblock %}
diff --git a/libs/verified-pages/src/pages/subscription-management/index.test.ts b/libs/verified-pages/src/pages/subscription-management/index.test.ts
new file mode 100644
index 00000000..b3a41171
--- /dev/null
+++ b/libs/verified-pages/src/pages/subscription-management/index.test.ts
@@ -0,0 +1,90 @@
+import * as subscriptionService from "@hmcts/subscriptions";
+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())
+}));
+
+vi.mock("@hmcts/location", () => ({
+ getLocationById: vi.fn((id) => ({
+ locationId: id,
+ name: `Location ${id}`,
+ welshName: `Lleoliad ${id}`
+ }))
+}));
+
+vi.mock("@hmcts/subscriptions", () => ({
+ getSubscriptionsByUserId: vi.fn()
+}));
+
+describe("subscription-management", () => {
+ let mockReq: Partial;
+ let mockRes: Partial;
+
+ beforeEach(() => {
+ mockReq = {
+ user: { id: "user123" } as any,
+ path: "/subscription-management"
+ };
+ mockRes = {
+ render: vi.fn(),
+ redirect: vi.fn(),
+ locals: {}
+ };
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ 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() }
+ ];
+
+ vi.mocked(subscriptionService.getSubscriptionsByUserId).mockResolvedValue(mockSubscriptions);
+
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(subscriptionService.getSubscriptionsByUserId).toHaveBeenCalledWith("user123");
+ expect(mockRes.render).toHaveBeenCalledWith(
+ "subscription-management/index",
+ expect.objectContaining({
+ count: 2,
+ subscriptions: expect.arrayContaining([
+ expect.objectContaining({ locationName: "Location 456" }),
+ expect.objectContaining({ locationName: "Location 789" })
+ ])
+ })
+ );
+ });
+
+ it("should render page with no subscriptions", async () => {
+ vi.mocked(subscriptionService.getSubscriptionsByUserId).mockResolvedValue([]);
+
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.render).toHaveBeenCalledWith(
+ "subscription-management/index",
+ expect.objectContaining({
+ count: 0
+ })
+ );
+ });
+
+ it("should redirect to sign-in when no user in request", async () => {
+ mockReq.user = undefined;
+
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.redirect).toHaveBeenCalledWith("/sign-in");
+ expect(subscriptionService.getSubscriptionsByUserId).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/libs/verified-pages/src/pages/subscription-management/index.ts b/libs/verified-pages/src/pages/subscription-management/index.ts
new file mode 100644
index 00000000..ae59ab25
--- /dev/null
+++ b/libs/verified-pages/src/pages/subscription-management/index.ts
@@ -0,0 +1,67 @@
+import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth";
+import { getLocationById } from "@hmcts/location";
+import { getSubscriptionsByUserId } 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 userId = req.user.id;
+
+ try {
+ const subscriptions = await getSubscriptionsByUserId(userId);
+
+ 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()
+ };
+ }
+ })
+ );
+
+ if (!res.locals.navigation) {
+ res.locals.navigation = {};
+ }
+ res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale);
+
+ res.render("subscription-management/index", {
+ ...t,
+ subscriptions: subscriptionsWithDetails,
+ count: subscriptions.length,
+ csrfToken: (req as any).csrfToken?.() || ""
+ });
+ } catch (error) {
+ console.error(`Error retrieving subscriptions for user ${userId}:`, error);
+
+ if (!res.locals.navigation) {
+ res.locals.navigation = {};
+ }
+ res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale);
+
+ res.render("subscription-management/index", {
+ ...t,
+ subscriptions: [],
+ count: 0,
+ csrfToken: (req as any).csrfToken?.() || ""
+ });
+ }
+};
+
+export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler];
diff --git a/libs/verified-pages/src/pages/unsubscribe-confirmation/cy.ts b/libs/verified-pages/src/pages/unsubscribe-confirmation/cy.ts
new file mode 100644
index 00000000..646b4e2e
--- /dev/null
+++ b/libs/verified-pages/src/pages/unsubscribe-confirmation/cy.ts
@@ -0,0 +1,11 @@
+export const cy = {
+ title: "Tanysgrifiad wedi'i ddileu",
+ panelTitle: "Tanysgrifiad wedi'i ddileu",
+ panelText: "Mae eich tanysgrifiad wedi'i ddileu",
+ continueText: "I barhau, gallwch fynd i",
+ yourAccountLink: "eich cyfrif",
+ inOrderTo: "er mwyn:",
+ addNewSubscriptionLink: "ychwanegu tanysgrifiad e-bost newydd",
+ manageSubscriptionsLink: "rheoli eich tanysgrifiadau e-bost cyfredol",
+ findCourtLink: "dod o hyd i lys neu dribiwnlys"
+};
diff --git a/libs/verified-pages/src/pages/unsubscribe-confirmation/en.ts b/libs/verified-pages/src/pages/unsubscribe-confirmation/en.ts
new file mode 100644
index 00000000..0d0c5177
--- /dev/null
+++ b/libs/verified-pages/src/pages/unsubscribe-confirmation/en.ts
@@ -0,0 +1,11 @@
+export const en = {
+ title: "Subscription removed",
+ panelTitle: "Subscription removed",
+ panelText: "Your subscription has been removed",
+ continueText: "To continue, you can go to",
+ yourAccountLink: "your account",
+ inOrderTo: "in order to:",
+ addNewSubscriptionLink: "add a new email subscription",
+ manageSubscriptionsLink: "manage your current email subscriptions",
+ findCourtLink: "find a court or tribunal"
+};
diff --git a/libs/verified-pages/src/pages/unsubscribe-confirmation/index.njk b/libs/verified-pages/src/pages/unsubscribe-confirmation/index.njk
new file mode 100644
index 00000000..e1a7419e
--- /dev/null
+++ b/libs/verified-pages/src/pages/unsubscribe-confirmation/index.njk
@@ -0,0 +1,27 @@
+{% extends "layouts/base-template.njk" %}
+{% from "govuk/components/panel/macro.njk" import govukPanel %}
+
+{% block pageTitle %}
+ {{ title }} - {{ serviceName }} - {{ govUk }}
+{% endblock %}
+
+{% block page_content %}
+
+
+ {{ govukPanel({
+ titleText: panelTitle,
+ html: panelText
+ }) }}
+
+
+ {{ continueText }} {{ yourAccountLink }} {{ inOrderTo }}
+
+
+
+
+
+{% endblock %}
diff --git a/libs/verified-pages/src/pages/unsubscribe-confirmation/index.test.ts b/libs/verified-pages/src/pages/unsubscribe-confirmation/index.test.ts
new file mode 100644
index 00000000..db693189
--- /dev/null
+++ b/libs/verified-pages/src/pages/unsubscribe-confirmation/index.test.ts
@@ -0,0 +1,88 @@
+import * as subscriptionService from "@hmcts/subscriptions";
+import type { Request, Response } from "express";
+import { afterAll, 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())
+}));
+
+vi.mock("@hmcts/subscriptions", () => ({
+ removeSubscription: vi.fn()
+}));
+
+describe("unsubscribe-confirmation", () => {
+ let mockReq: Partial;
+ let mockRes: Partial;
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+
+ beforeEach(() => {
+ mockReq = {
+ user: { id: "user123" } as any,
+ session: {
+ emailSubscriptions: {
+ subscriptionToRemove: "sub123"
+ }
+ } as any
+ };
+ mockRes = {
+ render: vi.fn(),
+ redirect: vi.fn(),
+ locals: {}
+ };
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterAll(() => {
+ consoleErrorSpy.mockRestore();
+ });
+
+ describe("GET", () => {
+ it("should redirect if no subscriptionToRemove in session", async () => {
+ mockReq.session = { emailSubscriptions: {} } as any;
+
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management");
+ expect(subscriptionService.removeSubscription).not.toHaveBeenCalled();
+ });
+
+ it("should remove subscription and render confirmation", async () => {
+ vi.mocked(subscriptionService.removeSubscription).mockResolvedValue({
+ subscriptionId: "sub123",
+ userId: "user123",
+ locationId: "456",
+ dateAdded: new Date()
+ });
+
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(subscriptionService.removeSubscription).toHaveBeenCalledWith("sub123", "user123");
+ expect(mockReq.session?.emailSubscriptions?.subscriptionToRemove).toBeUndefined();
+ expect(mockRes.render).toHaveBeenCalledWith("unsubscribe-confirmation/index", expect.any(Object));
+ });
+
+ it("should redirect on error", async () => {
+ vi.mocked(subscriptionService.removeSubscription).mockRejectedValue(new Error("Test error"));
+
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith("Error removing subscription:", expect.any(Error));
+ expect(mockRes.redirect).toHaveBeenCalledWith("/subscription-management");
+ });
+
+ it("should redirect to sign-in when no user in request", async () => {
+ mockReq.user = undefined;
+
+ await GET[GET.length - 1](mockReq as Request, mockRes as Response, vi.fn());
+
+ expect(mockRes.redirect).toHaveBeenCalledWith("/sign-in");
+ expect(subscriptionService.removeSubscription).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/libs/verified-pages/src/pages/unsubscribe-confirmation/index.ts b/libs/verified-pages/src/pages/unsubscribe-confirmation/index.ts
new file mode 100644
index 00000000..8f5b4880
--- /dev/null
+++ b/libs/verified-pages/src/pages/unsubscribe-confirmation/index.ts
@@ -0,0 +1,43 @@
+import { blockUserAccess, buildVerifiedUserNavigation, requireAuth } from "@hmcts/auth";
+import { removeSubscription } 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 userId = req.user.id;
+ const subscriptionId = req.session.emailSubscriptions?.subscriptionToRemove;
+
+ if (!subscriptionId) {
+ return res.redirect("/subscription-management");
+ }
+
+ try {
+ await removeSubscription(subscriptionId, userId);
+
+ if (req.session.emailSubscriptions) {
+ delete req.session.emailSubscriptions.subscriptionToRemove;
+ }
+
+ if (!res.locals.navigation) {
+ res.locals.navigation = {};
+ }
+ res.locals.navigation.verifiedItems = buildVerifiedUserNavigation(req.path, locale);
+
+ res.render("unsubscribe-confirmation/index", {
+ ...t
+ });
+ } catch (error) {
+ console.error("Error removing subscription:", error);
+ return res.redirect("/subscription-management");
+ }
+};
+
+export const GET: RequestHandler[] = [requireAuth(), blockUserAccess(), getHandler];
diff --git a/libs/web-core/package.json b/libs/web-core/package.json
index aea117e3..2568fb41 100644
--- a/libs/web-core/package.json
+++ b/libs/web-core/package.json
@@ -36,6 +36,9 @@
},
"./src/assets/css/back-to-top.scss": {
"default": "./src/assets/css/back-to-top.scss"
+ },
+ "./src/assets/css/button-as-link.scss": {
+ "default": "./src/assets/css/button-as-link.scss"
}
},
"scripts": {
diff --git a/libs/web-core/src/assets/css/button-as-link.scss b/libs/web-core/src/assets/css/button-as-link.scss
new file mode 100644
index 00000000..4f33c979
--- /dev/null
+++ b/libs/web-core/src/assets/css/button-as-link.scss
@@ -0,0 +1,30 @@
+// Button styled as a link
+// Used when a form button needs to visually appear as a link for UX consistency
+.govuk-button-as-link {
+ background: none;
+ border: none;
+ padding: 0;
+ margin: 0;
+ cursor: pointer;
+ text-decoration: underline;
+ font-size: inherit;
+ font-family: inherit;
+ line-height: inherit;
+ color: #1d70b8;
+
+ &:hover {
+ color: #003078;
+ }
+
+ &:focus {
+ outline: 3px solid transparent;
+ color: #0b0c0c;
+ background-color: #ffdd00;
+ box-shadow: 0 -2px #ffdd00, 0 4px #0b0c0c;
+ text-decoration: none;
+ }
+
+ &:active {
+ color: #0b0c0c;
+ }
+}
diff --git a/package.json b/package.json
index 425af903..f4c98dce 100644
--- a/package.json
+++ b/package.json
@@ -62,7 +62,8 @@
"resolutions": {
"vite": "7.2.4",
"glob": "13.0.0",
- "body-parser": "2.2.1"
+ "body-parser": "2.2.1",
+ "node-forge": "1.3.2"
},
"dependencies": {
"@microsoft/microsoft-graph-client": "3.0.7",
diff --git a/tsconfig.json b/tsconfig.json
index 06e7af73..113fd303 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -23,7 +23,8 @@
"@hmcts/redis": ["libs/redis/src"],
"@hmcts/system-admin-pages": ["libs/system-admin-pages/src"],
"@hmcts/list-types-common": ["libs/list-types/common/src"],
- "@hmcts/civil-and-family-daily-cause-list": ["libs/list-types/civil-and-family-daily-cause-list/src"]
+ "@hmcts/civil-and-family-daily-cause-list": ["libs/list-types/civil-and-family-daily-cause-list/src"],
+ "@hmcts/subscriptions": ["libs/subscriptions/src"]
},
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
diff --git a/turbo.json b/turbo.json
index c93c42a8..6741cf08 100644
--- a/turbo.json
+++ b/turbo.json
@@ -6,6 +6,10 @@
"dependsOn": ["^build"],
"outputs": ["dist/**", "build/**"]
},
+ "@hmcts/postgres#build": {
+ "dependsOn": ["^build"],
+ "outputs": ["dist/**", "../../node_modules/.prisma/**"]
+ },
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"],
diff --git a/yarn.lock b/yarn.lock
index 7b088b23..badc4718 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -959,6 +959,18 @@ __metadata:
languageName: unknown
linkType: soft
+"@hmcts/subscriptions@workspace:*, @hmcts/subscriptions@workspace:libs/subscriptions":
+ version: 0.0.0-use.local
+ resolution: "@hmcts/subscriptions@workspace:libs/subscriptions"
+ dependencies:
+ "@hmcts/auth": "workspace:*"
+ "@hmcts/location": "workspace:*"
+ "@hmcts/postgres": "workspace:*"
+ peerDependencies:
+ express: ^5.1.0
+ languageName: unknown
+ linkType: soft
+
"@hmcts/system-admin-pages@workspace:*, @hmcts/system-admin-pages@workspace:libs/system-admin-pages":
version: 0.0.0-use.local
resolution: "@hmcts/system-admin-pages@workspace:libs/system-admin-pages"
@@ -977,6 +989,8 @@ __metadata:
"@hmcts/verified-pages@workspace:*, @hmcts/verified-pages@workspace:libs/verified-pages":
version: 0.0.0-use.local
resolution: "@hmcts/verified-pages@workspace:libs/verified-pages"
+ dependencies:
+ "@hmcts/subscriptions": "workspace:*"
peerDependencies:
express: ^5.1.0
languageName: unknown
@@ -5372,10 +5386,10 @@ __metadata:
languageName: node
linkType: hard
-"node-forge@npm:^1.2.1":
- version: 1.3.1
- resolution: "node-forge@npm:1.3.1"
- checksum: 10c0/e882819b251a4321f9fc1d67c85d1501d3004b4ee889af822fd07f64de3d1a8e272ff00b689570af0465d65d6bf5074df9c76e900e0aff23e60b847f2a46fbe8
+"node-forge@npm:1.3.2":
+ version: 1.3.2
+ resolution: "node-forge@npm:1.3.2"
+ checksum: 10c0/1def35652c93a588718a6d0d0b4f33e3e7de283aa6f4c00d01d1605d6ccce23fb3b59bcbfb6434014acd23a251cfcc2736052b406f53d94e1b19c09d289d0176
languageName: node
linkType: hard