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 %} +
+ {{ govukInput({ + id: "search", + name: "q", + label: { + text: searchLabel, + classes: "govuk-label--m" + }, + value: query if query else "" + }) }} + + {{ govukButton({ + text: searchButton + }) }} +
+ +

+ {{ 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 %} +
+ + {{ govukButton({ + text: subscribeButton, + classes: "govuk-button--secondary" + }) }} +
+ {% 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 }}

+ +
+ + {{ govukButton({ + text: confirmButton + }) }} +
+ +

+ {{ 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 }}

+
+ {{ govukInput({ + id: "_csrf", + name: "_csrf", + type: "hidden", + value: csrfToken + }) }} + {{ govukInput({ + id: "subscription", + name: "subscription", + type: "hidden", + value: subscriptionId + }) }} + {{ govukRadios({ + idPrefix: "unsubscribe-confirm", + name: "unsubscribe-confirm", + items: [ + { + value: "yes", + text: radio1 + }, + { + value: "no", + text: radio2 + } + ] + }) }} + {{ govukButton({ + text: continueButton + }) }} +
+
+{% 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 %} +
+ {% for i in range(0, selectedJurisdictions.length) %} + + {{ selectedJurisdictionsDisplay[i] }} + × + + {% endfor %} + {% for i in range(0, selectedSubJurisdictions.length) %} + + {{ selectedSubJurisdictionsDisplay[i] }} + × + + {% endfor %} + {% for i in range(0, selectedRegions.length) %} + + {{ selectedRegionsDisplay[i] }} + × + + {% endfor %} +
+ {% endif %} +
+ + {# Filter Controls Section #} +
+
+ {{ govukButton({ + text: applyFilters + }) }} + + {# Mobile hide filters button #} + + +
+ +
+ {{ govukCheckboxes({ + name: "jurisdiction", + classes: "govuk-checkboxes--small", + items: jurisdictionItems + }) }} + + {# Sub-jurisdiction sections #} + {% for item in jurisdictionItems %} + {% if subJurisdictionItemsByJurisdiction[item.jurisdictionId].length > 0 %} +
+ +
+ {{ govukCheckboxes({ + name: "subJurisdiction", + classes: "govuk-checkboxes--small", + items: subJurisdictionItemsByJurisdiction[item.jurisdictionId] + }) }} +
+
+ {% endif %} + {% endfor %} +
+
+ +
+ +
+ {{ govukCheckboxes({ + name: "region", + classes: "govuk-checkboxes--small", + items: regionItems + }) }} +
+
+
+
+ +
+
+ +
+ {# Mobile filter toggle button #} + + +
+ + + + {% for row in tableRows %} + + + + + {% endfor %} + +
+ {% if row.letter %} + {{ row.letter }} + {% endif %} + +
+
+ + +
+
+
+ +

{{ totalSelected }}

+
+

0 {{ selected }}

+
+ + {{ govukButton({ + text: continueButton, + type: "submit" + }) }} +
+ + +
+
+ + + +{% 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 + }) }} +
+
+
+
+ {{ govukButton({ + text: addSubscriptions + }) }} +
+
+ {% else %} +

{{ heading }}

+ + {% if errors %} + {{ govukErrorSummary({ + titleText: errors.titleText, + errorList: errors.errorList + }) }} + {% endif %} + + + + + + + + + + + + {% for location in locations %} + + + + + {% endfor %} + +
{{ heading }}Actions
{{ location.name }} +
+ + + +
+
+ +

+ {{ addAnotherSubscription }} +

+ +
+ + {{ govukButton({ + text: confirmButton + }) }} +
+ {% 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 %} + + + + + + {% endfor %} + +
{{ tableHeaderLocation }}{{ tableHeaderDate }}{{ tableHeaderActions }}
{{ subscription.locationName }}{{ subscription.dateAdded | date('D MMMM YYYY') }} +
+ + + +
+
+ {% 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