From 05e90c1731ed134a4af9503bc6bd22a2708282b5 Mon Sep 17 00:00:00 2001 From: Joaquim d'Souza Date: Thu, 16 Apr 2026 11:05:58 +0200 Subject: [PATCH 1/5] fix: invitations list limited to sender org = current org --- AGENTS.md | 12 +++++ bin/cmd.ts | 4 ++ ...58921005_invitation_sender_organisation.ts | 50 +++++++++++++++++++ .../CreateInvitationModal.tsx | 7 +-- .../(dashboards)/invite-organisation/page.tsx | 7 ++- src/models/Invitation.ts | 1 + src/server/repositories/Invitation.ts | 3 +- src/server/trpc/routers/invitation.ts | 32 +++++++++++- src/server/trpc/routers/organisation.ts | 1 + .../server/trpc/routers/invitation.test.ts | 49 ++++++++++++++---- 10 files changed, 149 insertions(+), 17 deletions(-) create mode 100644 migrations/1774658921005_invitation_sender_organisation.ts diff --git a/AGENTS.md b/AGENTS.md index 37819d3f7..874756889 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,6 +109,18 @@ Use tRPC as the bridge between client components and server logic: All database access belongs in `src/server/repositories/`. Build queries with Kysely's query builder; avoid raw SQL (`sql` template tag) unless there is no alternative. +### CamelCasePlugin + +A `CamelCasePlugin` translates between camelCase (used in the Kysely query builder) and snake_case (used in the actual PostgreSQL columns). Use **camelCase** in the query builder API and **snake_case** in raw SQL (`sql` template tag / migrations with raw SQL). + +```ts +// ✅ Query builder — camelCase +db.selectFrom("invitation").where("invitation.senderOrganisationId", "=", id); + +// ✅ Raw SQL — snake_case +sql`UPDATE invitation SET sender_organisation_id = ${id}`; +``` + ### JSONPlugin The database is configured with a custom `JSONPlugin` that automatically serialises JavaScript objects and arrays into JSONB when writing to the database. **Do not call `JSON.stringify()` on values passed to Kysely queries** — it's handled for you and double-encoding will corrupt the data. diff --git a/bin/cmd.ts b/bin/cmd.ts index 850726814..b7c298ff6 100755 --- a/bin/cmd.ts +++ b/bin/cmd.ts @@ -112,12 +112,15 @@ program .option("--email ") .option("--name ") .option("--organisationId ") + .option("--senderOrganisationId ") .description("Create an invitation for a user") .action(async (options) => { const invitation = await createInvitation({ email: options.email, name: options.name, organisationId: options.organisationId, + senderOrganisationId: + options.senderOrganisationId || options.organisationId, }); logger.info(`Created invitation ${invitation.id}`); @@ -151,6 +154,7 @@ program email: user.email, name: user.name, organisationId: orgs[0].id, + senderOrganisationId: orgs[0].id, }); logger.info(`Created invitation ${invitation.id}`); diff --git a/migrations/1774658921005_invitation_sender_organisation.ts b/migrations/1774658921005_invitation_sender_organisation.ts new file mode 100644 index 000000000..47c023b38 --- /dev/null +++ b/migrations/1774658921005_invitation_sender_organisation.ts @@ -0,0 +1,50 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { sql } from "kysely"; +import type { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + // Add nullable column first + await db.schema + .alterTable("invitation") + .addColumn("senderOrganisationId", "uuid") + .execute(); + + // Backfill: assign all existing invitations to the admin organisation + await sql` + UPDATE invitation + SET "sender_organisation_id" = ( + SELECT id FROM organisation WHERE name = 'Common Knowledge' LIMIT 1 + ) + WHERE "sender_organisation_id" IS NULL + `.execute(db); + + // Add foreign key constraint + await db.schema + .alterTable("invitation") + .addForeignKeyConstraint( + "invitationSenderOrganisationIdFKey", + ["senderOrganisationId"], + "organisation", + ["id"], + (cb) => cb.onDelete("set null").onUpdate("cascade"), + ) + .execute(); + + // Make column not null after backfill + await db.schema + .alterTable("invitation") + .alterColumn("senderOrganisationId", (col) => col.setNotNull()) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable("invitation") + .dropConstraint("invitationSenderOrganisationIdFKey") + .execute(); + + await db.schema + .alterTable("invitation") + .dropColumn("senderOrganisationId") + .execute(); +} diff --git a/src/app/(private)/(dashboards)/invite-organisation/CreateInvitationModal.tsx b/src/app/(private)/(dashboards)/invite-organisation/CreateInvitationModal.tsx index bfc07d7c2..c606c1175 100644 --- a/src/app/(private)/(dashboards)/invite-organisation/CreateInvitationModal.tsx +++ b/src/app/(private)/(dashboards)/invite-organisation/CreateInvitationModal.tsx @@ -5,6 +5,7 @@ import { PlusIcon } from "lucide-react"; import { type FormEvent, useMemo, useState } from "react"; import { toast } from "sonner"; import FormFieldWrapper from "@/components/forms/FormFieldWrapper"; +import { useOrganisations } from "@/hooks/useOrganisations"; import { useTRPC } from "@/services/trpc/react"; import { Button } from "@/shadcn/ui/button"; import { Checkbox } from "@/shadcn/ui/checkbox"; @@ -43,6 +44,7 @@ export default function CreateInvitationModal() { Set >(new Set()); + const { organisationId: senderOrganisationId } = useOrganisations(); const trpc = useTRPC(); const client = useQueryClient(); @@ -62,9 +64,7 @@ export default function CreateInvitationModal() { client.invalidateQueries({ queryKey: trpc.organisation.listAll.queryKey(), }); - client.invalidateQueries({ - queryKey: trpc.invitation.list.queryKey(), - }); + client.invalidateQueries(trpc.invitation.list.queryFilter()); }, onError: (error) => { toast.error("Failed to create invitation.", { @@ -169,6 +169,7 @@ export default function CreateInvitationModal() { } createInvitationMutate({ + senderOrganisationId: senderOrganisationId ?? "", organisationId, organisationName, email, diff --git a/src/app/(private)/(dashboards)/invite-organisation/page.tsx b/src/app/(private)/(dashboards)/invite-organisation/page.tsx index eed957ca0..794a184b5 100644 --- a/src/app/(private)/(dashboards)/invite-organisation/page.tsx +++ b/src/app/(private)/(dashboards)/invite-organisation/page.tsx @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { redirect } from "next/navigation"; import { useCurrentUser } from "@/hooks"; +import { useOrganisations } from "@/hooks/useOrganisations"; import { UserRole } from "@/models/User"; import { useTRPC } from "@/services/trpc/react"; import { @@ -17,13 +18,17 @@ import CreateInvitationModal from "./CreateInvitationModal"; export default function InviteOrganisationPage() { const { currentUser } = useCurrentUser(); + const { organisationId } = useOrganisations(); const trpc = useTRPC(); const isAllowed = currentUser?.role === UserRole.Advocate || currentUser?.role === UserRole.Superadmin; const { data: invitations, isPending: invitationsLoading } = useQuery( - trpc.invitation.list.queryOptions(undefined, { enabled: isAllowed }), + trpc.invitation.list.queryOptions( + { organisationId: organisationId ?? "" }, + { enabled: isAllowed && Boolean(organisationId) }, + ), ); if (!isAllowed) { diff --git a/src/models/Invitation.ts b/src/models/Invitation.ts index d492ab82f..53a09b146 100644 --- a/src/models/Invitation.ts +++ b/src/models/Invitation.ts @@ -5,6 +5,7 @@ export const invitationSchema = z.object({ email: z.string().email().trim().toLowerCase(), name: z.string().trim(), organisationId: z.string(), + senderOrganisationId: z.string(), userId: z.string().nullish(), createdAt: z.date(), updatedAt: z.date(), diff --git a/src/server/repositories/Invitation.ts b/src/server/repositories/Invitation.ts index 2adc486a4..c56f2fae8 100644 --- a/src/server/repositories/Invitation.ts +++ b/src/server/repositories/Invitation.ts @@ -13,11 +13,12 @@ export function createInvitation(invitation: NewInvitation) { .executeTakeFirstOrThrow(); } -export function listPendingInvitations() { +export function listPendingInvitations(senderOrganisationId: string) { return db .selectFrom("invitation") .leftJoin("organisation", "invitation.organisationId", "organisation.id") .where("invitation.userId", "is", null) + .where("invitation.senderOrganisationId", "=", senderOrganisationId) .select([ "invitation.id", "invitation.email", diff --git a/src/server/trpc/routers/invitation.ts b/src/server/trpc/routers/invitation.ts index c77a46722..dadec1912 100644 --- a/src/server/trpc/routers/invitation.ts +++ b/src/server/trpc/routers/invitation.ts @@ -11,6 +11,7 @@ import { } from "@/server/repositories/Invitation"; import { findOrganisationById, + findOrganisationForUser, upsertOrganisation, } from "@/server/repositories/Organisation"; import logger from "@/server/services/logger"; @@ -24,6 +25,7 @@ export const invitationRouter = router({ .object({ name: z.string(), email: z.string().email(), + senderOrganisationId: z.string(), organisationId: z.string().nullish(), organisationName: z.string().nullish(), mapSelections: z @@ -40,8 +42,19 @@ export const invitationRouter = router({ path: ["organisationId", "organisationName"], }), ) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { try { + const senderOrg = await findOrganisationForUser( + input.senderOrganisationId, + ctx.user.id, + ); + if (!senderOrg) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You do not belong to the sender organisation", + }); + } + let org; if (input.organisationId) { org = await findOrganisationById(input.organisationId); @@ -66,6 +79,7 @@ export const invitationRouter = router({ email: input.email.toLowerCase().trim(), name: input.name, organisationId: org.id, + senderOrganisationId: senderOrg.id, }); const secret = new TextEncoder().encode(process.env.JWT_SECRET || ""); @@ -87,7 +101,21 @@ export const invitationRouter = router({ }); } }), - list: advocateProcedure.query(() => listPendingInvitations()), + list: advocateProcedure + .input(z.object({ organisationId: z.string() })) + .query(async ({ input, ctx }) => { + const org = await findOrganisationForUser( + input.organisationId, + ctx.user.id, + ); + if (!org) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You do not belong to this organisation", + }); + } + return listPendingInvitations(org.id); + }), listForUser: protectedProcedure.query(async ({ ctx }) => { return findPendingInvitationsByEmail(ctx.user.email); }), diff --git a/src/server/trpc/routers/organisation.ts b/src/server/trpc/routers/organisation.ts index d036cb302..8ade1240a 100644 --- a/src/server/trpc/routers/organisation.ts +++ b/src/server/trpc/routers/organisation.ts @@ -106,6 +106,7 @@ export const organisationRouter = router({ email, name: input.name, organisationId: ctx.organisation.id, + senderOrganisationId: ctx.organisation.id, }); if (existingUser) { diff --git a/tests/unit/server/trpc/routers/invitation.test.ts b/tests/unit/server/trpc/routers/invitation.test.ts index 70ac99776..9ffc5f681 100644 --- a/tests/unit/server/trpc/routers/invitation.test.ts +++ b/tests/unit/server/trpc/routers/invitation.test.ts @@ -20,6 +20,7 @@ import { } from "@/server/repositories/Map"; import { findMapViewsByMapId } from "@/server/repositories/MapView"; import { upsertOrganisation } from "@/server/repositories/Organisation"; +import { upsertOrganisationUser } from "@/server/repositories/OrganisationUser"; import { deleteUser, findUserById, @@ -32,7 +33,11 @@ const userIds: string[] = []; const mapIds: string[] = []; const dataSourceIds: string[] = []; -async function createTestUser(role?: UserRole | null) { +async function createSenderOrg() { + return upsertOrganisation({ name: `Sender Org ${uuidv4()}` }); +} + +async function createTestUser(role?: UserRole | null, organisationId?: string) { const user = await upsertUser({ email: `test-${uuidv4()}@example.com`, password: "test-password-123", @@ -44,6 +49,12 @@ async function createTestUser(role?: UserRole | null) { await updateUserRole(user.id, role); const updated = await findUserById(user.id); if (!updated) throw new Error("User not found after role update"); + if (organisationId) { + await upsertOrganisationUser({ + organisationId, + userId: updated.id, + }); + } return updated; } return user; @@ -55,34 +66,42 @@ function makeCaller(user: Awaited> | null) { describe("invitation.list", () => { test("superadmin can list invitations", async () => { - const superadmin = await createTestUser(UserRole.Superadmin); + const senderOrg = await createSenderOrg(); + const superadmin = await createTestUser(UserRole.Superadmin, senderOrg.id); const caller = makeCaller(superadmin); - const result = await caller.list(); + const result = await caller.list({ organisationId: senderOrg.id }); expect(Array.isArray(result)).toBe(true); }); test("advocate can list invitations", async () => { - const advocate = await createTestUser(UserRole.Advocate); + const senderOrg = await createSenderOrg(); + const advocate = await createTestUser(UserRole.Advocate, senderOrg.id); const caller = makeCaller(advocate); - const result = await caller.list(); + const result = await caller.list({ organisationId: senderOrg.id }); expect(Array.isArray(result)).toBe(true); }); test("regular user cannot list invitations", async () => { + const senderOrg = await createSenderOrg(); const regular = await createTestUser(); const caller = makeCaller(regular); - await expect(caller.list()).rejects.toMatchObject({ + await expect( + caller.list({ organisationId: senderOrg.id }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED", }); }); test("unauthenticated user cannot list invitations", async () => { + const senderOrg = await createSenderOrg(); const caller = makeCaller(null); - await expect(caller.list()).rejects.toMatchObject({ + await expect( + caller.list({ organisationId: senderOrg.id }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED", }); }); @@ -90,13 +109,15 @@ describe("invitation.list", () => { describe("invitation.create", () => { test("advocate can create an invitation with a new organisation", async () => { - const advocate = await createTestUser(UserRole.Advocate); + const senderOrg = await createSenderOrg(); + const advocate = await createTestUser(UserRole.Advocate, senderOrg.id); const caller = makeCaller(advocate); const email = `invitee-${uuidv4()}@example.com`; await caller.create({ name: "Invitee", email, + senderOrganisationId: senderOrg.id, organisationName: `New Org ${uuidv4()}`, }); @@ -106,13 +127,15 @@ describe("invitation.create", () => { }); test("superadmin can create an invitation with a new organisation", async () => { - const superadmin = await createTestUser(UserRole.Superadmin); + const senderOrg = await createSenderOrg(); + const superadmin = await createTestUser(UserRole.Superadmin, senderOrg.id); const caller = makeCaller(superadmin); const email = `invitee-${uuidv4()}@example.com`; await caller.create({ name: "Invitee", email, + senderOrganisationId: senderOrg.id, organisationName: `New Org ${uuidv4()}`, }); @@ -121,7 +144,8 @@ describe("invitation.create", () => { }); test("advocate can create an invitation with mapSelections (copies the map + DS)", async () => { - const advocate = await createTestUser(UserRole.Advocate); + const senderOrg = await createSenderOrg(); + const advocate = await createTestUser(UserRole.Advocate, senderOrg.id); const caller = makeCaller(advocate); const sourceOrg = await upsertOrganisation({ @@ -160,6 +184,7 @@ describe("invitation.create", () => { await caller.create({ name: "Invitee", email, + senderOrganisationId: senderOrg.id, organisationName: targetOrgName, mapSelections: [{ mapId: sourceMap.id, dataSourceIds: [sourceDs.id] }], }); @@ -190,6 +215,7 @@ describe("invitation.create", () => { }); test("regular user cannot create invitations", async () => { + const senderOrg = await createSenderOrg(); const regular = await createTestUser(); const caller = makeCaller(regular); @@ -197,18 +223,21 @@ describe("invitation.create", () => { caller.create({ name: "Test", email: "test@example.com", + senderOrganisationId: senderOrg.id, organisationName: "Test Org", }), ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); }); test("unauthenticated user cannot create invitations", async () => { + const senderOrg = await createSenderOrg(); const caller = makeCaller(null); await expect( caller.create({ name: "Test", email: "test@example.com", + senderOrganisationId: senderOrg.id, organisationName: "Test Org", }), ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); From 5d733022b88d591361a24a8c18a54adc9e683b57 Mon Sep 17 00:00:00 2001 From: joaquimds Date: Thu, 16 Apr 2026 11:33:35 +0200 Subject: [PATCH 2/5] Update src/server/repositories/Invitation.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/server/repositories/Invitation.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/repositories/Invitation.ts b/src/server/repositories/Invitation.ts index c56f2fae8..57fdb4a28 100644 --- a/src/server/repositories/Invitation.ts +++ b/src/server/repositories/Invitation.ts @@ -18,6 +18,7 @@ export function listPendingInvitations(senderOrganisationId: string) { .selectFrom("invitation") .leftJoin("organisation", "invitation.organisationId", "organisation.id") .where("invitation.userId", "is", null) + .where("invitation.used", "=", false) .where("invitation.senderOrganisationId", "=", senderOrganisationId) .select([ "invitation.id", From f394a631cbbcf5dec756781da79d4d0e366a609e Mon Sep 17 00:00:00 2001 From: Joaquim d'Souza Date: Thu, 16 Apr 2026 11:40:27 +0200 Subject: [PATCH 3/5] fix: minor PR review comments --- ...58921005_invitation_sender_organisation.ts | 2 +- .../server/trpc/routers/invitation.test.ts | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/migrations/1774658921005_invitation_sender_organisation.ts b/migrations/1774658921005_invitation_sender_organisation.ts index 47c023b38..2d50d6d49 100644 --- a/migrations/1774658921005_invitation_sender_organisation.ts +++ b/migrations/1774658921005_invitation_sender_organisation.ts @@ -26,7 +26,7 @@ export async function up(db: Kysely): Promise { ["senderOrganisationId"], "organisation", ["id"], - (cb) => cb.onDelete("set null").onUpdate("cascade"), + (cb) => cb.onDelete("cascade").onUpdate("cascade"), ) .execute(); diff --git a/tests/unit/server/trpc/routers/invitation.test.ts b/tests/unit/server/trpc/routers/invitation.test.ts index 9ffc5f681..9852ce4d4 100644 --- a/tests/unit/server/trpc/routers/invitation.test.ts +++ b/tests/unit/server/trpc/routers/invitation.test.ts @@ -95,6 +95,58 @@ describe("invitation.list", () => { }); }); + test("only returns invitations from the requested sender organisation", async () => { + const orgA = await createSenderOrg(); + const orgB = await createSenderOrg(); + + const superadmin = await createTestUser(UserRole.Superadmin, orgA.id); + await upsertOrganisationUser({ + organisationId: orgB.id, + userId: superadmin.id, + }); + + const caller = makeCaller(superadmin); + + // Create invitations under orgA + const emailA1 = `invitee-${uuidv4()}@example.com`; + const emailA2 = `invitee-${uuidv4()}@example.com`; + await caller.create({ + name: "Invitee A1", + email: emailA1, + senderOrganisationId: orgA.id, + organisationName: `Target Org ${uuidv4()}`, + }); + await caller.create({ + name: "Invitee A2", + email: emailA2, + senderOrganisationId: orgA.id, + organisationName: `Target Org ${uuidv4()}`, + }); + + // Create invitation under orgB + const emailB = `invitee-${uuidv4()}@example.com`; + await caller.create({ + name: "Invitee B", + email: emailB, + senderOrganisationId: orgB.id, + organisationName: `Target Org ${uuidv4()}`, + }); + + // List for orgA should only contain orgA's invitations + const resultA = await caller.list({ organisationId: orgA.id }); + const emailsA = resultA.map((inv) => inv.email); + expect(emailsA).toContain(emailA1); + expect(emailsA).toContain(emailA2); + expect(emailsA).not.toContain(emailB); + + // List for orgB should only contain orgB's invitation + const resultB = await caller.list({ organisationId: orgB.id }); + const emailsB = resultB.map((inv) => inv.email); + expect(emailsB).toContain(emailB); + expect(emailsB).not.toContain(emailA1); + expect(emailsB).not.toContain(emailA2); + }); + test("unauthenticated user cannot list invitations", async () => { const senderOrg = await createSenderOrg(); const caller = makeCaller(null); From 4580dd3f3d9574c329fc73843f6e951efc5f29ca Mon Sep 17 00:00:00 2001 From: joaquimds Date: Thu, 16 Apr 2026 11:59:03 +0200 Subject: [PATCH 4/5] Update src/server/trpc/routers/invitation.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/server/trpc/routers/invitation.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/trpc/routers/invitation.ts b/src/server/trpc/routers/invitation.ts index dadec1912..9796498fc 100644 --- a/src/server/trpc/routers/invitation.ts +++ b/src/server/trpc/routers/invitation.ts @@ -102,10 +102,10 @@ export const invitationRouter = router({ } }), list: advocateProcedure - .input(z.object({ organisationId: z.string() })) + .input(z.object({ senderOrganisationId: z.string().uuid() })) .query(async ({ input, ctx }) => { const org = await findOrganisationForUser( - input.organisationId, + input.senderOrganisationId, ctx.user.id, ); if (!org) { From 849f6a8b91a93be5bf0c0d56a05500f841bba9ed Mon Sep 17 00:00:00 2001 From: Joaquim d'Souza Date: Thu, 16 Apr 2026 12:05:18 +0200 Subject: [PATCH 5/5] feat: add NHC ICB 22 area set --- resources/areaSets/index.ts | 9 +++++++++ .../(dashboards)/invite-organisation/page.tsx | 2 +- .../map/[id]/components/Choropleth/configs.ts | 13 +++++++++++++ src/labels.ts | 4 ++++ src/models/AreaSet.ts | 3 +++ tests/unit/server/trpc/routers/invitation.test.ts | 12 ++++++------ 6 files changed, 36 insertions(+), 7 deletions(-) diff --git a/resources/areaSets/index.ts b/resources/areaSets/index.ts index 5aa0b498c..e44542027 100644 --- a/resources/areaSets/index.ts +++ b/resources/areaSets/index.ts @@ -162,4 +162,13 @@ export const areaSetsMetadata: AreaSetMetadata[] = [ codeKey: "DioNumber", nameKey: "Diocese", }, + { + code: AreaSetCode.ICB22, + name: "NHS Integrated Care Boards 2022", + filename: "icb_2022.geojson", + link: "https://open-geography-portalx-ons.hub.arcgis.com/api/download/v1/items/92362df594aa408aaa7a581ac83fb348/geojson?layers=0", + isNationalGridSRID: true, + codeKey: "ICB22CD", + nameKey: "ICB22NM", + }, ]; diff --git a/src/app/(private)/(dashboards)/invite-organisation/page.tsx b/src/app/(private)/(dashboards)/invite-organisation/page.tsx index 794a184b5..c9fdffa77 100644 --- a/src/app/(private)/(dashboards)/invite-organisation/page.tsx +++ b/src/app/(private)/(dashboards)/invite-organisation/page.tsx @@ -26,7 +26,7 @@ export default function InviteOrganisationPage() { const { data: invitations, isPending: invitationsLoading } = useQuery( trpc.invitation.list.queryOptions( - { organisationId: organisationId ?? "" }, + { senderOrganisationId: organisationId ?? "" }, { enabled: isAllowed && Boolean(organisationId) }, ), ); diff --git a/src/app/(private)/map/[id]/components/Choropleth/configs.ts b/src/app/(private)/map/[id]/components/Choropleth/configs.ts index b60823d2b..199e7b75e 100644 --- a/src/app/(private)/map/[id]/components/Choropleth/configs.ts +++ b/src/app/(private)/map/[id]/components/Choropleth/configs.ts @@ -256,6 +256,19 @@ export const CHOROPLETH_LAYER_CONFIGS: Record< }, }, ], + ICB22: [ + { + areaSetCode: AreaSetCode.ICB22, + minZoom: 0, + requiresBoundingBox: false, + mapbox: { + featureCodeProperty: "ICB22CD", + featureNameProperty: "ICB22NM", + layerId: "icb_2022-7ehb90", + sourceId: "commonknowledge.4x90o7q9", + }, + }, + ], }; export const HEX_CHOROPLETH_LAYER_CONFIG: ChoroplethLayerConfig = { diff --git a/src/labels.ts b/src/labels.ts index ed5288676..48b143ec0 100644 --- a/src/labels.ts +++ b/src/labels.ts @@ -37,6 +37,7 @@ export const AreaSetCodeLabels: Record = { SDZ22: "Scottish Data Zone", SIZ22: "Scottish Intermediate Zone", COED26: "Church of England Diocese", + ICB22: "NHS Integrated Care Board", }; export const AreaSetCodeYears: Record = { @@ -57,6 +58,7 @@ export const AreaSetCodeYears: Record = { SDZ22: "2022 — present", SIZ22: "2022 — present", COED26: "2026 — present", + ICB22: "2022 — present", }; export const AreaSetGroupCodeLabels: Record = { @@ -73,6 +75,7 @@ export const AreaSetGroupCodeLabels: Record = { SENC22: "Senedd Constituency", SOA22: "Scottish Inter. Zone ➔ Data Zone ➔ Output Area", COED26: "Church of England Diocese", + ICB22: "NHS Integrated Care Board", }; export const AreaSetGroupCodeYears: Record = { @@ -89,6 +92,7 @@ export const AreaSetGroupCodeYears: Record = { SENC22: "2022 — present", SOA22: "2022 — present", COED26: "2026 — present", + ICB22: "2022 — present", }; export const FilterTypeLabels: Record< diff --git a/src/models/AreaSet.ts b/src/models/AreaSet.ts index bc66c25cc..68acdba58 100644 --- a/src/models/AreaSet.ts +++ b/src/models/AreaSet.ts @@ -18,6 +18,7 @@ export enum AreaSetCode { SPC22 = "SPC22", SENC22 = "SENC22", COED26 = "COED26", + ICB22 = "ICB22", } export const areaSetCodes = Object.values(AreaSetCode); @@ -37,6 +38,7 @@ export enum AreaSetGroupCode { SENC22 = "SENC22", SOA22 = "SOA22", COED26 = "COED26", + ICB22 = "ICB22", } export const areaSetGroupCodes = Object.values(AreaSetGroupCode); @@ -58,6 +60,7 @@ export const AreaSetSizes: Record = { [AreaSetCode.CTYUA24]: 6, [AreaSetCode.CAUTH25]: 6, [AreaSetCode.COED26]: 6, + [AreaSetCode.ICB22]: 6, [AreaSetCode.UKR18]: 8, [AreaSetCode.UKC24]: 12, }; diff --git a/tests/unit/server/trpc/routers/invitation.test.ts b/tests/unit/server/trpc/routers/invitation.test.ts index 9852ce4d4..0e3dc789c 100644 --- a/tests/unit/server/trpc/routers/invitation.test.ts +++ b/tests/unit/server/trpc/routers/invitation.test.ts @@ -70,7 +70,7 @@ describe("invitation.list", () => { const superadmin = await createTestUser(UserRole.Superadmin, senderOrg.id); const caller = makeCaller(superadmin); - const result = await caller.list({ organisationId: senderOrg.id }); + const result = await caller.list({ senderOrganisationId: senderOrg.id }); expect(Array.isArray(result)).toBe(true); }); @@ -79,7 +79,7 @@ describe("invitation.list", () => { const advocate = await createTestUser(UserRole.Advocate, senderOrg.id); const caller = makeCaller(advocate); - const result = await caller.list({ organisationId: senderOrg.id }); + const result = await caller.list({ senderOrganisationId: senderOrg.id }); expect(Array.isArray(result)).toBe(true); }); @@ -89,7 +89,7 @@ describe("invitation.list", () => { const caller = makeCaller(regular); await expect( - caller.list({ organisationId: senderOrg.id }), + caller.list({ senderOrganisationId: senderOrg.id }), ).rejects.toMatchObject({ code: "UNAUTHORIZED", }); @@ -133,14 +133,14 @@ describe("invitation.list", () => { }); // List for orgA should only contain orgA's invitations - const resultA = await caller.list({ organisationId: orgA.id }); + const resultA = await caller.list({ senderOrganisationId: orgA.id }); const emailsA = resultA.map((inv) => inv.email); expect(emailsA).toContain(emailA1); expect(emailsA).toContain(emailA2); expect(emailsA).not.toContain(emailB); // List for orgB should only contain orgB's invitation - const resultB = await caller.list({ organisationId: orgB.id }); + const resultB = await caller.list({ senderOrganisationId: orgB.id }); const emailsB = resultB.map((inv) => inv.email); expect(emailsB).toContain(emailB); expect(emailsB).not.toContain(emailA1); @@ -152,7 +152,7 @@ describe("invitation.list", () => { const caller = makeCaller(null); await expect( - caller.list({ organisationId: senderOrg.id }), + caller.list({ senderOrganisationId: senderOrg.id }), ).rejects.toMatchObject({ code: "UNAUTHORIZED", });