diff --git a/app/database/migrations/20260825000000_rename_territory_kind_enum/migration.sql b/app/database/migrations/20260825000000_rename_territory_kind_enum/migration.sql new file mode 100644 index 00000000..c52c76a3 --- /dev/null +++ b/app/database/migrations/20260825000000_rename_territory_kind_enum/migration.sql @@ -0,0 +1,4 @@ +-- Rename the TerritoryKind enum to TerritoryKindKey so the name frees up for the +-- TerritoryKind entity table. Values and their mapped strings are untouched, so +-- "Territory"."type" keeps its data — this is a type rename, not a data migration. +ALTER TYPE "TerritoryKind" RENAME TO "TerritoryKindKey"; diff --git a/app/database/migrations/20260825100000_add_territory_kind_roles/migration.sql b/app/database/migrations/20260825100000_add_territory_kind_roles/migration.sql new file mode 100644 index 00000000..141f6097 --- /dev/null +++ b/app/database/migrations/20260825100000_add_territory_kind_roles/migration.sql @@ -0,0 +1,79 @@ +-- TerritoryKind: the entity behind what is still the `TerritoryKindKey` enum on +-- `Territory.type`. It exists now so per-kind configuration has a home that +-- survives kinds becoming user-created; `Territory` is deliberately NOT pointed +-- at it yet, so this migration is purely additive. +-- +-- TerritoryKindAllowedRole: which roles a publisher must hold to be attributed a +-- territory of that kind. No rows for a kind = no restriction. + +-- CreateTable +CREATE TABLE "TerritoryKind" ( + "id" SERIAL NOT NULL, + "key" TEXT NOT NULL, + "name" TEXT, + "isBuiltIn" BOOLEAN NOT NULL DEFAULT true, + "congregationId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TerritoryKind_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TerritoryKindAllowedRole" ( + "kindId" INTEGER NOT NULL, + "roleId" INTEGER NOT NULL, + "congregationId" INTEGER NOT NULL, + + CONSTRAINT "TerritoryKindAllowedRole_pkey" PRIMARY KEY ("kindId","roleId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "TerritoryKind_key_congregationId_key" ON "TerritoryKind"("key", "congregationId"); +CREATE UNIQUE INDEX "TerritoryKind_id_congregationId_key" ON "TerritoryKind"("id", "congregationId"); +CREATE INDEX "TerritoryKind_congregationId_idx" ON "TerritoryKind"("congregationId"); +CREATE INDEX "TerritoryKindAllowedRole_roleId_idx" ON "TerritoryKindAllowedRole"("roleId"); +CREATE INDEX "TerritoryKindAllowedRole_congregationId_idx" ON "TerritoryKindAllowedRole"("congregationId"); + +-- AddForeignKey +ALTER TABLE "TerritoryKind" ADD CONSTRAINT "TerritoryKind_congregationId_fkey" FOREIGN KEY ("congregationId") REFERENCES "Congregation"("id") ON DELETE CASCADE ON UPDATE CASCADE; +-- Compound FK so a kind can never be linked across tenants. +ALTER TABLE "TerritoryKindAllowedRole" ADD CONSTRAINT "TerritoryKindAllowedRole_kindId_congregationId_fkey" FOREIGN KEY ("kindId", "congregationId") REFERENCES "TerritoryKind"("id", "congregationId") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "TerritoryKindAllowedRole" ADD CONSTRAINT "TerritoryKindAllowedRole_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "TerritoryKindAllowedRole" ADD CONSTRAINT "TerritoryKindAllowedRole_congregationId_fkey" FOREIGN KEY ("congregationId") REFERENCES "Congregation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Row-Level Security +-- CASE/WHEN rather than OR — the planner may reorder OR branches and leak rows. +-- See docs/development/row-level-security.md. +ALTER TABLE "TerritoryKind" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "TerritoryKind" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON "TerritoryKind" FOR ALL + USING ( + CASE + WHEN NULLIF(current_setting('app.congregation_id', true), '') IS NULL THEN true + ELSE "congregationId" = current_setting('app.congregation_id', true)::int + END + ); + +ALTER TABLE "TerritoryKindAllowedRole" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "TerritoryKindAllowedRole" FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON "TerritoryKindAllowedRole" FOR ALL + USING ( + CASE + WHEN NULLIF(current_setting('app.congregation_id', true), '') IS NULL THEN true + ELSE "congregationId" = current_setting('app.congregation_id', true)::int + END + ); + +-- Seed the five built-in kinds for every existing congregation. New +-- congregations get them from seedBuiltInTerritoryKinds at setup. +-- +-- Keys are the TerritoryKindKey *member names*, not the @map strings: the Prisma +-- client surfaces `Territory.type` as 'Classical', while the column stores +-- 'doors-to-doors'. Storing the member name is what lets a caller look a kind up +-- by `territory.type` without a translation table. +INSERT INTO "TerritoryKind" ("key", "isBuiltIn", "congregationId", "updatedAt") +SELECT k, true, c."id", CURRENT_TIMESTAMP +FROM "Congregation" c +CROSS JOIN (VALUES ('Classical'), ('Univ'), ('Commerces'), ('Phone'), ('Hotel')) AS t(k) +ON CONFLICT ("key", "congregationId") DO NOTHING; diff --git a/app/database/schema.prisma b/app/database/schema.prisma index a31a5da0..3d14a080 100644 --- a/app/database/schema.prisma +++ b/app/database/schema.prisma @@ -15,7 +15,9 @@ enum PublisherType { Missionnaire @map("missionnaire") } -enum TerritoryKind { +// The key set of the built-in territory kinds. The `TerritoryKind` model is the +// entity; this enum still types `Territory.type` until territories move onto the FK. +enum TerritoryKindKey { Classical @map("doors-to-doors") Univ @map("campus") Commerces @map("commerces") @@ -81,6 +83,8 @@ model Congregation { members Member[] memberRoleAssignments MemberRoleAssignment[] territories Territory[] + territoryKinds TerritoryKind[] + territoryKindAllowedRoles TerritoryKindAllowedRole[] buildings Building[] buildingEntrances BuildingEntrance[] attributions Attribution[] @@ -330,6 +334,7 @@ model Role { allowedForTemplateServiceParts TemplateServicePartAllowedRole[] allowedForEventServiceParts EventServicePartAllowedRole[] allowedForBoardSections BoardSectionVisibilityRole[] + allowedForTerritoryKinds TerritoryKindAllowedRole[] congregation Congregation @relation(fields: [congregationId], references: [id], onDelete: Cascade) congregationId Int @@ -506,10 +511,49 @@ model BoardDynamicDocumentView { @@index([userId]) } +// A kind of territory, per congregation. The five built-ins are seeded from +// `TerritoryKindKey` at congregation setup; `key` becomes a free-form slug once +// congregations can define their own kinds. `Territory.type` still points at the +// enum — moving it onto a FK here is a later change. +model TerritoryKind { + id Int @id @default(autoincrement()) + key String + // null for built-ins — the label comes from i18n, as it does for Role + name String? + isBuiltIn Boolean @default(true) + + allowedRoles TerritoryKindAllowedRole[] + + congregation Congregation @relation(fields: [congregationId], references: [id], onDelete: Cascade) + congregationId Int + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([key, congregationId]) + @@unique([id, congregationId]) + @@index([congregationId]) +} + +// Roles a publisher must hold to be attributed a territory of this kind. +// No rows for a kind means no restriction — any active publisher qualifies. +model TerritoryKindAllowedRole { + kind TerritoryKind @relation(fields: [kindId, congregationId], references: [id, congregationId], onDelete: Cascade) + kindId Int + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + roleId Int + congregation Congregation @relation(fields: [congregationId], references: [id], onDelete: Cascade) + congregationId Int + + @@id([kindId, roleId]) + @@index([roleId]) + @@index([congregationId]) +} + model Territory { id Int @id @default(autoincrement()) number String - type TerritoryKind @default(Classical) + type TerritoryKindKey @default(Classical) notes String @default("") attributions Attribution[] @relation("territory") campaignScopes CampaignTerritory[] diff --git a/app/database/seed-marketing.ts b/app/database/seed-marketing.ts index e4536f41..736c0a3b 100644 --- a/app/database/seed-marketing.ts +++ b/app/database/seed-marketing.ts @@ -20,7 +20,8 @@ import { EventTemplateKey } from '../features/events/model/event-template.type' import { seedDefaultTemplates } from '../features/events/server/seed-templates.server' import { EntranceKind } from '../features/territories/model/entrance-kind.type' import { TerritoryAttributionKind } from '../features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '../features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '../features/territories/model/territory-kind.type' +import { seedBuiltInTerritoryKinds } from '../features/territories/server/territory-kinds.server' import { syncBuiltInRoleAssignments } from '../shared/domain/built-in-roles.server' import { seedBuiltInRoles } from '../shared/domain/setup.server' import { Permission } from '../shared/types/permission' @@ -366,35 +367,35 @@ const PUBLISHERS: { }, ] -const TERRITORIES: { number: string; type: TerritoryKind; notes: string }[] = [ - { number: 'T01', type: TerritoryKind.Classical, notes: 'Centre-ville, secteur piéton' }, - { number: 'T02', type: TerritoryKind.Classical, notes: '' }, - { number: 'T03', type: TerritoryKind.Classical, notes: 'Résidences récentes, beaucoup de jeunes familles' }, - { number: 'T04', type: TerritoryKind.Classical, notes: '' }, - { number: 'T05', type: TerritoryKind.Classical, notes: 'Quartier calme, peu de refus' }, - { number: 'T06', type: TerritoryKind.Classical, notes: '' }, - { number: 'T07', type: TerritoryKind.Classical, notes: 'Immeubles avec digicodes — voir notes entrées' }, - { number: 'T08', type: TerritoryKind.Classical, notes: '' }, - { number: 'T09', type: TerritoryKind.Classical, notes: '' }, - { number: 'T10', type: TerritoryKind.Classical, notes: 'Proche de la gare' }, - { number: 'T11', type: TerritoryKind.Classical, notes: '' }, - { number: 'T12', type: TerritoryKind.Classical, notes: 'Longue distance entre immeubles' }, - { number: 'T13', type: TerritoryKind.Classical, notes: '' }, - { number: 'T14', type: TerritoryKind.Classical, notes: '' }, - { number: 'P01', type: TerritoryKind.Phone, notes: 'Territoire téléphonique — personnes âgées' }, - { number: 'P02', type: TerritoryKind.Phone, notes: 'Territoire téléphonique' }, - { number: 'C01', type: TerritoryKind.Commerces, notes: 'Commerces rue principale' }, - { number: 'C02', type: TerritoryKind.Commerces, notes: 'Commerces zone commerciale' }, - { number: 'H01', type: TerritoryKind.Hotel, notes: 'Hôtels du quartier' }, - { number: 'U01', type: TerritoryKind.Univ, notes: 'Campus universitaire' }, +const TERRITORIES: { number: string; type: TerritoryKindKey; notes: string }[] = [ + { number: 'T01', type: TerritoryKindKey.Classical, notes: 'Centre-ville, secteur piéton' }, + { number: 'T02', type: TerritoryKindKey.Classical, notes: '' }, + { number: 'T03', type: TerritoryKindKey.Classical, notes: 'Résidences récentes, beaucoup de jeunes familles' }, + { number: 'T04', type: TerritoryKindKey.Classical, notes: '' }, + { number: 'T05', type: TerritoryKindKey.Classical, notes: 'Quartier calme, peu de refus' }, + { number: 'T06', type: TerritoryKindKey.Classical, notes: '' }, + { number: 'T07', type: TerritoryKindKey.Classical, notes: 'Immeubles avec digicodes — voir notes entrées' }, + { number: 'T08', type: TerritoryKindKey.Classical, notes: '' }, + { number: 'T09', type: TerritoryKindKey.Classical, notes: '' }, + { number: 'T10', type: TerritoryKindKey.Classical, notes: 'Proche de la gare' }, + { number: 'T11', type: TerritoryKindKey.Classical, notes: '' }, + { number: 'T12', type: TerritoryKindKey.Classical, notes: 'Longue distance entre immeubles' }, + { number: 'T13', type: TerritoryKindKey.Classical, notes: '' }, + { number: 'T14', type: TerritoryKindKey.Classical, notes: '' }, + { number: 'P01', type: TerritoryKindKey.Phone, notes: 'Territoire téléphonique — personnes âgées' }, + { number: 'P02', type: TerritoryKindKey.Phone, notes: 'Territoire téléphonique' }, + { number: 'C01', type: TerritoryKindKey.Commerces, notes: 'Commerces rue principale' }, + { number: 'C02', type: TerritoryKindKey.Commerces, notes: 'Commerces zone commerciale' }, + { number: 'H01', type: TerritoryKindKey.Hotel, notes: 'Hôtels du quartier' }, + { number: 'U01', type: TerritoryKindKey.Univ, notes: 'Campus universitaire' }, ] -const ENTRANCE_KIND_FOR_TERRITORY: Record = { - [TerritoryKind.Classical]: EntranceKind.Residential, - [TerritoryKind.Phone]: EntranceKind.Residential, - [TerritoryKind.Commerces]: EntranceKind.Commerce, - [TerritoryKind.Hotel]: EntranceKind.Hotel, - [TerritoryKind.Univ]: EntranceKind.Campus, +const ENTRANCE_KIND_FOR_TERRITORY: Record = { + [TerritoryKindKey.Classical]: EntranceKind.Residential, + [TerritoryKindKey.Phone]: EntranceKind.Residential, + [TerritoryKindKey.Commerces]: EntranceKind.Commerce, + [TerritoryKindKey.Hotel]: EntranceKind.Hotel, + [TerritoryKindKey.Univ]: EntranceKind.Campus, } const SHOP_KINDS = [ @@ -639,6 +640,7 @@ async function main() { // for this congregation even when the marketing seed runs on a fresh DB // without the regular seed having run first. await seedBuiltInRoles(prisma, congId) + await seedBuiltInTerritoryKinds(prisma, congId) // ── Event templates ─────────────────────────────────────────────────── await seedDefaultTemplates(prisma, congId, 'fr') diff --git a/app/database/seed.ts b/app/database/seed.ts index a4da5059..77cb32b5 100644 --- a/app/database/seed.ts +++ b/app/database/seed.ts @@ -1,6 +1,7 @@ import 'dotenv/config' import { PrismaPg } from '@prisma/adapter-pg' import { seedDefaultTemplates } from '../features/events/server/seed-templates.server' +import { seedBuiltInTerritoryKinds } from '../features/territories/server/territory-kinds.server' import { seedBuiltInRoles, seedPermissions } from '../shared/domain/setup.server' import { PrismaClient } from './generated/client' @@ -44,6 +45,7 @@ async function main() { await seedDefaultTemplates(prisma, defaultCongregation.id, seedLocale) await seedBuiltInRoles(prisma, defaultCongregation.id) + await seedBuiltInTerritoryKinds(prisma, defaultCongregation.id) } } diff --git a/app/features/authentication/server/register-congregation.server.test.ts b/app/features/authentication/server/register-congregation.server.test.ts index 01c1275c..6086327e 100644 --- a/app/features/authentication/server/register-congregation.server.test.ts +++ b/app/features/authentication/server/register-congregation.server.test.ts @@ -6,6 +6,8 @@ const scopedDb = { // Seeding the default templates also seeds the part presets they link to. partPreset: { findFirst: vi.fn(), create: vi.fn(), findMany: vi.fn() }, role: { upsert: vi.fn(), findUnique: vi.fn() }, + // Setup also seeds the built-in territory kinds. + territoryKind: { upsert: vi.fn() }, permission: { findUnique: vi.fn() }, rolePermission: { upsert: vi.fn() }, } diff --git a/app/features/authentication/server/register-congregation.server.ts b/app/features/authentication/server/register-congregation.server.ts index 2f202f03..f0276637 100644 --- a/app/features/authentication/server/register-congregation.server.ts +++ b/app/features/authentication/server/register-congregation.server.ts @@ -1,5 +1,6 @@ import { randomBytes } from 'node:crypto' import { seedDefaultTemplates } from '~/features/events/index.server' +import { seedBuiltInTerritoryKinds } from '~/features/territories/index.server' import * as m from '~/i18n/paraglide/messages' import type { locales } from '~/i18n/paraglide/runtime' import { hash } from '~/shared/auth/crypto.server' @@ -87,7 +88,7 @@ export async function registerCongregation( // freeform templates) inside a scoped transaction so PostgreSQL RLS allows // the inserts. await withScope(congregation.id, async scopedDb => { - await seedCongregationDefaults(scopedDb, congregation.id, locale, seedDefaultTemplates) + await seedCongregationDefaults(scopedDb, congregation.id, locale, seedDefaultTemplates, seedBuiltInTerritoryKinds) await syncBuiltInRoleAssignments(scopedDb, user.id, congregation.id, user.id) }) diff --git a/app/features/authentication/server/setup-first-account.server.test.ts b/app/features/authentication/server/setup-first-account.server.test.ts index 47764cd6..4f24f773 100644 --- a/app/features/authentication/server/setup-first-account.server.test.ts +++ b/app/features/authentication/server/setup-first-account.server.test.ts @@ -6,6 +6,8 @@ const scopedDb = { // Seeding the default templates also seeds the part presets they link to. partPreset: { findFirst: vi.fn(), create: vi.fn(), findMany: vi.fn() }, role: { upsert: vi.fn(), findUnique: vi.fn() }, + // Setup also seeds the built-in territory kinds. + territoryKind: { upsert: vi.fn() }, permission: { findUnique: vi.fn() }, rolePermission: { upsert: vi.fn() }, } diff --git a/app/features/authentication/server/setup-first-account.server.ts b/app/features/authentication/server/setup-first-account.server.ts index 84417661..ee33b052 100644 --- a/app/features/authentication/server/setup-first-account.server.ts +++ b/app/features/authentication/server/setup-first-account.server.ts @@ -1,4 +1,5 @@ import { seedDefaultTemplates } from '~/features/events/index.server' +import { seedBuiltInTerritoryKinds } from '~/features/territories/index.server' import type { locales } from '~/i18n/paraglide/runtime' import { hash } from '~/shared/auth/crypto.server' import { syncBuiltInRoleAssignments } from '~/shared/domain/built-in-roles.server' @@ -57,7 +58,7 @@ export async function setupFirstAccount( // freeform templates) inside a scoped transaction so PostgreSQL RLS allows // the inserts. await withScope(congregation.id, async scopedDb => { - await seedCongregationDefaults(scopedDb, congregation.id, locale, seedDefaultTemplates) + await seedCongregationDefaults(scopedDb, congregation.id, locale, seedDefaultTemplates, seedBuiltInTerritoryKinds) await syncBuiltInRoleAssignments(scopedDb, user.id, congregation.id, user.id) }) diff --git a/app/features/dashboard/server/dashboard.integration.test.ts b/app/features/dashboard/server/dashboard.integration.test.ts index 3a445a44..95f836fd 100644 --- a/app/features/dashboard/server/dashboard.integration.test.ts +++ b/app/features/dashboard/server/dashboard.integration.test.ts @@ -1,7 +1,7 @@ import { PrismaPg } from '@prisma/adapter-pg' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { PrismaClient } from '~/database/generated/client' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' const adapter = new PrismaPg({ connectionString: process.env.DB_RUNTIME_URL ?? process.env.DB_URL, @@ -76,7 +76,7 @@ beforeAll(async () => { // Territory with attribution to Alice const territory = await tx.territory.create({ - data: { number: `T-DASH-${ts}`, type: TerritoryKind.Classical, congregationId }, + data: { number: `T-DASH-${ts}`, type: TerritoryKindKey.Classical, congregationId }, }) await tx.attribution.create({ diff --git a/app/features/dashboard/ui/build-urgent-items.test.ts b/app/features/dashboard/ui/build-urgent-items.test.ts index 914b790a..c1b6d3b3 100644 --- a/app/features/dashboard/ui/build-urgent-items.test.ts +++ b/app/features/dashboard/ui/build-urgent-items.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' vi.mock('~/i18n/paraglide/messages', () => ({ dashboard_urgent_territory_overdue: ({ number }: { number: string }) => `Territory ${number} — overdue`, @@ -38,7 +38,7 @@ function makeTerritory(id: number, number: string, status: 'on-time' | 'due-soon id, startDate: new Date(2026, 0, 1), lateDate, - territory: { id, number, type: TerritoryKind.Classical }, + territory: { id, number, type: TerritoryKindKey.Classical }, status, } } diff --git a/app/features/publishers/routes/publishers/publisher.tsx b/app/features/publishers/routes/publishers/publisher.tsx index 2208a5d0..d8466172 100644 --- a/app/features/publishers/routes/publishers/publisher.tsx +++ b/app/features/publishers/routes/publishers/publisher.tsx @@ -9,7 +9,7 @@ import { getPublisherById } from '~/features/publishers/server/publishers.server import EmergencyInfoView, { type EmergencyInfoViewData } from '~/features/publishers/ui/EmergencyInfoView' import { PioneerActivitySection, pioneerProfileLabel } from '~/features/publishers/ui/PioneerActivitySection' import { PublisherEngagementCards } from '~/features/publishers/ui/PublisherEngagementCards' -import { AttributionStatus, TerritoryKind } from '~/features/territories' +import { AttributionStatus, TerritoryKindKey } from '~/features/territories' import { findActiveAttributionsForPublisher } from '~/features/territories/index.server' import * as m from '~/i18n/paraglide/messages' import { @@ -461,13 +461,13 @@ export default function PublisherPage({ loaderData }: Route.ComponentProps) { )} - {attribution.territory.type === TerritoryKind.Classical && + {attribution.territory.type === TerritoryKindKey.Classical && m.publishers_view_territory_classical()} - {attribution.territory.type === TerritoryKind.Commerces && + {attribution.territory.type === TerritoryKindKey.Commerces && m.publishers_view_territory_commerces()} - {attribution.territory.type === TerritoryKind.Phone && m.publishers_view_territory_phone()} - {attribution.territory.type === TerritoryKind.Hotel && m.publishers_view_territory_hotel()} - {attribution.territory.type === TerritoryKind.Univ && m.publishers_view_territory_univ()} + {attribution.territory.type === TerritoryKindKey.Phone && m.publishers_view_territory_phone()} + {attribution.territory.type === TerritoryKindKey.Hotel && m.publishers_view_territory_hotel()} + {attribution.territory.type === TerritoryKindKey.Univ && m.publishers_view_territory_univ()} {attribution.startDate.toLocaleDateString('fr-FR')} diff --git a/app/features/settings/routes/territories/settings.tsx b/app/features/settings/routes/territories/settings.tsx index 7340eea7..24a8b742 100644 --- a/app/features/settings/routes/territories/settings.tsx +++ b/app/features/settings/routes/territories/settings.tsx @@ -1,12 +1,22 @@ import { getFormProps, getInputProps, useForm } from '@conform-to/react' import { parseWithZod } from '@conform-to/zod' -import { useState } from 'react' import { data, Form, Link, redirect } from 'react-router' -import { territorySettingsSchema } from '~/features/settings/schemas/territory-settings.schema' +import { KIND_ROLES_FIELD_PREFIX, territorySettingsSchema } from '~/features/settings/schemas/territory-settings.schema' import { loadTerritorySettings } from '~/features/settings/server/load-territory-settings.server' -import { banoUrlWriteError, getAllowedZips, parseZips, serializeZips } from '~/features/territories/index.server' +import { DurationInput } from '~/features/settings/ui/DurationInput' +import { TerritoryKindSettingsList } from '~/features/settings/ui/TerritoryKindSettingsList' +import { TerritoryKindKey } from '~/features/territories' +import { + banoUrlWriteError, + getAllowedZips, + listTerritoryKindsWithRoles, + parseZips, + serializeZips, + setKindAllowedRoles, +} from '~/features/territories/index.server' import * as m from '~/i18n/paraglide/messages' import { currentAccountContext, permissionsContext, withScopeFromContext } from '~/shared/auth/route-context.server' +import { listRoles } from '~/shared/domain/roles.server' import { getSetting, setSetting } from '~/shared/domain/settings.server' import { Permission } from '~/shared/types/permission' import { TerritorySettingKey } from '~/shared/types/territory-setting-key' @@ -27,58 +37,6 @@ export const meta: Route.MetaFunction = () => { return [{ title: m.settings_territories_meta_title() }] } -function formatDayHint(days: number): string { - if (days < 14) return `${days} jour${days > 1 ? 's' : ''}` - if (days < 28) { - const weeks = Math.round(days / 7) - return `= ${weeks} semaine${weeks > 1 ? 's' : ''}` - } - const months = Math.round(days / 30) - return `≈ ${months} mois` -} - -function DurationInput({ - field, - label, - hint, - defaultValue, - onChange, -}: { - field: { id: string; name: string; errors?: string[] } - label: string - hint: string - defaultValue: number - onChange: () => void -}) { - const [hint_, setHint] = useState(formatDayHint(defaultValue)) - - return ( -
- -
- { - const v = Number(e.target.value) - if (v > 0) setHint(formatDayHint(v)) - onChange() - }} - /> - {m.settings_territories_attribution_duration_days_unit()} - {hint_} -
- {field.errors &&

{field.errors}

} -

{hint}

-
- ) -} - export function loader({ context }: Route.LoaderArgs) { const permissions = context.get(permissionsContext) const currentUser = context.get(currentAccountContext) @@ -89,9 +47,11 @@ export function loader({ context }: Route.LoaderArgs) { } return withScopeFromContext(context, async db => { - const [zips, settings] = await Promise.all([ + const [zips, settings, kinds, roles] = await Promise.all([ getAllowedZips(db), loadTerritorySettings(db, currentUser.congregationId), + listTerritoryKindsWithRoles(db, currentUser.congregationId), + listRoles(db, currentUser.congregationId), ]) // Attribution default duration — fall back to legacy months×30 for pre-v2 congregations @@ -109,6 +69,9 @@ export function loader({ context }: Route.LoaderArgs) { } return { + kinds, + // Same ordering as the board-section pickers: built-ins first, then custom. + roles: roles.map(({ id, key, name, isBuiltIn }) => ({ id, key, name, isBuiltIn })), zips: serializeZips(zips), banoUrl: settings[TerritorySettingKey.BanoUrl] ?? '', prospectionValidity: Number(settings[TerritorySettingKey.ProspectionValidity] ?? '24'), @@ -131,6 +94,8 @@ export default function TerritorySettingsPage({ loaderData, actionData }: Route. attributionDefaultDuration, attributionPhoneDuration, attributionCommerceDuration, + kinds, + roles, } = loaderData const [form, fields] = useForm({ @@ -248,19 +213,12 @@ export default function TerritorySettingsPage({ loaderData, actionData }: Route.

{m.settings_territories_types_title()}

-
- - -
+ @@ -341,6 +299,16 @@ export async function action({ request, context }: Route.ActionArgs) { currentUser.congregationId, ) + for (const key of Object.values(TerritoryKindKey)) { + await setKindAllowedRoles( + db, + key, + submission.value[`${KIND_ROLES_FIELD_PREFIX}${key}`], + currentUser.congregationId, + currentUser.id, + ) + } + return redirect('/settings/territories') }) } diff --git a/app/features/settings/schemas/territory-settings.schema.test.ts b/app/features/settings/schemas/territory-settings.schema.test.ts index 619608aa..7267b08f 100644 --- a/app/features/settings/schemas/territory-settings.schema.test.ts +++ b/app/features/settings/schemas/territory-settings.schema.test.ts @@ -1,24 +1,35 @@ import { describe, expect, it } from 'vitest' -import { territorySettingsSchema } from './territory-settings.schema' +import { TerritoryKindKey } from '~/features/territories' +import { KIND_ROLES_FIELD_PREFIX, territorySettingsSchema } from './territory-settings.schema' -function parseBanoUrl(value: string) { - return territorySettingsSchema.safeParse({ zips: '', 'bano-url': value, 'prospection-validity': '' }) -} +describe('territorySettingsSchema — per-kind role fields', () => { + // The five kind fields are hand-spelled so the parsed value stays typed. That + // makes them driftable: add a kind to the enum, forget the schema, and the + // form silently drops that kind's roles on every save. This is the guard. + it('declares a role field for every territory kind', () => { + const declared = Object.keys(territorySettingsSchema.shape).filter(key => key.startsWith(KIND_ROLES_FIELD_PREFIX)) + const expected = Object.values(TerritoryKindKey).map(key => `${KIND_ROLES_FIELD_PREFIX}${key}`) -describe('territorySettingsSchema bano-url', () => { - it('accepts an empty value', () => { - expect(parseBanoUrl('').success).toBe(true) + expect(declared.sort()).toEqual(expected.sort()) }) - it('accepts a valid https URL', () => { - expect(parseBanoUrl('https://bano.openstreetmap.fr/data/bano.csv').success).toBe(true) + it('reads a cleared checkbox group as an explicit "no restriction"', () => { + const result = territorySettingsSchema.parse({}) + + expect(result[`${KIND_ROLES_FIELD_PREFIX}${TerritoryKindKey.Phone}`]).toEqual([]) }) - it('rejects a non-https URL', () => { - expect(parseBanoUrl('http://bano.openstreetmap.fr/data/bano.csv').success).toBe(false) + it('coerces a single posted role id into a list', () => { + const result = territorySettingsSchema.parse({ [`${KIND_ROLES_FIELD_PREFIX}${TerritoryKindKey.Phone}`]: '7' }) + + expect(result[`${KIND_ROLES_FIELD_PREFIX}${TerritoryKindKey.Phone}`]).toEqual([7]) }) - it('rejects a syntactically invalid URL', () => { - expect(parseBanoUrl('not a url').success).toBe(false) + it('keeps every posted role id when several are checked', () => { + const result = territorySettingsSchema.parse({ + [`${KIND_ROLES_FIELD_PREFIX}${TerritoryKindKey.Phone}`]: ['7', '9'], + }) + + expect(result[`${KIND_ROLES_FIELD_PREFIX}${TerritoryKindKey.Phone}`]).toEqual([7, 9]) }) }) diff --git a/app/features/settings/schemas/territory-settings.schema.ts b/app/features/settings/schemas/territory-settings.schema.ts index ce637895..4f3d59fc 100644 --- a/app/features/settings/schemas/territory-settings.schema.ts +++ b/app/features/settings/schemas/territory-settings.schema.ts @@ -1,5 +1,22 @@ import { z } from 'zod' +/** + * Roles allowed to be attributed a territory of a given kind, one field per + * kind. A checkbox group posts nothing when every box is cleared, so the + * preprocess maps "absent" to [] — an explicit "no restriction" — rather than + * letting the kind fall through unchanged. + * + * The keys are the built-in `TerritoryKindKey` members. They are spelled out + * rather than derived so the parsed value stays typed; when congregations can + * define their own kinds this becomes a dynamic parse. + */ +const roleIdsField = z.preprocess( + v => (Array.isArray(v) ? v : v == null || v === '' ? [] : [v]), + z.array(z.coerce.number().int().positive()), +) + +export const KIND_ROLES_FIELD_PREFIX = 'kind-roles-' + function isEmptyOrHttpsUrl(value: string): boolean { if (value === '') return true try { @@ -24,6 +41,11 @@ export const territorySettingsSchema = z.object({ 'attribution-default-duration': z.string().default('120'), 'attribution-phone-duration': z.string().default('14'), 'attribution-commerce-duration': z.string().default('120'), + 'kind-roles-Classical': roleIdsField.default([]), + 'kind-roles-Univ': roleIdsField.default([]), + 'kind-roles-Commerces': roleIdsField.default([]), + 'kind-roles-Phone': roleIdsField.default([]), + 'kind-roles-Hotel': roleIdsField.default([]), }) export type TerritorySettingsInput = z.infer diff --git a/app/features/settings/server/data-transfer.integration.test.ts b/app/features/settings/server/data-transfer.integration.test.ts index cd10390e..30a20252 100644 --- a/app/features/settings/server/data-transfer.integration.test.ts +++ b/app/features/settings/server/data-transfer.integration.test.ts @@ -3,7 +3,7 @@ import JsZip from 'jszip' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { PrismaClient } from '~/database/generated/client' import { EntranceKind } from '~/features/territories/model/entrance-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { flushPendingAuditWrites } from '~/shared/domain/audit.server' import { BUILT_IN_ROLE_KEYS } from '~/shared/domain/built-in-roles.server' import { PublisherType } from '~/shared/types/publisher-type' @@ -90,7 +90,7 @@ beforeAll(async () => { }) const territory = await tx.territory.create({ - data: { number: `T-${ts}`, type: TerritoryKind.Classical, notes: 'Test territory', congregationId: sourceId }, + data: { number: `T-${ts}`, type: TerritoryKindKey.Classical, notes: 'Test territory', congregationId: sourceId }, }) const building = await tx.building.create({ diff --git a/app/features/settings/server/data-transfer.type.test.ts b/app/features/settings/server/data-transfer.type.test.ts index b02aec16..c13b9157 100644 --- a/app/features/settings/server/data-transfer.type.test.ts +++ b/app/features/settings/server/data-transfer.type.test.ts @@ -172,6 +172,12 @@ describe('ENTITY_FILES', () => { expect(ENTITY_FILES.indexOf('roles')).toBeLessThan(visibilityIndex) }) + it('has territory-kinds and roles before territory-kind-allowed-roles (dependency order)', () => { + const allowedIndex = ENTITY_FILES.indexOf('territory-kind-allowed-roles') + expect(ENTITY_FILES.indexOf('territory-kinds')).toBeLessThan(allowedIndex) + expect(ENTITY_FILES.indexOf('roles')).toBeLessThan(allowedIndex) + }) + it('has programme-template-parts before programme-template-part-allowed-roles (dependency order)', () => { expect(ENTITY_FILES.indexOf('programme-template-parts')).toBeLessThan( ENTITY_FILES.indexOf('programme-template-part-allowed-roles'), diff --git a/app/features/settings/server/data-transfer.type.ts b/app/features/settings/server/data-transfer.type.ts index 571c11ed..a331c208 100644 --- a/app/features/settings/server/data-transfer.type.ts +++ b/app/features/settings/server/data-transfer.type.ts @@ -72,6 +72,8 @@ export const ENTITY_FILES = [ 'pioneer-goals', 'external-speakers', 'territories', + 'territory-kinds', + 'territory-kind-allowed-roles', 'territory-card-overlays', 'territory-perimeter', 'buildings', diff --git a/app/features/settings/server/export-congregation.server.ts b/app/features/settings/server/export-congregation.server.ts index 1789916d..ab760e1e 100644 --- a/app/features/settings/server/export-congregation.server.ts +++ b/app/features/settings/server/export-congregation.server.ts @@ -298,6 +298,20 @@ export function buildExportSteps(db: TransactionClient, congregationId: number, select: { id: true, number: true, type: true, notes: true }, }), }, + { + name: 'territory-kinds', + export: () => + db.territoryKind.findMany({ + select: { id: true, key: true, name: true, isBuiltIn: true }, + }), + }, + { + name: 'territory-kind-allowed-roles', + export: () => + db.territoryKindAllowedRole.findMany({ + select: { kindId: true, roleId: true }, + }), + }, { name: 'territory-card-overlays', export: () => diff --git a/app/features/settings/server/import-congregation.server.ts b/app/features/settings/server/import-congregation.server.ts index 7c496e11..139cf0ef 100644 --- a/app/features/settings/server/import-congregation.server.ts +++ b/app/features/settings/server/import-congregation.server.ts @@ -59,6 +59,8 @@ import { importAttributions, importTerritories, importTerritoryCardOverlays, + importTerritoryKindAllowedRoles, + importTerritoryKinds, importTerritoryPerimeter, } from './import-territories.server' import { @@ -123,6 +125,8 @@ export { importAttributions, importTerritories, importTerritoryCardOverlays, + importTerritoryKindAllowedRoles, + importTerritoryKinds, importTerritoryPerimeter, } from './import-territories.server' export { @@ -225,6 +229,12 @@ export async function runImport(job: Job): Promise { await importTerritories(zip, db, idMap, congregationId) await progress() + await importTerritoryKinds(zip, db, idMap, congregationId) + await progress() + + await importTerritoryKindAllowedRoles(zip, db, idMap, congregationId) + await progress() + await importTerritoryCardOverlays(zip, db, idMap, congregationId) await progress() diff --git a/app/features/settings/server/import-territories.server.ts b/app/features/settings/server/import-territories.server.ts index 199c57d6..73e3b53d 100644 --- a/app/features/settings/server/import-territories.server.ts +++ b/app/features/settings/server/import-territories.server.ts @@ -1,5 +1,5 @@ import type JsZip from 'jszip' -import type { TerritoryAttributionKind, TerritoryKind } from '~/features/territories' +import type { TerritoryAttributionKind, TerritoryKindKey } from '~/features/territories' import type { TransactionClient } from '~/shared/infra/db.server' import type { EntityIdMap } from './data-transfer.type' import { readNdjsonFile } from './ndjson-archive' @@ -16,18 +16,67 @@ export async function importTerritories( if (existing) { await db.territory.update({ where: { id_congregationId: { id: existing.id, congregationId } }, - data: { type: record.type as TerritoryKind, notes: record.notes }, + data: { type: record.type as TerritoryKindKey, notes: record.notes }, }) idMap.set('territories', record.id, existing.id) } else { const created = await db.territory.create({ - data: { number: record.number, type: record.type as TerritoryKind, notes: record.notes, congregationId }, + data: { number: record.number, type: record.type as TerritoryKindKey, notes: record.notes, congregationId }, }) idMap.set('territories', record.id, created.id) } } } +/** + * Kinds are seeded per congregation before an import runs, so the archive's rows + * are matched by key rather than created — only a kind the target congregation + * does not have yet (a future custom kind) is inserted. + */ +export async function importTerritoryKinds( + zip: JsZip, + db: TransactionClient, + idMap: EntityIdMap, + congregationId: number, +): Promise { + const records = await readNdjsonFile<{ id: number; key: string; name: string | null; isBuiltIn: boolean }>( + zip, + 'territory-kinds', + ) + for (const record of records) { + const existing = await db.territoryKind.findFirst({ where: { key: record.key, congregationId } }) + if (existing) { + idMap.set('territory-kinds', record.id, existing.id) + continue + } + const created = await db.territoryKind.create({ + data: { key: record.key, name: record.name, isBuiltIn: record.isBuiltIn, congregationId }, + }) + idMap.set('territory-kinds', record.id, created.id) + } +} + +export async function importTerritoryKindAllowedRoles( + zip: JsZip, + db: TransactionClient, + idMap: EntityIdMap, + congregationId: number, +): Promise { + const records = await readNdjsonFile<{ kindId: number; roleId: number }>(zip, 'territory-kind-allowed-roles') + const data: { kindId: number; roleId: number; congregationId: number }[] = [] + + for (const record of records) { + const kindId = idMap.getOptional('territory-kinds', record.kindId) + const roleId = idMap.getOptional('roles', record.roleId) + if (!kindId || !roleId) continue + data.push({ kindId, roleId, congregationId }) + } + + if (data.length > 0) { + await db.territoryKindAllowedRole.createMany({ data, skipDuplicates: true }) + } +} + export async function importTerritoryCardOverlays( zip: JsZip, db: TransactionClient, diff --git a/app/features/settings/ui/DurationInput.tsx b/app/features/settings/ui/DurationInput.tsx new file mode 100644 index 00000000..00392307 --- /dev/null +++ b/app/features/settings/ui/DurationInput.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react' +import * as m from '~/i18n/paraglide/messages' +import { Input } from '~/shared/ui/input' +import { Label } from '~/shared/ui/label' + +function formatDayHint(days: number): string { + if (days < 14) return `${days} jour${days > 1 ? 's' : ''}` + if (days < 28) { + const weeks = Math.round(days / 7) + return `= ${weeks} semaine${weeks > 1 ? 's' : ''}` + } + const months = Math.round(days / 30) + return `≈ ${months} mois` +} + +interface Props { + field: { id: string; name: string; errors?: string[] } + label: string + hint: string + defaultValue: number + onChange: () => void +} + +/** A day count with a live "≈ 4 mois" readout beside it. */ +export function DurationInput({ field, label, hint, defaultValue, onChange }: Props) { + const [hint_, setHint] = useState(formatDayHint(defaultValue)) + + return ( +
+ +
+ { + const v = Number(e.target.value) + if (v > 0) setHint(formatDayHint(v)) + onChange() + }} + /> + {m.settings_territories_attribution_duration_days_unit()} + {hint_} +
+ {field.errors &&

{field.errors}

} +

{hint}

+
+ ) +} diff --git a/app/features/settings/ui/TerritoryKindSettingsList.tsx b/app/features/settings/ui/TerritoryKindSettingsList.tsx new file mode 100644 index 00000000..b4c676f5 --- /dev/null +++ b/app/features/settings/ui/TerritoryKindSettingsList.tsx @@ -0,0 +1,90 @@ +import { TerritoryKindKey } from '~/features/territories' +import * as m from '~/i18n/paraglide/messages' +import { Checkbox } from '~/shared/ui/checkbox' +import { Label } from '~/shared/ui/label' +import { type RoleOption, RolePicker } from '~/shared/ui/RolePicker' +import { Separator } from '~/shared/ui/separator' + +export interface TerritoryKindRow { + id: number + key: string + name: string | null + allowedRoleIds: number[] +} + +interface Props { + kinds: TerritoryKindRow[] + roles: RoleOption[] + phoneTypeActivated: boolean + onChange: () => void +} + +/** + * Built-in kinds take their label from i18n; a custom kind (none yet) carries + * its own name. Mirrors how built-in roles resolve their display name. + */ +function kindLabel(kind: TerritoryKindRow): string { + switch (kind.key) { + case TerritoryKindKey.Classical: + return m.territories_type_classical_capitalized() + case TerritoryKindKey.Univ: + return m.territories_type_university_singular() + case TerritoryKindKey.Commerces: + return m.territories_type_commerces() + case TerritoryKindKey.Phone: + return m.territories_type_phone_singular() + case TerritoryKindKey.Hotel: + return m.territories_type_hotel() + default: + return kind.name ?? kind.key + } +} + +/** + * One row per territory kind: which roles a publisher must hold to be + * attributed a territory of that kind, and — for Phone, the only kind that has + * one — its activation switch. + */ +export function TerritoryKindSettingsList({ kinds, roles, phoneTypeActivated, onChange }: Props) { + return ( +
+

{m.settings_territories_kinds_hint()}

+ {kinds.map((kind, index) => { + const labelId = `kind-roles-label-${kind.key}` + return ( +
+ {index > 0 && } +

{kindLabel(kind)}

+ {kind.key === TerritoryKindKey.Phone && ( +
+ + +
+ )} + + +
+ ) + })} +
+ ) +} diff --git a/app/features/territories/index.server.ts b/app/features/territories/index.server.ts index 0e7e1f50..fea44ab2 100644 --- a/app/features/territories/index.server.ts +++ b/app/features/territories/index.server.ts @@ -1,5 +1,6 @@ // Public server-only surface of the territories feature. +export { findAttributablePublishers } from './server/attributable-publishers.queries' export * as attributionAggregate from './server/attribution.aggregate' export { findActiveAttributionsForPublisher } from './server/attributions.server' export { @@ -12,3 +13,5 @@ export { territoryNotifications } from './server/notifications.server' export { assertAllowedOpenDataUrl, banoUrlWriteError } from './server/open-data-allowlist.server' export { clearPerimeter, getPerimeter, setPerimeter } from './server/perimeter.server' export { getAllowedZips, parseZips, serializeZips } from './server/settings.server' +export { getKindAllowedRoleIds, listTerritoryKindsWithRoles } from './server/territory-kinds.queries' +export { seedBuiltInTerritoryKinds, setKindAllowedRoles } from './server/territory-kinds.server' diff --git a/app/features/territories/index.ts b/app/features/territories/index.ts index 6d9e27db..3b335760 100644 --- a/app/features/territories/index.ts +++ b/app/features/territories/index.ts @@ -13,7 +13,7 @@ export { } from './model/card-overlay' export type { EntranceKind } from './model/entrance-kind.type' export type { TerritoryAttributionKind } from './model/territory-attribution-kind.type' -export { TerritoryKind } from './model/territory-kind.type' +export { TerritoryKindKey } from './model/territory-kind.type' export { AttributionStatus } from './ui/AttributionStatus' export { default as CardOverlayMap } from './ui/CardOverlayMap' export { ColorPicker } from './ui/ColorPicker' diff --git a/app/features/territories/model/stats-filter-defaults.ts b/app/features/territories/model/stats-filter-defaults.ts index 9094c3ca..76a4b69d 100644 --- a/app/features/territories/model/stats-filter-defaults.ts +++ b/app/features/territories/model/stats-filter-defaults.ts @@ -1,11 +1,11 @@ import { AttributionCategory } from './attribution-category' -import { TerritoryKind } from './territory-kind.type' +import { TerritoryKindKey } from './territory-kind.type' // Server truth (`parseStatsFilterParams`) applies these when the URL has no // `kind` / `attributionKind` params. UI code that mirrors the current filter // scope (chip bar, dialog defaults) reads from here so display and query stay // in lockstep. -export const DEFAULT_TERRITORY_KINDS: TerritoryKind[] = [TerritoryKind.Classical] +export const DEFAULT_TERRITORY_KINDS: TerritoryKindKey[] = [TerritoryKindKey.Classical] export const DEFAULT_ATTRIBUTION_KINDS: AttributionCategory[] = [ AttributionCategory.Default, diff --git a/app/features/territories/model/territory-kind.type.ts b/app/features/territories/model/territory-kind.type.ts index f0909cd3..4bc3544a 100644 --- a/app/features/territories/model/territory-kind.type.ts +++ b/app/features/territories/model/territory-kind.type.ts @@ -1 +1,6 @@ -export { TerritoryKind } from '~/database/generated/enums' +/** + * The key set of the built-in territory kinds. `TerritoryKind` (the table) is + * the entity — this enum names its built-in keys and still types + * `Territory.type` until territories move onto the FK. + */ +export { TerritoryKindKey } from '~/database/generated/enums' diff --git a/app/features/territories/routes/api/entrances-in-bbox-params.test.ts b/app/features/territories/routes/api/entrances-in-bbox-params.test.ts index d33f07d6..343ea0f9 100644 --- a/app/features/territories/routes/api/entrances-in-bbox-params.test.ts +++ b/app/features/territories/routes/api/entrances-in-bbox-params.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { parseEntrancesInBboxParams } from './entrances-in-bbox-params' const wideBbox = '48.0,2.0,49.0,3.0' @@ -37,13 +37,13 @@ describe('parseEntrancesInBboxParams — edit mode (default)', () => { }) describe('parseEntrancesInBboxParams — create mode', () => { - it('parses a valid bbox + TerritoryKind', () => { - const params = new URLSearchParams({ bbox: wideBbox, mode: 'create', kind: TerritoryKind.Commerces }) + it('parses a valid bbox + TerritoryKindKey', () => { + const params = new URLSearchParams({ bbox: wideBbox, mode: 'create', kind: TerritoryKindKey.Commerces }) const result = parseEntrancesInBboxParams(params) expect(result).toEqual({ mode: 'create', bbox: { swLat: 48, swLng: 2, neLat: 49, neLng: 3 }, - kind: TerritoryKind.Commerces, + kind: TerritoryKindKey.Commerces, }) }) @@ -58,12 +58,12 @@ describe('parseEntrancesInBboxParams — create mode', () => { }) it('rejects an unknown mode', () => { - const params = new URLSearchParams({ bbox: wideBbox, mode: 'delete', kind: TerritoryKind.Commerces }) + const params = new URLSearchParams({ bbox: wideBbox, mode: 'delete', kind: TerritoryKindKey.Commerces }) expect(parseEntrancesInBboxParams(params)).toBeNull() }) it('rejects an empty bbox even when mode + kind are valid', () => { - const params = new URLSearchParams({ mode: 'create', kind: TerritoryKind.Commerces }) + const params = new URLSearchParams({ mode: 'create', kind: TerritoryKindKey.Commerces }) expect(parseEntrancesInBboxParams(params)).toBeNull() }) @@ -71,14 +71,14 @@ describe('parseEntrancesInBboxParams — create mode', () => { const params = new URLSearchParams({ bbox: wideBbox, mode: 'create', - kind: TerritoryKind.Hotel, + kind: TerritoryKindKey.Hotel, territoryId: '999', }) const result = parseEntrancesInBboxParams(params) expect(result).toEqual({ mode: 'create', bbox: { swLat: 48, swLng: 2, neLat: 49, neLng: 3 }, - kind: TerritoryKind.Hotel, + kind: TerritoryKindKey.Hotel, }) }) }) diff --git a/app/features/territories/routes/api/entrances-in-bbox-params.ts b/app/features/territories/routes/api/entrances-in-bbox-params.ts index 1f36c013..15f8458c 100644 --- a/app/features/territories/routes/api/entrances-in-bbox-params.ts +++ b/app/features/territories/routes/api/entrances-in-bbox-params.ts @@ -1,9 +1,9 @@ import type { Bbox } from '~/features/territories/model/bbox.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' export type EntrancesInBboxParams = | { mode: 'edit'; bbox: Bbox; territoryId: number } - | { mode: 'create'; bbox: Bbox; kind: TerritoryKind } + | { mode: 'create'; bbox: Bbox; kind: TerritoryKindKey } function parseBbox(value: string | null): Bbox | null { if (!value) return null @@ -13,9 +13,9 @@ function parseBbox(value: string | null): Bbox | null { return { swLat, swLng, neLat, neLng } } -function parseKind(value: string | null): TerritoryKind | null { +function parseKind(value: string | null): TerritoryKindKey | null { if (value == null) return null - return (Object.values(TerritoryKind) as string[]).includes(value) ? (value as TerritoryKind) : null + return (Object.values(TerritoryKindKey) as string[]).includes(value) ? (value as TerritoryKindKey) : null } export function parseEntrancesInBboxParams(searchParams: URLSearchParams): EntrancesInBboxParams | null { diff --git a/app/features/territories/routes/attributions/edit.tsx b/app/features/territories/routes/attributions/edit.tsx index 0a21b2a4..a77626f7 100644 --- a/app/features/territories/routes/attributions/edit.tsx +++ b/app/features/territories/routes/attributions/edit.tsx @@ -3,9 +3,9 @@ import { parseWithZod } from '@conform-to/zod' import { ArrowDownToLine, X } from 'lucide-react' import { useState } from 'react' import { data, Form, redirect } from 'react-router' -import { getPublishers } from '~/features/publishers/index.server' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' import { updateAttributionSchema } from '~/features/territories/schemas/attribution.schema' +import { findAttributablePublishers } from '~/features/territories/server/attributable-publishers.queries' import { aggregateEntrance } from '~/features/territories/server/buildings.server' import { updateAttribution } from '~/features/territories/server/update-attribution.server' import { AttributionKindBadge } from '~/features/territories/ui/AttributionKindBadge' @@ -66,7 +66,11 @@ export function loader({ params, context }: Route.LoaderArgs) { throw redirect('/territories/attributions') } - const users = await getPublishers(db, congregationId) + // The attribution's own publisher stays listed even if they no longer hold + // an allowed role, so tightening a kind never locks an existing attribution. + const users = await findAttributablePublishers(db, attribution.territory.type, congregationId, { + alwaysIncludeMemberId: attribution.publisherId, + }) return { users, phoneTypeActive, attribution, entrances: attribution.territory.entrances.map(aggregateEntrance) } }) @@ -276,6 +280,11 @@ export async function action({ request, params, context }: Route.ActionArgs) { if (err instanceof ConflictError && err.message === 'attribution_overlap') { return data(submission.reply({ formErrors: [m.attributions_overlap_error()] }), { status: 409 }) } + if (err instanceof ConflictError && err.message === 'publisher_role_not_allowed') { + return data(submission.reply({ fieldErrors: { publisher: [m.attributions_publisher_role_error()] } }), { + status: 409, + }) + } throw err } }) diff --git a/app/features/territories/routes/attributions/new.tsx b/app/features/territories/routes/attributions/new.tsx index a9f84dcc..aa23c73d 100644 --- a/app/features/territories/routes/attributions/new.tsx +++ b/app/features/territories/routes/attributions/new.tsx @@ -3,6 +3,7 @@ import { parseWithZod } from '@conform-to/zod' import { data, Form, redirect } from 'react-router' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' import { createAttributionSchema } from '~/features/territories/schemas/attribution.schema' +import { findAttributablePublishers } from '~/features/territories/server/attributable-publishers.queries' import { aggregateEntrance } from '~/features/territories/server/buildings.server' import { getActiveCampaign } from '~/features/territories/server/campaign.queries' import { createAttribution } from '~/features/territories/server/create-attribution.server' @@ -66,19 +67,9 @@ export function loader({ request, context }: Route.LoaderArgs) { throw redirect('/territories/attributions/new/available-territories') } - const users = await db.member.findMany({ - where: { - isPublisher: true, - leftAt: null, - congregationId, - }, - orderBy: [ - { - lastname: 'asc', - }, - { firstname: 'asc' }, - ], - }) + // Only publishers holding a role this territory's kind allows. An + // unrestricted kind yields every active publisher, as before role gating. + const users = await findAttributablePublishers(db, territory.type, congregationId) return { users, @@ -249,6 +240,11 @@ export async function action({ request, context }: Route.ActionArgs) { if (err instanceof ConflictError && err.message === 'territory_occupied') { return data(submission.reply({ formErrors: [m.attributions_territory_occupied_error()] }), { status: 409 }) } + if (err instanceof ConflictError && err.message === 'publisher_role_not_allowed') { + return data(submission.reply({ fieldErrors: { publisher: [m.attributions_publisher_role_error()] } }), { + status: 409, + }) + } throw err } }) diff --git a/app/features/territories/routes/attributions/territories.tsx b/app/features/territories/routes/attributions/territories.tsx index 66540880..219c8aec 100644 --- a/app/features/territories/routes/attributions/territories.tsx +++ b/app/features/territories/routes/attributions/territories.tsx @@ -1,7 +1,7 @@ import { ExternalLink, Send } from 'lucide-react' import React from 'react' import { Link, redirect, useSearchParams } from 'react-router' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { getZips } from '~/features/territories/server/buildings.server' import { getActiveCampaign } from '~/features/territories/server/campaign.queries' import { classifySearch } from '~/features/territories/server/search-intent.server' @@ -29,11 +29,11 @@ import { formatDistance } from '~/shared/utils/distance' import { sortFromUrl } from '~/shared/utils/pagination.server' const territoryTypeLabels: Record = { - [TerritoryKind.Classical]: m.territories_type_classical(), - [TerritoryKind.Commerces]: m.territories_type_commerces(), - [TerritoryKind.Phone]: m.territories_type_phone(), - [TerritoryKind.Hotel]: m.territories_type_hotel(), - [TerritoryKind.Univ]: m.territories_type_university(), + [TerritoryKindKey.Classical]: m.territories_type_classical(), + [TerritoryKindKey.Commerces]: m.territories_type_commerces(), + [TerritoryKindKey.Phone]: m.territories_type_phone(), + [TerritoryKindKey.Hotel]: m.territories_type_hotel(), + [TerritoryKindKey.Univ]: m.territories_type_university(), } import type { Route } from './+types/territories' diff --git a/app/features/territories/routes/my-territories/list.tsx b/app/features/territories/routes/my-territories/list.tsx index e667b86e..05b28993 100644 --- a/app/features/territories/routes/my-territories/list.tsx +++ b/app/features/territories/routes/my-territories/list.tsx @@ -1,7 +1,7 @@ import { ChevronRight, Download, MapPin, Pause } from 'lucide-react' import { Link } from 'react-router' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { getActiveCampaign } from '~/features/territories/server/campaign.queries' import { getUserTerritoriesWithDetails, @@ -65,23 +65,23 @@ function statusLabel(status: TerritoryStatus): string { } function territoryTypeLabel(type: string): string { - if (type === TerritoryKind.Phone) return m.territories_type_phone_singular() - if (type === TerritoryKind.Commerces) return m.territories_type_commerces() - if (type === TerritoryKind.Hotel) return m.territories_type_hotel_singular() - if (type === TerritoryKind.Univ) return m.territories_type_university_singular() + if (type === TerritoryKindKey.Phone) return m.territories_type_phone_singular() + if (type === TerritoryKindKey.Commerces) return m.territories_type_commerces() + if (type === TerritoryKindKey.Hotel) return m.territories_type_hotel_singular() + if (type === TerritoryKindKey.Univ) return m.territories_type_university_singular() return m.territories_type_classical() } function quantityLabel(type: string, entrances: { homes: number | null; phones: number | null }[]): string { - if (type === TerritoryKind.Phone) { + if (type === TerritoryKindKey.Phone) { const count = entrances.reduce((acc, e) => acc + (e.phones ?? 0), 0) return m.my_territories_phones_count({ count }) } - if (type === TerritoryKind.Classical || type === TerritoryKind.Univ) { + if (type === TerritoryKindKey.Classical || type === TerritoryKindKey.Univ) { const count = entrances.reduce((acc, e) => acc + ((e.homes ?? 0) || (e.phones ?? 0)), 0) return m.my_territories_homes_count({ count }) } - if (type === TerritoryKind.Commerces) { + if (type === TerritoryKindKey.Commerces) { return m.my_territories_commerces_count({ count: entrances.length }) } return m.my_territories_entrances_count({ count: entrances.length }) diff --git a/app/features/territories/routes/split-tool/_layout.tsx b/app/features/territories/routes/split-tool/_layout.tsx index 67c31f1d..ae98fb12 100644 --- a/app/features/territories/routes/split-tool/_layout.tsx +++ b/app/features/territories/routes/split-tool/_layout.tsx @@ -1,5 +1,5 @@ import { NavLink, Outlet, useSearchParams } from 'react-router' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { countAvailableEntrances, getZips } from '~/features/territories/server/buildings.server' import { buildTerritoryFilterChips } from '~/features/territories/ui/build-filter-chips' import TerritoryFilters from '~/features/territories/ui/TerritoryFilters' @@ -34,11 +34,11 @@ export function loader({ context }: Route.LoaderArgs) { (await getBoolSetting(db, TerritorySettingKey.TerritoryTypePhoneActive, congregationId)) ?? false const ctx = { phoneTypeActive } const [classical, phones, commerce, campus, hotel, zips] = await Promise.all([ - countAvailableEntrances(db, congregationId, TerritoryKind.Classical, ctx), - countAvailableEntrances(db, congregationId, TerritoryKind.Phone, ctx), - countAvailableEntrances(db, congregationId, TerritoryKind.Commerces, ctx), - countAvailableEntrances(db, congregationId, TerritoryKind.Univ, ctx), - countAvailableEntrances(db, congregationId, TerritoryKind.Hotel, ctx), + countAvailableEntrances(db, congregationId, TerritoryKindKey.Classical, ctx), + countAvailableEntrances(db, congregationId, TerritoryKindKey.Phone, ctx), + countAvailableEntrances(db, congregationId, TerritoryKindKey.Commerces, ctx), + countAvailableEntrances(db, congregationId, TerritoryKindKey.Univ, ctx), + countAvailableEntrances(db, congregationId, TerritoryKindKey.Hotel, ctx), getZips(db, congregationId), ]) diff --git a/app/features/territories/routes/split-tool/commerces.tsx b/app/features/territories/routes/split-tool/commerces.tsx index b5d67749..14ec0f17 100644 --- a/app/features/territories/routes/split-tool/commerces.tsx +++ b/app/features/territories/routes/split-tool/commerces.tsx @@ -1,4 +1,4 @@ -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { countAvailableEntrances } from '~/features/territories/server/buildings.server' import { computeNextTerritoryNumber } from '~/features/territories/server/compute-next-territory-number.server' import { getCongregationCenter } from '~/features/territories/server/get-congregation-center.server' @@ -33,9 +33,9 @@ export function loader({ context }: Route.LoaderArgs) { const phoneTypeActive = (await getBoolSetting(db, TerritorySettingKey.TerritoryTypePhoneActive, congregationId)) ?? false const [suggestedNumber, fallbackCenter, counts] = await Promise.all([ - computeNextTerritoryNumber(db, congregationId, TerritoryKind.Commerces), + computeNextTerritoryNumber(db, congregationId, TerritoryKindKey.Commerces), getCongregationCenter(db, congregationId), - countAvailableEntrances(db, congregationId, TerritoryKind.Commerces, { phoneTypeActive }), + countAvailableEntrances(db, congregationId, TerritoryKindKey.Commerces, { phoneTypeActive }), ]) return { apiKey, suggestedNumber, fallbackCenter, counts } }) @@ -46,7 +46,7 @@ export default function BuildingListPage({ loaderData }: Route.ComponentProps) { return ( = { - [TerritoryKind.Classical]: m.territories_type_classical(), - [TerritoryKind.Commerces]: m.territories_type_commerces(), - [TerritoryKind.Phone]: m.territories_type_phone(), - [TerritoryKind.Hotel]: m.territories_type_hotel(), - [TerritoryKind.Univ]: m.territories_type_university(), + [TerritoryKindKey.Classical]: m.territories_type_classical(), + [TerritoryKindKey.Commerces]: m.territories_type_commerces(), + [TerritoryKindKey.Phone]: m.territories_type_phone(), + [TerritoryKindKey.Hotel]: m.territories_type_hotel(), + [TerritoryKindKey.Univ]: m.territories_type_university(), } export const meta: Route.MetaFunction = () => { diff --git a/app/features/territories/routes/territory/new.tsx b/app/features/territories/routes/territory/new.tsx index c5661510..b56ea4d4 100644 --- a/app/features/territories/routes/territory/new.tsx +++ b/app/features/territories/routes/territory/new.tsx @@ -4,7 +4,7 @@ import { ExternalLink, Trash2 } from 'lucide-react' import { useState } from 'react' import { data, Form, Link, redirect } from 'react-router' import { getSession } from '~/features/authentication/index.server' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { createTerritorySchema } from '~/features/territories/schemas/territory.schema' import { aggregateEntrance } from '~/features/territories/server/buildings.server' import { createTerritory } from '~/features/territories/server/create-territory.server' @@ -120,20 +120,20 @@ export default function NewTerritoryPage({ loaderData, actionData }: Route.Compo
- - + {m.territories_type_classical_capitalized()} - {m.territories_type_commerces()} - {m.territories_type_hotel()} + {m.territories_type_commerces()} + {m.territories_type_hotel()} {phoneTypeActive && ( - {m.territories_type_phone_singular()} + {m.territories_type_phone_singular()} )} - {m.territories_type_university_singular()} + {m.territories_type_university_singular()} {fields.type.errors &&

{fields.type.errors}

} diff --git a/app/features/territories/routes/territory/view.tsx b/app/features/territories/routes/territory/view.tsx index 75fa85a2..df743532 100644 --- a/app/features/territories/routes/territory/view.tsx +++ b/app/features/territories/routes/territory/view.tsx @@ -12,7 +12,7 @@ import { import { Link, redirect } from 'react-router' import type { Attribution, Member } from '~/database/generated/client' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { findAdjacentTerritories, findTerritoryWithHistory } from '~/features/territories/server/attributions.server' import { aggregateEntrance } from '~/features/territories/server/buildings.server' import { entranceContentLabel } from '~/features/territories/server/entrance-content-label' @@ -83,11 +83,11 @@ export function loader({ request, params, context }: Route.LoaderArgs) { function getTerritoryTypeLabel(type: string): string { const labels: Record string> = { - [TerritoryKind.Classical]: () => m.territories_type_classical_capitalized(), - [TerritoryKind.Commerces]: () => m.territories_type_commerces(), - [TerritoryKind.Hotel]: () => m.territories_type_hotel(), - [TerritoryKind.Phone]: () => m.territories_type_phone_singular(), - [TerritoryKind.Univ]: () => m.territories_type_university_singular(), + [TerritoryKindKey.Classical]: () => m.territories_type_classical_capitalized(), + [TerritoryKindKey.Commerces]: () => m.territories_type_commerces(), + [TerritoryKindKey.Hotel]: () => m.territories_type_hotel(), + [TerritoryKindKey.Phone]: () => m.territories_type_phone_singular(), + [TerritoryKindKey.Univ]: () => m.territories_type_university_singular(), } return labels[type]?.() ?? type } @@ -355,7 +355,7 @@ export default function ViewTerritoryPage({ loaderData }: Route.ComponentProps)
{m.territories_view_type_label()}
{getTerritoryTypeLabel(territory.type)}
- {territory.type === TerritoryKind.Phone + {territory.type === TerritoryKindKey.Phone ? m.territories_view_phones_count_label() : m.territories_view_homes_count_label()}
diff --git a/app/features/territories/schemas/building.schema.ts b/app/features/territories/schemas/building.schema.ts index 614989b2..691f5363 100644 --- a/app/features/territories/schemas/building.schema.ts +++ b/app/features/territories/schemas/building.schema.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' export const createBuildingSchema = z.object({ number: z.string().min(1), @@ -22,7 +22,7 @@ export const buildingNotesSchema = z.object({ }) export const splitToolCreateSchema = z.object({ - type: z.nativeEnum(TerritoryKind), + type: z.nativeEnum(TerritoryKindKey), entranceIds: z.string().min(1), }) diff --git a/app/features/territories/schemas/territory.schema.ts b/app/features/territories/schemas/territory.schema.ts index 35ef5c4f..626b9221 100644 --- a/app/features/territories/schemas/territory.schema.ts +++ b/app/features/territories/schemas/territory.schema.ts @@ -1,9 +1,9 @@ import { z } from 'zod' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' export const createTerritorySchema = z.object({ number: z.string().min(1), - type: z.nativeEnum(TerritoryKind), + type: z.nativeEnum(TerritoryKindKey), entrances: z .array(z.coerce.number()) .or(z.coerce.number().transform(v => [v])) diff --git a/app/features/territories/server/aggregate-attribution-stats.server.test.ts b/app/features/territories/server/aggregate-attribution-stats.server.test.ts index 6f432898..0840d508 100644 --- a/app/features/territories/server/aggregate-attribution-stats.server.test.ts +++ b/app/features/territories/server/aggregate-attribution-stats.server.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { StatsFilterParams } from './stats-filter-params.type' vi.mock('~/shared/infra/db.server', () => ({ @@ -13,7 +13,7 @@ const { aggregateAttributionStatsForWindow } = await import('./aggregate-attribu const { unscopedDb: db } = await import('~/shared/infra/db.server') const baseParams: StatsFilterParams = { - territoryKind: [TerritoryKind.Classical], + territoryKind: [TerritoryKindKey.Classical], attributionKind: [TerritoryAttributionKind.Default], startDate: new Date(2025, 0, 1), endDate: new Date(2025, 11, 31), diff --git a/app/features/territories/server/attributable-publishers.queries.test.ts b/app/features/territories/server/attributable-publishers.queries.test.ts new file mode 100644 index 00000000..2eadd32c --- /dev/null +++ b/app/features/territories/server/attributable-publishers.queries.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('~/shared/auth/permissions.server', () => ({ + findMembersWithAnyRole: vi.fn(), +})) + +vi.mock('./territory-kinds.queries', () => ({ + getKindAllowedRoleIds: vi.fn(), +})) + +vi.mock('~/shared/infra/db.server', () => ({ + unscopedDb: { + member: { findMany: vi.fn() }, + }, +})) + +const { findAttributablePublishers } = await import('./attributable-publishers.queries') +const { unscopedDb: db } = await import('~/shared/infra/db.server') +const { findMembersWithAnyRole } = await import('~/shared/auth/permissions.server') +const { getKindAllowedRoleIds } = await import('./territory-kinds.queries') + +const PUBLISHERS = [ + { id: 1, firstname: 'Marc', lastname: 'Dupont' }, + { id: 2, firstname: 'Anne', lastname: 'Leroy' }, + { id: 3, firstname: 'Paul', lastname: 'Martin' }, +] + +beforeEach(() => { + vi.resetAllMocks() + vi.mocked(db.member.findMany).mockResolvedValue(PUBLISHERS as never) +}) + +describe('findAttributablePublishers', () => { + it('returns every active publisher when the kind carries no restriction', async () => { + vi.mocked(getKindAllowedRoleIds).mockResolvedValue([]) + + const result = await findAttributablePublishers(db, 'Classical', 4) + + expect(result.map(p => p.id)).toEqual([1, 2, 3]) + expect(findMembersWithAnyRole).not.toHaveBeenCalled() + }) + + it('keeps only publishers holding one of the allowed roles', async () => { + vi.mocked(getKindAllowedRoleIds).mockResolvedValue([7]) + vi.mocked(findMembersWithAnyRole).mockResolvedValue([3, 1]) + + const result = await findAttributablePublishers(db, 'Phone', 4) + + expect(result.map(p => p.id)).toEqual([1, 3]) + expect(findMembersWithAnyRole).toHaveBeenCalledWith(db, [7], 4) + }) + + it('preserves the publisher ordering rather than the eligibility ordering', async () => { + vi.mocked(getKindAllowedRoleIds).mockResolvedValue([7]) + vi.mocked(findMembersWithAnyRole).mockResolvedValue([3, 2, 1]) + + const result = await findAttributablePublishers(db, 'Phone', 4) + + expect(result.map(p => p.id)).toEqual([1, 2, 3]) + }) + + it('keeps the already-attributed publisher listed even when they no longer qualify', async () => { + vi.mocked(getKindAllowedRoleIds).mockResolvedValue([7]) + vi.mocked(findMembersWithAnyRole).mockResolvedValue([1]) + + const result = await findAttributablePublishers(db, 'Phone', 4, { alwaysIncludeMemberId: 2 }) + + expect(result.map(p => p.id)).toEqual([1, 2]) + }) + + it('does not duplicate the already-attributed publisher when they do qualify', async () => { + vi.mocked(getKindAllowedRoleIds).mockResolvedValue([7]) + vi.mocked(findMembersWithAnyRole).mockResolvedValue([1, 2]) + + const result = await findAttributablePublishers(db, 'Phone', 4, { alwaysIncludeMemberId: 2 }) + + expect(result.map(p => p.id)).toEqual([1, 2]) + }) + + it('returns nobody when the kind requires a role no publisher holds', async () => { + vi.mocked(getKindAllowedRoleIds).mockResolvedValue([7]) + vi.mocked(findMembersWithAnyRole).mockResolvedValue([]) + + expect(await findAttributablePublishers(db, 'Phone', 4)).toEqual([]) + }) +}) diff --git a/app/features/territories/server/attributable-publishers.queries.ts b/app/features/territories/server/attributable-publishers.queries.ts new file mode 100644 index 00000000..63dfd12c --- /dev/null +++ b/app/features/territories/server/attributable-publishers.queries.ts @@ -0,0 +1,43 @@ +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' +import { findMembersWithAnyRole } from '~/shared/auth/permissions.server' +import type { TransactionClient } from '~/shared/infra/db.server' +import { getKindAllowedRoleIds } from './territory-kinds.queries' + +interface Options { + /** + * Keep this member in the list even if they no longer hold an allowed role. + * The edit form passes the publisher already on the attribution, so tightening + * a kind's roles never makes an existing attribution uneditable. + */ + alwaysIncludeMemberId?: number +} + +/** + * The publishers who may be attributed a territory of this kind, in the order + * the picker shows them (surname, then first name). + * + * A kind with no allowed roles is unrestricted — every active publisher + * qualifies, which is the behaviour that predates role gating. Otherwise the + * roster is intersected with the members holding at least one allowed role. + * `findMembersWithAnyRole` is the canonical resolver: it unions the identity + * roles on `MemberRoleAssignment` with the custom roles on the linked account. + */ +export async function findAttributablePublishers( + db: TransactionClient, + kindKey: TerritoryKindKey, + congregationId: number, + options?: Options, +) { + const publishers = await db.member.findMany({ + where: { isPublisher: true, leftAt: null, congregationId }, + orderBy: [{ lastname: 'asc' }, { firstname: 'asc' }], + }) + + const allowedRoleIds = await getKindAllowedRoleIds(db, kindKey, congregationId) + if (allowedRoleIds.length === 0) return publishers + + const eligibleIds = new Set(await findMembersWithAnyRole(db, allowedRoleIds, congregationId)) + if (options?.alwaysIncludeMemberId != null) eligibleIds.add(options.alwaysIncludeMemberId) + + return publishers.filter(publisher => eligibleIds.has(publisher.id)) +} diff --git a/app/features/territories/server/attribution-eligibility.policy.test.ts b/app/features/territories/server/attribution-eligibility.policy.test.ts new file mode 100644 index 00000000..3cd6a252 --- /dev/null +++ b/app/features/territories/server/attribution-eligibility.policy.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('~/shared/auth/permissions.server', () => ({ + findMembersWithAnyRole: vi.fn(), +})) + +vi.mock('./territory-kinds.queries', () => ({ + getKindAllowedRoleIds: vi.fn(), +})) + +vi.mock('~/shared/infra/db.server', () => ({ + unscopedDb: { + territory: { findFirst: vi.fn() }, + attribution: { findFirst: vi.fn() }, + }, +})) + +const { assertPublisherAllowedForKind, assertPublisherAllowedForAttribution } = await import( + './attribution-eligibility.policy' +) +const { unscopedDb: db } = await import('~/shared/infra/db.server') +const { findMembersWithAnyRole } = await import('~/shared/auth/permissions.server') +const { getKindAllowedRoleIds } = await import('./territory-kinds.queries') +const { ConflictError } = await import('~/shared/errors/app-error.server') + +beforeEach(() => { + vi.resetAllMocks() +}) + +describe('assertPublisherAllowedForKind', () => { + it('passes when the kind carries no restriction', async () => { + vi.mocked(getKindAllowedRoleIds).mockResolvedValue([]) + + await expect(assertPublisherAllowedForKind(db, 'Classical', 5, 4)).resolves.toBeUndefined() + expect(findMembersWithAnyRole).not.toHaveBeenCalled() + }) + + it('passes when the publisher holds one of the allowed roles', async () => { + vi.mocked(getKindAllowedRoleIds).mockResolvedValue([7]) + vi.mocked(findMembersWithAnyRole).mockResolvedValue([5, 9]) + + await expect(assertPublisherAllowedForKind(db, 'Phone', 5, 4)).resolves.toBeUndefined() + }) + + it('rejects a publisher who holds none of the allowed roles', async () => { + vi.mocked(getKindAllowedRoleIds).mockResolvedValue([7]) + vi.mocked(findMembersWithAnyRole).mockResolvedValue([9]) + + await expect(assertPublisherAllowedForKind(db, 'Phone', 5, 4)).rejects.toThrow(ConflictError) + await expect(assertPublisherAllowedForKind(db, 'Phone', 5, 4)).rejects.toThrow('publisher_role_not_allowed') + }) +}) + +describe('assertPublisherAllowedForAttribution', () => { + it('resolves the kind from the attribution territory before checking', async () => { + vi.mocked(db.attribution.findFirst).mockResolvedValue({ + publisherId: 2, + territory: { type: 'Phone' }, + } as never) + vi.mocked(getKindAllowedRoleIds).mockResolvedValue([7]) + vi.mocked(findMembersWithAnyRole).mockResolvedValue([5]) + + await expect(assertPublisherAllowedForAttribution(db, 11, 5, 4)).resolves.toBeUndefined() + expect(getKindAllowedRoleIds).toHaveBeenCalledWith(db, 'Phone', 4) + }) + + it('rejects a change to a publisher who does not qualify for the territory kind', async () => { + vi.mocked(db.attribution.findFirst).mockResolvedValue({ + publisherId: 2, + territory: { type: 'Phone' }, + } as never) + vi.mocked(getKindAllowedRoleIds).mockResolvedValue([7]) + vi.mocked(findMembersWithAnyRole).mockResolvedValue([9]) + + await expect(assertPublisherAllowedForAttribution(db, 11, 5, 4)).rejects.toThrow('publisher_role_not_allowed') + }) + + it('leaves an unchanged publisher alone, so tightening a kind cannot lock an attribution', async () => { + vi.mocked(db.attribution.findFirst).mockResolvedValue({ + publisherId: 5, + territory: { type: 'Phone' }, + } as never) + vi.mocked(getKindAllowedRoleIds).mockResolvedValue([7]) + vi.mocked(findMembersWithAnyRole).mockResolvedValue([9]) + + await expect(assertPublisherAllowedForAttribution(db, 11, 5, 4)).resolves.toBeUndefined() + expect(getKindAllowedRoleIds).not.toHaveBeenCalled() + }) + + it('passes when the attribution is gone — the aggregate reports the real failure', async () => { + vi.mocked(db.attribution.findFirst).mockResolvedValue(null as never) + + await expect(assertPublisherAllowedForAttribution(db, 11, 5, 4)).resolves.toBeUndefined() + expect(getKindAllowedRoleIds).not.toHaveBeenCalled() + }) +}) diff --git a/app/features/territories/server/attribution-eligibility.policy.ts b/app/features/territories/server/attribution-eligibility.policy.ts new file mode 100644 index 00000000..51cc311c --- /dev/null +++ b/app/features/territories/server/attribution-eligibility.policy.ts @@ -0,0 +1,57 @@ +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' +import { findMembersWithAnyRole } from '~/shared/auth/permissions.server' +import { ConflictError } from '~/shared/errors/app-error.server' +import type { TransactionClient } from '~/shared/infra/db.server' +import { getKindAllowedRoleIds } from './territory-kinds.queries' + +/** + * Role gating for territory attribution. + * + * Deliberately called from `createAttribution` / `updateAttribution` rather than + * from the aggregate. Those two delegators are the human-initiated path — the + * attribution routes — so posting straight at the route cannot slip past the + * check, while `campaign-lifecycle.workflow` (which calls the aggregate + * directly) stays exempt. That sweep re-attributes a pairing that already + * existed, and it swallows ConflictError, so gating it would silently strip a + * publisher's territory when a kind's roles change. + */ +export async function assertPublisherAllowedForKind( + db: TransactionClient, + kindKey: TerritoryKindKey, + publisherId: number, + congregationId: number, +): Promise { + const allowedRoleIds = await getKindAllowedRoleIds(db, kindKey, congregationId) + if (allowedRoleIds.length === 0) return + + const eligibleIds = await findMembersWithAnyRole(db, allowedRoleIds, congregationId) + if (!eligibleIds.includes(publisherId)) throw new ConflictError('publisher_role_not_allowed') +} + +/** + * Same check for an edit, where the kind has to be resolved through the + * attribution's territory. + * + * Only a *change* of publisher is gated. Leaving the publisher as-is always + * passes, so tightening a kind's roles never locks an existing attribution out + * of being edited — it only stops that publisher being picked somewhere new. + * This matches the picker, which keeps the current publisher listed. + * + * A missing attribution passes: the aggregate reports that as NotFoundError, + * which is the accurate failure. + */ +export async function assertPublisherAllowedForAttribution( + db: TransactionClient, + attributionId: number, + publisherId: number, + congregationId: number, +): Promise { + const attribution = await db.attribution.findFirst({ + where: { id: attributionId, congregationId }, + select: { publisherId: true, territory: { select: { type: true } } }, + }) + if (attribution == null) return + if (attribution.publisherId === publisherId) return + + await assertPublisherAllowedForKind(db, attribution.territory.type, publisherId, congregationId) +} diff --git a/app/features/territories/server/attribution.aggregate.integration.test.ts b/app/features/territories/server/attribution.aggregate.integration.test.ts index c752b6be..969304f9 100644 --- a/app/features/territories/server/attribution.aggregate.integration.test.ts +++ b/app/features/territories/server/attribution.aggregate.integration.test.ts @@ -2,7 +2,7 @@ import { PrismaPg } from '@prisma/adapter-pg' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { PrismaClient } from '~/database/generated/client' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { ConflictError } from '~/shared/errors/app-error.server' const auditMock = vi.fn() @@ -56,7 +56,7 @@ beforeAll(async () => { }) secondPublisherId = p2.id const t = await tx.territory.create({ - data: { number: `T-${ts}`, type: TerritoryKind.Classical, congregationId: congId }, + data: { number: `T-${ts}`, type: TerritoryKindKey.Classical, congregationId: congId }, }) territoryId = t.id }) diff --git a/app/features/territories/server/attribution.aggregate.test.ts b/app/features/territories/server/attribution.aggregate.test.ts index 982dd67f..cd23e05b 100644 --- a/app/features/territories/server/attribution.aggregate.test.ts +++ b/app/features/territories/server/attribution.aggregate.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { attributionsOverlap } from './attribution.aggregate' // Attribution overlap covers the 5 cases the state model exposes: @@ -112,7 +112,7 @@ beforeEach(() => { mockDb.attribution.update.mockResolvedValue({ id: 42 } as never) mockDb.attribution.findMany.mockResolvedValue([]) mockDb.attribution.findFirst.mockResolvedValue(null as never) - mockDb.territory.findUniqueOrThrow.mockResolvedValue({ type: TerritoryKind.Classical } as never) + mockDb.territory.findUniqueOrThrow.mockResolvedValue({ type: TerritoryKindKey.Classical } as never) }) describe('assign — layer-aware overlap', () => { diff --git a/app/features/territories/server/attribution.aggregate.ts b/app/features/territories/server/attribution.aggregate.ts index 9d977044..2a81e314 100644 --- a/app/features/territories/server/attribution.aggregate.ts +++ b/app/features/territories/server/attribution.aggregate.ts @@ -1,6 +1,6 @@ import type { Prisma } from '~/database/generated/client' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { AuditAction, audit } from '~/shared/domain/audit.server' import { getSetting } from '~/shared/domain/settings.server' import { ConflictError, NotFoundError } from '~/shared/errors/app-error.server' @@ -47,14 +47,14 @@ function parsePositiveDays(value: string | null | undefined, fallback: number): async function _resolveDurationDays( db: TransactionClient, attributionType: TerritoryAttributionKind, - territoryType: TerritoryKind, + territoryType: TerritoryKindKey, congregationId: number, ): Promise { if (attributionType === TerritoryAttributionKind.Phone) { const setting = await getSetting(db, TerritorySettingKey.AttributionPhoneDurationDays, congregationId) return parsePositiveDays(setting, DEFAULT_DURATION_DAYS.phone) } - if (territoryType === TerritoryKind.Commerces) { + if (territoryType === TerritoryKindKey.Commerces) { const setting = await getSetting(db, TerritorySettingKey.AttributionCommerceDurationDays, congregationId) return parsePositiveDays(setting, DEFAULT_DURATION_DAYS.commerce) } diff --git a/app/features/territories/server/attributions.server.ts b/app/features/territories/server/attributions.server.ts index 3692bda6..7106cc05 100644 --- a/app/features/territories/server/attributions.server.ts +++ b/app/features/territories/server/attributions.server.ts @@ -1,4 +1,4 @@ -import type { Prisma, TerritoryKind } from '~/database/generated/client' +import type { Prisma, TerritoryKindKey } from '~/database/generated/client' import type { TransactionClient } from '~/shared/infra/db.server' import type { LatLng } from '~/shared/utils/distance' import { paginationFromUrl } from '~/shared/utils/pagination.server' @@ -88,7 +88,7 @@ export function findTerritoryWithHistory(db: TransactionClient, territoryId: num export async function findAdjacentTerritories( db: TransactionClient, territoryNumber: string, - territoryType: TerritoryKind, + territoryType: TerritoryKindKey, congregationId: number, ): Promise<{ prev: { id: number; number: string } | null diff --git a/app/features/territories/server/building-filters.server.test.ts b/app/features/territories/server/building-filters.server.test.ts index b1f4426d..10453e66 100644 --- a/app/features/territories/server/building-filters.server.test.ts +++ b/app/features/territories/server/building-filters.server.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { EntranceKind } from '~/features/territories/model/entrance-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { computeFilters } from './building-filters.server' describe('computeFilters', () => { @@ -20,31 +20,31 @@ describe('computeFilters', () => { }) it('applies type filter for Classical → residential entrances with homes', () => { - const result = computeFilters(new URLSearchParams({ type: TerritoryKind.Classical })) + const result = computeFilters(new URLSearchParams({ type: TerritoryKindKey.Classical })) expect(result).toMatchObject({ entrances: { some: { kind: EntranceKind.Residential, homes: { gt: 0 } } }, }) }) it('applies type filter for Phone → residential entrances with phones', () => { - const result = computeFilters(new URLSearchParams({ type: TerritoryKind.Phone })) + const result = computeFilters(new URLSearchParams({ type: TerritoryKindKey.Phone })) expect(result).toMatchObject({ entrances: { some: { kind: EntranceKind.Residential, phones: { gt: 0 } } }, }) }) it('applies type filter for Commerce → commerce entrances', () => { - const result = computeFilters(new URLSearchParams({ type: TerritoryKind.Commerces })) + const result = computeFilters(new URLSearchParams({ type: TerritoryKindKey.Commerces })) expect(result).toMatchObject({ entrances: { some: { kind: EntranceKind.Commerce } } }) }) it('applies type filter for Hotel → hotel entrances', () => { - const result = computeFilters(new URLSearchParams({ type: TerritoryKind.Hotel })) + const result = computeFilters(new URLSearchParams({ type: TerritoryKindKey.Hotel })) expect(result).toMatchObject({ entrances: { some: { kind: EntranceKind.Hotel } } }) }) it('applies type filter for Univ → campus entrances', () => { - const result = computeFilters(new URLSearchParams({ type: TerritoryKind.Univ })) + const result = computeFilters(new URLSearchParams({ type: TerritoryKindKey.Univ })) expect(result).toMatchObject({ entrances: { some: { kind: EntranceKind.Campus } } }) }) @@ -118,7 +118,7 @@ describe('computeFilters', () => { }) it('combines zip and type filters', () => { - const result = computeFilters(new URLSearchParams({ zip: '75001', type: TerritoryKind.Classical })) + const result = computeFilters(new URLSearchParams({ zip: '75001', type: TerritoryKindKey.Classical })) expect(result).toHaveProperty('zip') expect(result).toHaveProperty('entrances') }) diff --git a/app/features/territories/server/building-filters.server.ts b/app/features/territories/server/building-filters.server.ts index 6e30dfc9..55ac9c19 100644 --- a/app/features/territories/server/building-filters.server.ts +++ b/app/features/territories/server/building-filters.server.ts @@ -1,7 +1,7 @@ import type { Prisma } from '~/database/generated/client' import { EntranceKind } from '~/features/territories/model/entrance-kind.type' import type { ShopKind } from '~/features/territories/model/shop-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { stripDiacritics } from '~/shared/utils/strip-diacritics' import { addressRegex, proximityPrefix } from './address-regex' @@ -42,25 +42,25 @@ function applyShopFilter(filters: Prisma.BuildingWhereInput, params: URLSearchPa function applyTypeFilter(filters: Prisma.BuildingWhereInput, params: URLSearchParams): Prisma.BuildingWhereInput { if (params.has('type') && params.get('type') !== 'none') { - const type = params.get('type') as TerritoryKind + const type = params.get('type') as TerritoryKindKey - if (type === TerritoryKind.Classical) { + if (type === TerritoryKindKey.Classical) { return { ...filters, entrances: { some: { kind: EntranceKind.Residential, homes: { gt: 0 } } } } } - if (type === TerritoryKind.Phone) { + if (type === TerritoryKindKey.Phone) { return { ...filters, entrances: { some: { kind: EntranceKind.Residential, phones: { gt: 0 } } } } } - if (type === TerritoryKind.Commerces) { + if (type === TerritoryKindKey.Commerces) { return { ...filters, entrances: { some: { kind: EntranceKind.Commerce } } } } - if (type === TerritoryKind.Hotel) { + if (type === TerritoryKindKey.Hotel) { return { ...filters, entrances: { some: { kind: EntranceKind.Hotel } } } } - if (type === TerritoryKind.Univ) { + if (type === TerritoryKindKey.Univ) { return { ...filters, entrances: { some: { kind: EntranceKind.Campus } } } } } diff --git a/app/features/territories/server/buildings.server.ts b/app/features/territories/server/buildings.server.ts index 1c259453..3470984c 100644 --- a/app/features/territories/server/buildings.server.ts +++ b/app/features/territories/server/buildings.server.ts @@ -1,7 +1,7 @@ import type { Prisma } from '~/database/generated/client' import type { Bbox } from '~/features/territories/model/bbox.type' import { EntranceKind } from '~/features/territories/model/entrance-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { availableForCreateWhere, type MapVisibilityContext, @@ -11,12 +11,12 @@ import type { TransactionClient } from '~/shared/infra/db.server' import type { AggregatedEntrance, Entrance } from '~/shared/types/entrance' import { paginationFromUrl } from '~/shared/utils/pagination.server' -const entranceKindForTerritoryType: Record = { - [TerritoryKind.Classical]: EntranceKind.Residential, - [TerritoryKind.Phone]: EntranceKind.Residential, - [TerritoryKind.Commerces]: EntranceKind.Commerce, - [TerritoryKind.Hotel]: EntranceKind.Hotel, - [TerritoryKind.Univ]: EntranceKind.Campus, +const entranceKindForTerritoryType: Record = { + [TerritoryKindKey.Classical]: EntranceKind.Residential, + [TerritoryKindKey.Phone]: EntranceKind.Residential, + [TerritoryKindKey.Commerces]: EntranceKind.Commerce, + [TerritoryKindKey.Hotel]: EntranceKind.Hotel, + [TerritoryKindKey.Univ]: EntranceKind.Campus, } export type BboxEntranceStatus = 'in-this-territory' | 'available' | 'on-other-territory' @@ -128,7 +128,7 @@ export function aggregateEntrance(entrance: Entrance): AggregatedEntrance { } } -export async function getZips(db: TransactionClient, congregationId: number, territoryType?: TerritoryKind) { +export async function getZips(db: TransactionClient, congregationId: number, territoryType?: TerritoryKindKey) { const selectors: Prisma.BuildingWhereInput = { active: true, congregationId } if (territoryType != null) { @@ -146,7 +146,11 @@ export async function getZips(db: TransactionClient, congregationId: number, ter return await db.building.groupBy({ by: 'zip', where: selectors }) } -export async function getAvailableZips(db: TransactionClient, congregationId: number, territoryType?: TerritoryKind) { +export async function getAvailableZips( + db: TransactionClient, + congregationId: number, + territoryType?: TerritoryKindKey, +) { const selectors: Prisma.BuildingWhereInput = { active: true, congregationId } if (territoryType != null) { @@ -168,7 +172,7 @@ export async function getAvailableStreets( db: TransactionClient, congregationId: number, zip?: string, - territoryType?: TerritoryKind, + territoryType?: TerritoryKindKey, ) { const selectors: Prisma.BuildingWhereInput = { active: true, congregationId } @@ -195,7 +199,7 @@ export async function getAvailableEntrances( congregationId: number, zip?: string, street?: string, - territoryType?: TerritoryKind, + territoryType?: TerritoryKindKey, ): Promise { const selectors: Prisma.BuildingEntranceWhereInput = { congregationId, @@ -223,7 +227,7 @@ export async function getAvailableEntrances( async function queryEntrancesInBbox( db: TransactionClient, where: Prisma.BuildingEntranceWhereInput, - territoryType: TerritoryKind, + territoryType: TerritoryKindKey, matchesThisTerritory: (row: { territories: { id: number; number: string }[] }) => { inThisTerritory: boolean otherTerritory: { id: number; number: string } | null @@ -285,7 +289,7 @@ export async function getEntrancesInBbox( db: TransactionClient, congregationId: number, territoryId: number, - territoryType: TerritoryKind, + territoryType: TerritoryKindKey, bbox: Bbox, ctx: MapVisibilityContext, limit = 1500, @@ -319,7 +323,7 @@ export async function getEntrancesInBbox( export async function countAvailableEntrances( db: TransactionClient, congregationId: number, - kind: TerritoryKind, + kind: TerritoryKindKey, ctx: MapVisibilityContext, ): Promise<{ total: number; withoutCoordinates: number }> { const baseWhere = { @@ -341,7 +345,7 @@ export async function countAvailableEntrances( export async function getAvailableEntrancesInBbox( db: TransactionClient, congregationId: number, - kind: TerritoryKind, + kind: TerritoryKindKey, bbox: Bbox, ctx: MapVisibilityContext, limit = 1500, diff --git a/app/features/territories/server/compute-attributions-per-month.server.test.ts b/app/features/territories/server/compute-attributions-per-month.server.test.ts index d67f6d07..a0201fef 100644 --- a/app/features/territories/server/compute-attributions-per-month.server.test.ts +++ b/app/features/territories/server/compute-attributions-per-month.server.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { computeAttributionsPerMonth } from './compute-attributions-per-month.server' import type { StatsAttribution } from './stats-attribution.type' @@ -9,7 +9,7 @@ function makeAttribution(startDate: Date, id = 1): StatsAttribution { id, territoryId: 1, territoryNumber: 'T-1', - territoryType: TerritoryKind.Classical, + territoryType: TerritoryKindKey.Classical, type: TerritoryAttributionKind.Default, campaignId: null, campaignRestPeriodDays: null, diff --git a/app/features/territories/server/compute-availability-gap.server.test.ts b/app/features/territories/server/compute-availability-gap.server.test.ts index 53211873..445de1c6 100644 --- a/app/features/territories/server/compute-availability-gap.server.test.ts +++ b/app/features/territories/server/compute-availability-gap.server.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { computeAvailabilityGap } from './compute-availability-gap.server' import type { StatsAttribution } from './stats-attribution.type' @@ -9,7 +9,7 @@ function makeAttribution(territoryId: number, startDate: Date, endDate: Date | n id, territoryId, territoryNumber: `T-${territoryId}`, - territoryType: TerritoryKind.Classical, + territoryType: TerritoryKindKey.Classical, type: TerritoryAttributionKind.Default, campaignId: null, campaignRestPeriodDays: null, diff --git a/app/features/territories/server/compute-coverage-by-territory-type.server.test.ts b/app/features/territories/server/compute-coverage-by-territory-type.server.test.ts index cc4d031f..416abb7b 100644 --- a/app/features/territories/server/compute-coverage-by-territory-type.server.test.ts +++ b/app/features/territories/server/compute-coverage-by-territory-type.server.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { computeCoverageByTerritoryType } from './compute-coverage-by-territory-type.server' import type { StatsAttribution } from './stats-attribution.type' import type { TerritoryCountByType } from './territory-count-by-type.type' -function makeAttribution(territoryId: number, territoryType: TerritoryKind): StatsAttribution { +function makeAttribution(territoryId: number, territoryType: TerritoryKindKey): StatsAttribution { return { id: territoryId, territoryId, @@ -22,7 +22,7 @@ function makeAttribution(territoryId: number, territoryType: TerritoryKind): Sta describe('computeCoverageByTerritoryType', () => { it("retourne des couvertures à 0 quand il n'y a aucune attribution", () => { - const counts: TerritoryCountByType[] = [{ type: TerritoryKind.Classical, count: 10 }] + const counts: TerritoryCountByType[] = [{ type: TerritoryKindKey.Classical, count: 10 }] const result = computeCoverageByTerritoryType([], counts) expect(result).toHaveLength(1) @@ -33,14 +33,14 @@ describe('computeCoverageByTerritoryType', () => { it('calcule la couverture par type de territoire', () => { const counts: TerritoryCountByType[] = [ - { type: TerritoryKind.Classical, count: 10 }, - { type: TerritoryKind.Commerces, count: 5 }, + { type: TerritoryKindKey.Classical, count: 10 }, + { type: TerritoryKindKey.Commerces, count: 5 }, ] const attributions = [ - makeAttribution(1, TerritoryKind.Classical), - makeAttribution(2, TerritoryKind.Classical), - makeAttribution(2, TerritoryKind.Classical), // même territoire, 2 fois - makeAttribution(10, TerritoryKind.Commerces), + makeAttribution(1, TerritoryKindKey.Classical), + makeAttribution(2, TerritoryKindKey.Classical), + makeAttribution(2, TerritoryKindKey.Classical), // même territoire, 2 fois + makeAttribution(10, TerritoryKindKey.Commerces), ] const result = computeCoverageByTerritoryType(attributions, counts) @@ -55,7 +55,7 @@ describe('computeCoverageByTerritoryType', () => { }) it('gère un type avec 0 territoires', () => { - const counts: TerritoryCountByType[] = [{ type: TerritoryKind.Hotel, count: 0 }] + const counts: TerritoryCountByType[] = [{ type: TerritoryKindKey.Hotel, count: 0 }] const result = computeCoverageByTerritoryType([], counts) expect(result[0].coverage).toBe(0) @@ -63,14 +63,14 @@ describe('computeCoverageByTerritoryType', () => { }) it('utilise le type brut comme label pour un type inconnu avec 0 territoires', () => { - const counts: TerritoryCountByType[] = [{ type: 'special' as TerritoryKind, count: 0 }] + const counts: TerritoryCountByType[] = [{ type: 'special' as TerritoryKindKey, count: 0 }] const result = computeCoverageByTerritoryType([], counts) expect(result[0].label).toBe('special') }) it('utilise le type brut comme label quand le type est inconnu', () => { - const counts: TerritoryCountByType[] = [{ type: 'unknown-type' as TerritoryKind, count: 5 }] + const counts: TerritoryCountByType[] = [{ type: 'unknown-type' as TerritoryKindKey, count: 5 }] const result = computeCoverageByTerritoryType([], counts) expect(result[0].label).toBe('unknown-type') @@ -78,8 +78,8 @@ describe('computeCoverageByTerritoryType', () => { }) it('utilise le type brut comme label pour un type inconnu avec des attributions', () => { - const counts: TerritoryCountByType[] = [{ type: 'custom' as TerritoryKind, count: 2 }] - const attributions = [makeAttribution(1, 'custom' as TerritoryKind)] + const counts: TerritoryCountByType[] = [{ type: 'custom' as TerritoryKindKey, count: 2 }] + const attributions = [makeAttribution(1, 'custom' as TerritoryKindKey)] const result = computeCoverageByTerritoryType(attributions, counts) diff --git a/app/features/territories/server/compute-coverage-by-territory-type.server.ts b/app/features/territories/server/compute-coverage-by-territory-type.server.ts index f3855652..a190d500 100644 --- a/app/features/territories/server/compute-coverage-by-territory-type.server.ts +++ b/app/features/territories/server/compute-coverage-by-territory-type.server.ts @@ -1,10 +1,10 @@ -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import * as m from '~/i18n/paraglide/messages' import type { StatsAttribution } from './stats-attribution.type' import type { TerritoryCountByType } from './territory-count-by-type.type' export interface CoverageByType { - kind: TerritoryKind + kind: TerritoryKindKey label: string coverage: number totalCoverage: number @@ -12,11 +12,11 @@ export interface CoverageByType { function territoryKindLabels(): Record { return { - [TerritoryKind.Classical]: m.territory_kind_label_classical(), - [TerritoryKind.Univ]: m.territory_kind_label_univ(), - [TerritoryKind.Commerces]: m.territory_kind_label_commerces(), - [TerritoryKind.Phone]: m.territory_kind_label_phone(), - [TerritoryKind.Hotel]: m.territory_kind_label_hotel(), + [TerritoryKindKey.Classical]: m.territory_kind_label_classical(), + [TerritoryKindKey.Univ]: m.territory_kind_label_univ(), + [TerritoryKindKey.Commerces]: m.territory_kind_label_commerces(), + [TerritoryKindKey.Phone]: m.territory_kind_label_phone(), + [TerritoryKindKey.Hotel]: m.territory_kind_label_hotel(), } } diff --git a/app/features/territories/server/compute-duration-stats.server.test.ts b/app/features/territories/server/compute-duration-stats.server.test.ts index 428cede5..33357f49 100644 --- a/app/features/territories/server/compute-duration-stats.server.test.ts +++ b/app/features/territories/server/compute-duration-stats.server.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { computeDurationStats } from './compute-duration-stats.server' import type { StatsAttribution } from './stats-attribution.type' @@ -13,7 +13,7 @@ function makeAttribution( id: overrides.id ?? 1, territoryId: overrides.territoryId ?? 1, territoryNumber: overrides.territoryNumber ?? 'T-1', - territoryType: TerritoryKind.Classical, + territoryType: TerritoryKindKey.Classical, type: TerritoryAttributionKind.Default, campaignId: null, campaignRestPeriodDays: null, diff --git a/app/features/territories/server/compute-monthly-coverage-evolution.server.test.ts b/app/features/territories/server/compute-monthly-coverage-evolution.server.test.ts index 82bb44f1..ad833e9c 100644 --- a/app/features/territories/server/compute-monthly-coverage-evolution.server.test.ts +++ b/app/features/territories/server/compute-monthly-coverage-evolution.server.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { computeMonthlyCoverageEvolution } from './compute-monthly-coverage-evolution.server' import type { StatsAttribution } from './stats-attribution.type' import type { TerritoryCountByType } from './territory-count-by-type.type' @@ -10,7 +10,7 @@ function makeAttribution(territoryId: number, startDate: Date, endDate: Date | n id: territoryId, territoryId, territoryNumber: `T-${territoryId}`, - territoryType: TerritoryKind.Classical, + territoryType: TerritoryKindKey.Classical, type: TerritoryAttributionKind.Default, campaignId: null, campaignRestPeriodDays: null, @@ -21,7 +21,7 @@ function makeAttribution(territoryId: number, startDate: Date, endDate: Date | n } describe('computeMonthlyCoverageEvolution', () => { - const counts: TerritoryCountByType[] = [{ type: TerritoryKind.Classical, count: 10 }] + const counts: TerritoryCountByType[] = [{ type: TerritoryKindKey.Classical, count: 10 }] it("retourne un tableau vide quand il n'y a aucun territoire", () => { const result = computeMonthlyCoverageEvolution([], [], new Date(2025, 8, 1), new Date(2025, 10, 30)) diff --git a/app/features/territories/server/compute-next-territory-number.server.test.ts b/app/features/territories/server/compute-next-territory-number.server.test.ts index bf8ab5a8..31da7b8f 100644 --- a/app/features/territories/server/compute-next-territory-number.server.test.ts +++ b/app/features/territories/server/compute-next-territory-number.server.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { territory: { count: vi.fn() } }, @@ -16,7 +16,7 @@ describe('computeNextTerritoryNumber', () => { it('returns D-prefixed number for classical territories', async () => { vi.mocked(db.territory.count).mockResolvedValue(4 as never) - const result = await computeNextTerritoryNumber(db as never, 1, TerritoryKind.Classical) + const result = await computeNextTerritoryNumber(db as never, 1, TerritoryKindKey.Classical) expect(result).toBe('D005') }) @@ -24,7 +24,7 @@ describe('computeNextTerritoryNumber', () => { it('returns H-prefixed number for hotel territories', async () => { vi.mocked(db.territory.count).mockResolvedValue(0 as never) - const result = await computeNextTerritoryNumber(db as never, 1, TerritoryKind.Hotel) + const result = await computeNextTerritoryNumber(db as never, 1, TerritoryKindKey.Hotel) expect(result).toBe('H001') }) @@ -32,7 +32,7 @@ describe('computeNextTerritoryNumber', () => { it('returns U-prefixed number for campus territories', async () => { vi.mocked(db.territory.count).mockResolvedValue(9 as never) - const result = await computeNextTerritoryNumber(db as never, 1, TerritoryKind.Univ) + const result = await computeNextTerritoryNumber(db as never, 1, TerritoryKindKey.Univ) expect(result).toBe('U010') }) @@ -40,7 +40,7 @@ describe('computeNextTerritoryNumber', () => { it('returns C-prefixed number for commerces territories', async () => { vi.mocked(db.territory.count).mockResolvedValue(2 as never) - const result = await computeNextTerritoryNumber(db as never, 1, TerritoryKind.Commerces) + const result = await computeNextTerritoryNumber(db as never, 1, TerritoryKindKey.Commerces) expect(result).toBe('C003') }) @@ -48,7 +48,7 @@ describe('computeNextTerritoryNumber', () => { it('returns P-prefixed number for phones territories', async () => { vi.mocked(db.territory.count).mockResolvedValue(11 as never) - const result = await computeNextTerritoryNumber(db as never, 1, TerritoryKind.Phone) + const result = await computeNextTerritoryNumber(db as never, 1, TerritoryKindKey.Phone) expect(result).toBe('P012') }) @@ -56,7 +56,7 @@ describe('computeNextTerritoryNumber', () => { it('pads to 3 digits when zero territories exist yet', async () => { vi.mocked(db.territory.count).mockResolvedValue(0 as never) - const result = await computeNextTerritoryNumber(db as never, 1, TerritoryKind.Classical) + const result = await computeNextTerritoryNumber(db as never, 1, TerritoryKindKey.Classical) expect(result).toBe('D001') }) @@ -64,7 +64,7 @@ describe('computeNextTerritoryNumber', () => { it('does not truncate when the running count exceeds 3 digits', async () => { vi.mocked(db.territory.count).mockResolvedValue(999 as never) - const result = await computeNextTerritoryNumber(db as never, 1, TerritoryKind.Classical) + const result = await computeNextTerritoryNumber(db as never, 1, TerritoryKindKey.Classical) expect(result).toBe('D1000') }) @@ -72,10 +72,10 @@ describe('computeNextTerritoryNumber', () => { it('scopes the count query by congregation and territory kind', async () => { vi.mocked(db.territory.count).mockResolvedValue(0 as never) - await computeNextTerritoryNumber(db as never, 42, TerritoryKind.Commerces) + await computeNextTerritoryNumber(db as never, 42, TerritoryKindKey.Commerces) expect(db.territory.count).toHaveBeenCalledWith({ - where: { type: TerritoryKind.Commerces, congregationId: 42 }, + where: { type: TerritoryKindKey.Commerces, congregationId: 42 }, }) }) }) diff --git a/app/features/territories/server/compute-next-territory-number.server.ts b/app/features/territories/server/compute-next-territory-number.server.ts index 49e0fd81..4b272a16 100644 --- a/app/features/territories/server/compute-next-territory-number.server.ts +++ b/app/features/territories/server/compute-next-territory-number.server.ts @@ -1,18 +1,18 @@ -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { TransactionClient } from '~/shared/infra/db.server' -const PREFIX: Record = { - [TerritoryKind.Classical]: 'D', - [TerritoryKind.Hotel]: 'H', - [TerritoryKind.Univ]: 'U', - [TerritoryKind.Commerces]: 'C', - [TerritoryKind.Phone]: 'P', +const PREFIX: Record = { + [TerritoryKindKey.Classical]: 'D', + [TerritoryKindKey.Hotel]: 'H', + [TerritoryKindKey.Univ]: 'U', + [TerritoryKindKey.Commerces]: 'C', + [TerritoryKindKey.Phone]: 'P', } export async function computeNextTerritoryNumber( db: TransactionClient, congregationId: number, - kind: TerritoryKind, + kind: TerritoryKindKey, ): Promise { const count = await db.territory.count({ where: { type: kind, congregationId } }) return `${PREFIX[kind]}${String(count + 1).padStart(3, '0')}` diff --git a/app/features/territories/server/compute-overdue-rate.server.test.ts b/app/features/territories/server/compute-overdue-rate.server.test.ts index eb4a4e35..ba2566ce 100644 --- a/app/features/territories/server/compute-overdue-rate.server.test.ts +++ b/app/features/territories/server/compute-overdue-rate.server.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { computeOverdueRate } from './compute-overdue-rate.server' import type { StatsAttribution } from './stats-attribution.type' @@ -9,7 +9,7 @@ function makeAttribution(endDate: Date | null, lateDate: Date): StatsAttribution id: 1, territoryId: 1, territoryNumber: 'T-1', - territoryType: TerritoryKind.Classical, + territoryType: TerritoryKindKey.Classical, type: TerritoryAttributionKind.Default, campaignId: null, campaignRestPeriodDays: null, diff --git a/app/features/territories/server/compute-ranked-territories.server.test.ts b/app/features/territories/server/compute-ranked-territories.server.test.ts index 22ae7a37..a8ece6ae 100644 --- a/app/features/territories/server/compute-ranked-territories.server.test.ts +++ b/app/features/territories/server/compute-ranked-territories.server.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { computeRankedTerritories } from './compute-ranked-territories.server' import type { StatsAttribution } from './stats-attribution.type' @@ -9,7 +9,7 @@ function makeAttribution(overrides: Partial = {}): StatsAttrib id: 1, territoryId: 1, territoryNumber: 'T-1', - territoryType: TerritoryKind.Classical, + territoryType: TerritoryKindKey.Classical, type: TerritoryAttributionKind.Default, campaignId: null, campaignRestPeriodDays: null, diff --git a/app/features/territories/server/compute-rest-period-utilization.server.test.ts b/app/features/territories/server/compute-rest-period-utilization.server.test.ts index 7fa8a3a8..e5a0a549 100644 --- a/app/features/territories/server/compute-rest-period-utilization.server.test.ts +++ b/app/features/territories/server/compute-rest-period-utilization.server.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { computeRestPeriodUtilization } from './compute-rest-period-utilization.server' import type { StatsAttribution } from './stats-attribution.type' @@ -17,7 +17,7 @@ function makeAttribution( id, territoryId, territoryNumber: `T-${territoryId}`, - territoryType: TerritoryKind.Classical, + territoryType: TerritoryKindKey.Classical, type, campaignId, campaignRestPeriodDays, diff --git a/app/features/territories/server/compute-territory-quantity.test.ts b/app/features/territories/server/compute-territory-quantity.test.ts index 772255db..1e034a27 100644 --- a/app/features/territories/server/compute-territory-quantity.test.ts +++ b/app/features/territories/server/compute-territory-quantity.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { EntranceKind } from '~/features/territories/model/entrance-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { AggregatedEntrance } from '~/shared/types/entrance' import { computeTerritoryQuantity } from './compute-territory-quantity' @@ -52,38 +52,38 @@ function makeEntrance(overrides: { homes?: number; phones?: number } = {}): Aggr describe('computeTerritoryQuantity', () => { it('retourne la somme des foyers pour un territoire classique', () => { const entrances = [makeEntrance({ homes: 10 }), makeEntrance({ homes: 20 })] - expect(computeTerritoryQuantity(TerritoryKind.Classical, entrances)).toBe(30) + expect(computeTerritoryQuantity(TerritoryKindKey.Classical, entrances)).toBe(30) }) it('retourne la somme des foyers pour un territoire universitaire', () => { const entrances = [makeEntrance({ homes: 5 }), makeEntrance({ homes: 15 })] - expect(computeTerritoryQuantity(TerritoryKind.Univ, entrances)).toBe(20) + expect(computeTerritoryQuantity(TerritoryKindKey.Univ, entrances)).toBe(20) }) it('utilise les téléphones en fallback quand les foyers sont absents pour un territoire classique', () => { const entrance = makeEntrance({ phones: 8 }) entrance.homes = 0 - expect(computeTerritoryQuantity(TerritoryKind.Classical, [entrance])).toBe(8) + expect(computeTerritoryQuantity(TerritoryKindKey.Classical, [entrance])).toBe(8) }) it('retourne la somme des téléphones pour un territoire téléphone', () => { const entrances = [makeEntrance({ phones: 12 }), makeEntrance({ phones: 8 })] - expect(computeTerritoryQuantity(TerritoryKind.Phone, entrances)).toBe(20) + expect(computeTerritoryQuantity(TerritoryKindKey.Phone, entrances)).toBe(20) }) it("retourne le nombre d'allées pour un territoire commerces", () => { const entrances = [makeEntrance(), makeEntrance(), makeEntrance()] - expect(computeTerritoryQuantity(TerritoryKind.Commerces, entrances)).toBe(3) + expect(computeTerritoryQuantity(TerritoryKindKey.Commerces, entrances)).toBe(3) }) it("retourne le nombre d'allées pour un territoire hôtels", () => { const entrances = [makeEntrance(), makeEntrance()] - expect(computeTerritoryQuantity(TerritoryKind.Hotel, entrances)).toBe(2) + expect(computeTerritoryQuantity(TerritoryKindKey.Hotel, entrances)).toBe(2) }) it("retourne 0 quand il n'y a pas d'allées", () => { - expect(computeTerritoryQuantity(TerritoryKind.Classical, [])).toBe(0) - expect(computeTerritoryQuantity(TerritoryKind.Phone, [])).toBe(0) - expect(computeTerritoryQuantity(TerritoryKind.Commerces, [])).toBe(0) + expect(computeTerritoryQuantity(TerritoryKindKey.Classical, [])).toBe(0) + expect(computeTerritoryQuantity(TerritoryKindKey.Phone, [])).toBe(0) + expect(computeTerritoryQuantity(TerritoryKindKey.Commerces, [])).toBe(0) }) }) diff --git a/app/features/territories/server/compute-territory-quantity.ts b/app/features/territories/server/compute-territory-quantity.ts index 54ed8b42..d36e27b1 100644 --- a/app/features/territories/server/compute-territory-quantity.ts +++ b/app/features/territories/server/compute-territory-quantity.ts @@ -1,13 +1,13 @@ -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { AggregatedEntrance } from '~/shared/types/entrance' // Calcule le nombre de foyers/téléphones d'un territoire selon son type export function computeTerritoryQuantity(type: string, entrances: AggregatedEntrance[]): number { - if (type === TerritoryKind.Phone) { + if (type === TerritoryKindKey.Phone) { return entrances.reduce((acc, entrance) => acc + entrance.phones, 0) } - if (type === TerritoryKind.Classical || type === TerritoryKind.Univ) { + if (type === TerritoryKindKey.Classical || type === TerritoryKindKey.Univ) { return entrances.reduce((acc, entrance) => acc + (entrance.homes || entrance.phones), 0) } diff --git a/app/features/territories/server/create-attribution.server.test.ts b/app/features/territories/server/create-attribution.server.test.ts index 4639ab7f..b690844e 100644 --- a/app/features/territories/server/create-attribution.server.test.ts +++ b/app/features/territories/server/create-attribution.server.test.ts @@ -1,23 +1,27 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' vi.mock('~/shared/domain/settings.server', () => ({ getSetting: vi.fn(), })) vi.mock('~/shared/domain/audit.server', () => ({ AuditAction: {}, audit: vi.fn() })) vi.mock('./campaign.queries', () => ({ getActiveCampaign: vi.fn() })) +vi.mock('./attribution-eligibility.policy', () => ({ assertPublisherAllowedForKind: vi.fn() })) const mockDb = { // aggregate.assign runs _assertNoActiveOverlap (findMany) and the // occupied-territory guard for campaign assignments (findFirst). attribution: { create: vi.fn(), findMany: vi.fn(), findFirst: vi.fn() }, - territory: { findUniqueOrThrow: vi.fn() }, + // findFirst resolves the kind for the role gate; findUniqueOrThrow is the aggregate's own read. + territory: { findUniqueOrThrow: vi.fn(), findFirst: vi.fn() }, } const { createAttribution } = await import('./create-attribution.server') const { getSetting } = await import('~/shared/domain/settings.server') const { getActiveCampaign } = await import('./campaign.queries') +const { assertPublisherAllowedForKind } = await import('./attribution-eligibility.policy') +const { ConflictError } = await import('~/shared/errors/app-error.server') const baseParams = { publisherId: 1, @@ -44,7 +48,8 @@ beforeEach(() => { mockDb.attribution.create.mockResolvedValue({} as never) mockDb.attribution.findMany.mockResolvedValue([]) mockDb.attribution.findFirst.mockResolvedValue(null as never) - mockDb.territory.findUniqueOrThrow.mockResolvedValue({ type: TerritoryKind.Classical } as never) + mockDb.territory.findUniqueOrThrow.mockResolvedValue({ type: TerritoryKindKey.Classical } as never) + mockDb.territory.findFirst.mockResolvedValue({ type: TerritoryKindKey.Classical } as never) }) describe('createAttribution', () => { @@ -92,7 +97,7 @@ describe('createAttribution', () => { }) it('uses commerce duration (120 days) for commerce territory type', async () => { - mockDb.territory.findUniqueOrThrow.mockResolvedValue({ type: TerritoryKind.Commerces } as never) + mockDb.territory.findUniqueOrThrow.mockResolvedValue({ type: TerritoryKindKey.Commerces } as never) await createAttribution(mockDb as never, { ...baseParams, type: TerritoryAttributionKind.Default }) @@ -108,4 +113,21 @@ describe('createAttribution', () => { expect(result).toEqual(fake) }) + + it('creates nothing when the publisher fails the role gate', async () => { + vi.mocked(assertPublisherAllowedForKind).mockRejectedValue(new ConflictError('publisher_role_not_allowed')) + + await expect( + createAttribution(mockDb as never, { ...baseParams, type: TerritoryAttributionKind.Default }), + ).rejects.toThrow('publisher_role_not_allowed') + expect(mockDb.attribution.create).not.toHaveBeenCalled() + }) + + it('creates the attribution when the gate passes', async () => { + mockDb.territory.findFirst.mockResolvedValue({ type: TerritoryKindKey.Phone } as never) + + await createAttribution(mockDb as never, { ...baseParams, type: TerritoryAttributionKind.Default }) + + expect(mockDb.attribution.create).toHaveBeenCalled() + }) }) diff --git a/app/features/territories/server/create-attribution.server.ts b/app/features/territories/server/create-attribution.server.ts index 2ffb1ef2..b3de5ce2 100644 --- a/app/features/territories/server/create-attribution.server.ts +++ b/app/features/territories/server/create-attribution.server.ts @@ -1,13 +1,26 @@ import type { TransactionClient } from '~/shared/infra/db.server' import * as attributionAggregate from './attribution.aggregate' +import { assertPublisherAllowedForKind } from './attribution-eligibility.policy' /** * Assign a territory to a publisher. Thin delegator kept for verb-noun * discoverability; the invariant (`_assertNoActiveOverlap`, lateDate resolution, * audit) lives in `attribution.aggregate.assign`. + * + * Role gating sits here rather than in the aggregate on purpose: this is the + * human-initiated path, so the campaign sweep — which calls the aggregate + * directly — keeps carrying existing pairings across a role change. */ export type CreateAttributionParams = attributionAggregate.CreateAttributionParams -export function createAttribution(db: TransactionClient, params: CreateAttributionParams) { +export async function createAttribution(db: TransactionClient, params: CreateAttributionParams) { + const territory = await db.territory.findFirst({ + where: { id: params.territoryId, congregationId: params.congregationId }, + select: { type: true }, + }) + if (territory != null) { + await assertPublisherAllowedForKind(db, territory.type, params.publisherId, params.congregationId) + } + return attributionAggregate.assign(db, params) } diff --git a/app/features/territories/server/create-territory-from-split.server.test.ts b/app/features/territories/server/create-territory-from-split.server.test.ts index 21166d41..34fc6ac7 100644 --- a/app/features/territories/server/create-territory-from-split.server.test.ts +++ b/app/features/territories/server/create-territory-from-split.server.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { territory: { count: vi.fn(), create: vi.fn() }, auditLog: { create: vi.fn() } }, @@ -16,10 +16,14 @@ beforeEach(() => { describe('createTerritoryFromSplit', () => { it('generates D-prefixed number for classical territory type', async () => { vi.mocked(db.territory.count).mockResolvedValue(4 as never) - vi.mocked(db.territory.create).mockResolvedValue({ id: 1, number: 'D005', type: TerritoryKind.Classical } as never) + vi.mocked(db.territory.create).mockResolvedValue({ + id: 1, + number: 'D005', + type: TerritoryKindKey.Classical, + } as never) const result = await createTerritoryFromSplit(db as never, { - type: TerritoryKind.Classical, + type: TerritoryKindKey.Classical, entranceIds: [1], congregationId: 1, actorId: 99, @@ -30,10 +34,10 @@ describe('createTerritoryFromSplit', () => { it('generates H-prefixed number for hotel territory type', async () => { vi.mocked(db.territory.count).mockResolvedValue(0 as never) - vi.mocked(db.territory.create).mockResolvedValue({ id: 2, number: 'H001', type: TerritoryKind.Hotel } as never) + vi.mocked(db.territory.create).mockResolvedValue({ id: 2, number: 'H001', type: TerritoryKindKey.Hotel } as never) const result = await createTerritoryFromSplit(db as never, { - type: TerritoryKind.Hotel, + type: TerritoryKindKey.Hotel, entranceIds: [2], congregationId: 1, actorId: 99, @@ -47,7 +51,7 @@ describe('createTerritoryFromSplit', () => { vi.mocked(db.territory.create).mockResolvedValue({ id: 3 } as never) const result = await createTerritoryFromSplit(db as never, { - type: TerritoryKind.Univ, + type: TerritoryKindKey.Univ, entranceIds: [3], congregationId: 1, actorId: 99, @@ -61,7 +65,7 @@ describe('createTerritoryFromSplit', () => { vi.mocked(db.territory.create).mockResolvedValue({ id: 4 } as never) const result = await createTerritoryFromSplit(db as never, { - type: TerritoryKind.Commerces, + type: TerritoryKindKey.Commerces, entranceIds: [4], congregationId: 1, actorId: 99, @@ -75,7 +79,7 @@ describe('createTerritoryFromSplit', () => { vi.mocked(db.territory.create).mockResolvedValue({ id: 5 } as never) const result = await createTerritoryFromSplit(db as never, { - type: TerritoryKind.Phone, + type: TerritoryKindKey.Phone, entranceIds: [5], congregationId: 1, actorId: 99, @@ -89,7 +93,7 @@ describe('createTerritoryFromSplit', () => { vi.mocked(db.territory.create).mockResolvedValue({ id: 6 } as never) const result = await createTerritoryFromSplit(db as never, { - type: TerritoryKind.Classical, + type: TerritoryKindKey.Classical, entranceIds: [6], congregationId: 1, actorId: 99, @@ -103,7 +107,7 @@ describe('createTerritoryFromSplit', () => { vi.mocked(db.territory.create).mockResolvedValue({ id: 7 } as never) await createTerritoryFromSplit(db as never, { - type: TerritoryKind.Classical, + type: TerritoryKindKey.Classical, entranceIds: [10, 20, 30], congregationId: 5, actorId: 99, @@ -112,7 +116,7 @@ describe('createTerritoryFromSplit', () => { expect(db.territory.create).toHaveBeenCalledWith({ data: { number: 'D001', - type: TerritoryKind.Classical, + type: TerritoryKindKey.Classical, entrances: { connect: [{ id: 10 }, { id: 20 }, { id: 30 }], }, diff --git a/app/features/territories/server/create-territory-from-split.server.ts b/app/features/territories/server/create-territory-from-split.server.ts index 384d6043..9ae2d4d2 100644 --- a/app/features/territories/server/create-territory-from-split.server.ts +++ b/app/features/territories/server/create-territory-from-split.server.ts @@ -1,11 +1,11 @@ -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { AuditAction, audit } from '~/shared/domain/audit.server' import type { TransactionClient } from '~/shared/infra/db.server' import { computeNextTerritoryNumber } from './compute-next-territory-number.server' export interface CreateTerritoryFromSplitParams { - type: TerritoryKind + type: TerritoryKindKey entranceIds: number[] congregationId: number actorId: number diff --git a/app/features/territories/server/create-territory.server.test.ts b/app/features/territories/server/create-territory.server.test.ts index 0adac3b6..015325d3 100644 --- a/app/features/territories/server/create-territory.server.test.ts +++ b/app/features/territories/server/create-territory.server.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { territory: { create: vi.fn() }, auditLog: { create: vi.fn() } }, @@ -15,12 +15,12 @@ beforeEach(() => { describe('createTerritory', () => { it('returns the created territory', async () => { - const fake = { id: 1, number: 'D001', type: TerritoryKind.Classical, congregationId: 1 } + const fake = { id: 1, number: 'D001', type: TerritoryKindKey.Classical, congregationId: 1 } vi.mocked(db.territory.create).mockResolvedValue(fake as never) const result = await createTerritory(db as never, { number: 'D001', - type: TerritoryKind.Classical, + type: TerritoryKindKey.Classical, entranceIds: [10, 20], congregationId: 1, actorId: 99, @@ -34,7 +34,7 @@ describe('createTerritory', () => { await createTerritory(db as never, { number: 'H002', - type: TerritoryKind.Hotel, + type: TerritoryKindKey.Hotel, entranceIds: [3, 5, 7], congregationId: 2, actorId: 99, @@ -43,7 +43,7 @@ describe('createTerritory', () => { expect(db.territory.create).toHaveBeenCalledWith({ data: { number: 'H002', - type: TerritoryKind.Hotel, + type: TerritoryKindKey.Hotel, entrances: { connect: [{ id: 3 }, { id: 5 }, { id: 7 }], }, diff --git a/app/features/territories/server/create-territory.server.ts b/app/features/territories/server/create-territory.server.ts index 8d4b1051..a2411d0a 100644 --- a/app/features/territories/server/create-territory.server.ts +++ b/app/features/territories/server/create-territory.server.ts @@ -1,10 +1,10 @@ -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { AuditAction, audit } from '~/shared/domain/audit.server' import type { TransactionClient } from '~/shared/infra/db.server' export interface CreateTerritoryParams { number: string - type: TerritoryKind + type: TerritoryKindKey entranceIds: number[] congregationId: number actorId: number diff --git a/app/features/territories/server/entrance-content-label.test.ts b/app/features/territories/server/entrance-content-label.test.ts index b28a693e..5e1ac30c 100644 --- a/app/features/territories/server/entrance-content-label.test.ts +++ b/app/features/territories/server/entrance-content-label.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { EntranceKind } from '~/features/territories/model/entrance-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { entranceContentLabel } from './entrance-content-label' const make = (overrides: Partial[1]> = {}) => ({ @@ -15,74 +15,80 @@ describe('entranceContentLabel', () => { describe('Commerce entrance', () => { it('renders the shopKind capitalised', () => { expect( - entranceContentLabel(TerritoryKind.Commerces, make({ kind: EntranceKind.Commerce, shopKind: 'boulangerie' })), + entranceContentLabel( + TerritoryKindKey.Commerces, + make({ kind: EntranceKind.Commerce, shopKind: 'boulangerie' }), + ), ).toBe('Boulangerie') }) it('trims surrounding whitespace before capitalising', () => { expect( - entranceContentLabel(TerritoryKind.Commerces, make({ kind: EntranceKind.Commerce, shopKind: ' pharmacie ' })), + entranceContentLabel( + TerritoryKindKey.Commerces, + make({ kind: EntranceKind.Commerce, shopKind: ' pharmacie ' }), + ), ).toBe('Pharmacie') }) it('falls back to the kind label when shopKind is empty', () => { - expect(entranceContentLabel(TerritoryKind.Commerces, make({ kind: EntranceKind.Commerce, shopKind: '' }))).toBe( - 'Commerce', - ) + expect( + entranceContentLabel(TerritoryKindKey.Commerces, make({ kind: EntranceKind.Commerce, shopKind: '' })), + ).toBe('Commerce') }) it('falls back to the kind label when shopKind is null', () => { - expect(entranceContentLabel(TerritoryKind.Commerces, make({ kind: EntranceKind.Commerce, shopKind: null }))).toBe( - 'Commerce', - ) + expect( + entranceContentLabel(TerritoryKindKey.Commerces, make({ kind: EntranceKind.Commerce, shopKind: null })), + ).toBe('Commerce') }) }) it('renders the kind label for Hotel entrances', () => { - expect(entranceContentLabel(TerritoryKind.Hotel, make({ kind: EntranceKind.Hotel }))).toBe('Hôtel') + expect(entranceContentLabel(TerritoryKindKey.Hotel, make({ kind: EntranceKind.Hotel }))).toBe('Hôtel') }) it('renders the kind label for Campus entrances', () => { - expect(entranceContentLabel(TerritoryKind.Univ, make({ kind: EntranceKind.Campus }))).toBe( + expect(entranceContentLabel(TerritoryKindKey.Univ, make({ kind: EntranceKind.Campus }))).toBe( 'Résidence universitaire', ) }) it('renders the kind label for Laundromat entrances', () => { - expect(entranceContentLabel(TerritoryKind.Classical, make({ kind: EntranceKind.Laundromat }))).toBe('Laverie') + expect(entranceContentLabel(TerritoryKindKey.Classical, make({ kind: EntranceKind.Laundromat }))).toBe('Laverie') }) describe('Residential entrance', () => { it('shows phone count when the territory is a phone territory', () => { - expect(entranceContentLabel(TerritoryKind.Phone, make({ kind: EntranceKind.Residential, phones: 12 }))).toBe( + expect(entranceContentLabel(TerritoryKindKey.Phone, make({ kind: EntranceKind.Residential, phones: 12 }))).toBe( '12 tél.', ) }) it('shows 0 phones when the entrance has no phones field on a phone territory', () => { - expect(entranceContentLabel(TerritoryKind.Phone, make({ kind: EntranceKind.Residential }))).toBe('0 tél.') + expect(entranceContentLabel(TerritoryKindKey.Phone, make({ kind: EntranceKind.Residential }))).toBe('0 tél.') }) it('shows the homes count on a Classical territory', () => { - expect(entranceContentLabel(TerritoryKind.Classical, make({ kind: EntranceKind.Residential, homes: 8 }))).toBe( + expect(entranceContentLabel(TerritoryKindKey.Classical, make({ kind: EntranceKind.Residential, homes: 8 }))).toBe( '8 foyers', ) }) it('uses the singular key when homes equals 1', () => { - expect(entranceContentLabel(TerritoryKind.Classical, make({ kind: EntranceKind.Residential, homes: 1 }))).toBe( + expect(entranceContentLabel(TerritoryKindKey.Classical, make({ kind: EntranceKind.Residential, homes: 1 }))).toBe( '1 foyer', ) }) it('falls back to phones when homes is null on a non-phone territory', () => { expect( - entranceContentLabel(TerritoryKind.Univ, make({ kind: EntranceKind.Residential, homes: null, phones: 5 })), + entranceContentLabel(TerritoryKindKey.Univ, make({ kind: EntranceKind.Residential, homes: null, phones: 5 })), ).toBe('5 foyers') }) it('returns 0 in the singular branch when both homes and phones are null', () => { - expect(entranceContentLabel(TerritoryKind.Classical, make({ kind: EntranceKind.Residential }))).toBe('0 foyer') + expect(entranceContentLabel(TerritoryKindKey.Classical, make({ kind: EntranceKind.Residential }))).toBe('0 foyer') }) }) }) diff --git a/app/features/territories/server/entrance-content-label.ts b/app/features/territories/server/entrance-content-label.ts index 8aaabef0..43e0dcbc 100644 --- a/app/features/territories/server/entrance-content-label.ts +++ b/app/features/territories/server/entrance-content-label.ts @@ -1,5 +1,5 @@ import { EntranceKind, entranceKindLabels } from '~/features/territories/model/entrance-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import * as m from '~/i18n/paraglide/messages' type EntranceLike = { @@ -14,7 +14,7 @@ function capitalize(value: string): string { return value.charAt(0).toLocaleUpperCase() + value.slice(1) } -export function entranceContentLabel(territoryType: TerritoryKind, entrance: EntranceLike): string { +export function entranceContentLabel(territoryType: TerritoryKindKey, entrance: EntranceLike): string { const labels = entranceKindLabels() if (entrance.kind === EntranceKind.Commerce) { @@ -27,7 +27,7 @@ export function entranceContentLabel(territoryType: TerritoryKind, entrance: Ent if (entrance.kind === EntranceKind.Laundromat) return labels[EntranceKind.Laundromat] // Residential entrance: pick the count that matches the territory's purpose. - if (territoryType === TerritoryKind.Phone) { + if (territoryType === TerritoryKindKey.Phone) { return m.territories_content_phones({ count: entrance.phones ?? 0 }) } const homes = entrance.homes ?? entrance.phones ?? 0 diff --git a/app/features/territories/server/fetch-attributions-for-stats.server.test.ts b/app/features/territories/server/fetch-attributions-for-stats.server.test.ts index ec107cf5..8fed964a 100644 --- a/app/features/territories/server/fetch-attributions-for-stats.server.test.ts +++ b/app/features/territories/server/fetch-attributions-for-stats.server.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { StatsFilterParams } from './stats-filter-params.type' vi.mock('~/shared/infra/db.server', () => ({ @@ -13,7 +13,7 @@ const { fetchAttributionsForStats } = await import('./fetch-attributions-for-sta const { unscopedDb: db } = await import('~/shared/infra/db.server') const baseParams: StatsFilterParams = { - territoryKind: [TerritoryKind.Classical], + territoryKind: [TerritoryKindKey.Classical], attributionKind: [TerritoryAttributionKind.Default], startDate: new Date(2025, 8, 1), endDate: new Date(2026, 7, 31), @@ -30,7 +30,7 @@ describe('fetchAttributionsForStats', () => { { id: 1, territoryId: 10, - territory: { number: 'T-1', type: TerritoryKind.Classical }, + territory: { number: 'T-1', type: TerritoryKindKey.Classical }, type: TerritoryAttributionKind.Default, campaignId: null, campaign: null, @@ -47,7 +47,7 @@ describe('fetchAttributionsForStats', () => { id: 1, territoryId: 10, territoryNumber: 'T-1', - territoryType: TerritoryKind.Classical, + territoryType: TerritoryKindKey.Classical, type: TerritoryAttributionKind.Default, campaignId: null, campaignRestPeriodDays: null, diff --git a/app/features/territories/server/fetch-territory-counts.server.test.ts b/app/features/territories/server/fetch-territory-counts.server.test.ts index 18af0ce9..d65ae4d8 100644 --- a/app/features/territories/server/fetch-territory-counts.server.test.ts +++ b/app/features/territories/server/fetch-territory-counts.server.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { @@ -18,24 +18,26 @@ beforeEach(() => { describe('fetchTerritoryCounts', () => { it('retourne les compteurs par type', async () => { vi.mocked(db.territory.groupBy).mockResolvedValue([ - { type: TerritoryKind.Classical, _count: { id: 30 } }, - { type: TerritoryKind.Commerces, _count: { id: 5 } }, + { type: TerritoryKindKey.Classical, _count: { id: 30 } }, + { type: TerritoryKindKey.Commerces, _count: { id: 5 } }, ] as never) - const result = await fetchTerritoryCounts(db, 1, [TerritoryKind.Classical, TerritoryKind.Commerces]) + const result = await fetchTerritoryCounts(db, 1, [TerritoryKindKey.Classical, TerritoryKindKey.Commerces]) expect(result).toEqual([ - { type: TerritoryKind.Classical, count: 30 }, - { type: TerritoryKind.Commerces, count: 5 }, + { type: TerritoryKindKey.Classical, count: 30 }, + { type: TerritoryKindKey.Commerces, count: 5 }, ]) }) it('fonctionne sans filtre de types', async () => { - vi.mocked(db.territory.groupBy).mockResolvedValue([{ type: TerritoryKind.Classical, _count: { id: 20 } }] as never) + vi.mocked(db.territory.groupBy).mockResolvedValue([ + { type: TerritoryKindKey.Classical, _count: { id: 20 } }, + ] as never) const result = await fetchTerritoryCounts(db, 1) - expect(result).toEqual([{ type: TerritoryKind.Classical, count: 20 }]) + expect(result).toEqual([{ type: TerritoryKindKey.Classical, count: 20 }]) }) }) @@ -54,10 +56,10 @@ describe('countTerritoriesExistingBefore', () => { it('applies the kind filter when provided', async () => { vi.mocked(db.territory.count).mockResolvedValue(0) - await countTerritoriesExistingBefore(db, 1, new Date(2025, 7, 31), [TerritoryKind.Classical]) + await countTerritoriesExistingBefore(db, 1, new Date(2025, 7, 31), [TerritoryKindKey.Classical]) const where = vi.mocked(db.territory.count).mock.calls[0][0]?.where - expect(where?.type).toEqual({ in: [TerritoryKind.Classical] }) + expect(where?.type).toEqual({ in: [TerritoryKindKey.Classical] }) }) it('omits the kind filter when the array is empty', async () => { @@ -73,8 +75,8 @@ describe('countTerritoriesExistingBefore', () => { describe('getTotalTerritoryCount', () => { it('retourne la somme des compteurs', () => { const counts = [ - { type: TerritoryKind.Classical, count: 30 }, - { type: TerritoryKind.Commerces, count: 5 }, + { type: TerritoryKindKey.Classical, count: 30 }, + { type: TerritoryKindKey.Commerces, count: 5 }, ] expect(getTotalTerritoryCount(counts)).toBe(35) }) diff --git a/app/features/territories/server/fetch-territory-counts.server.ts b/app/features/territories/server/fetch-territory-counts.server.ts index 1a0b0409..78444d57 100644 --- a/app/features/territories/server/fetch-territory-counts.server.ts +++ b/app/features/territories/server/fetch-territory-counts.server.ts @@ -1,4 +1,4 @@ -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { TransactionClient } from '~/shared/infra/db.server' import type { TerritoryCountByType } from './territory-count-by-type.type' @@ -8,7 +8,7 @@ export { getTotalTerritoryCount } from './territory-count-by-type.type' export async function fetchTerritoryCounts( db: TransactionClient, congregationId: number, - territoryKinds?: TerritoryKind[], + territoryKinds?: TerritoryKindKey[], ): Promise { const groups = await db.territory.groupBy({ by: ['type'], @@ -33,7 +33,7 @@ export async function countTerritoriesExistingBefore( db: TransactionClient, congregationId: number, cutoff: Date, - territoryKinds?: TerritoryKind[], + territoryKinds?: TerritoryKindKey[], ): Promise { return db.territory.count({ where: { diff --git a/app/features/territories/server/find-adjacent-territories.integration.test.ts b/app/features/territories/server/find-adjacent-territories.integration.test.ts index 72c9ad6e..6a83691d 100644 --- a/app/features/territories/server/find-adjacent-territories.integration.test.ts +++ b/app/features/territories/server/find-adjacent-territories.integration.test.ts @@ -1,7 +1,7 @@ import { PrismaPg } from '@prisma/adapter-pg' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { PrismaClient } from '~/database/generated/client' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' const adapter = new PrismaPg({ connectionString: process.env.DB_RUNTIME_URL ?? process.env.DB_URL, @@ -42,29 +42,29 @@ beforeAll(async () => { await withScope(primaryCongId, async tx => { const t01 = await tx.territory.create({ - data: { number: 'T01', type: TerritoryKind.Classical, congregationId: primaryCongId }, + data: { number: 'T01', type: TerritoryKindKey.Classical, congregationId: primaryCongId }, }) t01Id = t01.id const t02 = await tx.territory.create({ - data: { number: 'T02', type: TerritoryKind.Classical, congregationId: primaryCongId }, + data: { number: 'T02', type: TerritoryKindKey.Classical, congregationId: primaryCongId }, }) t02Id = t02.id const t03 = await tx.territory.create({ - data: { number: 'T03', type: TerritoryKind.Classical, congregationId: primaryCongId }, + data: { number: 'T03', type: TerritoryKindKey.Classical, congregationId: primaryCongId }, }) t03Id = t03.id // Different type — must be excluded by the type filter even though the number "P01" // sorts before "T01" lexicographically. await tx.territory.create({ - data: { number: 'P01', type: TerritoryKind.Phone, congregationId: primaryCongId }, + data: { number: 'P01', type: TerritoryKindKey.Phone, congregationId: primaryCongId }, }) }) // Cross-congregation territory of the same number+type — must be excluded by tenant isolation. await withScope(otherCongId, async tx => { const crossT02 = await tx.territory.create({ - data: { number: 'T02', type: TerritoryKind.Classical, congregationId: otherCongId }, + data: { number: 'T02', type: TerritoryKindKey.Classical, congregationId: otherCongId }, }) crossCongT02Id = crossT02.id }) @@ -84,7 +84,7 @@ afterAll(async () => { describe('findAdjacentTerritories (integration)', () => { it('returns prev=T01 and next=T03 for the middle territory T02', async () => { const result = await withScope(primaryCongId, tx => - findAdjacentTerritories(tx, 'T02', TerritoryKind.Classical, primaryCongId), + findAdjacentTerritories(tx, 'T02', TerritoryKindKey.Classical, primaryCongId), ) expect(result.prev).toEqual({ id: t01Id, number: 'T01' }) expect(result.next).toEqual({ id: t03Id, number: 'T03' }) @@ -92,7 +92,7 @@ describe('findAdjacentTerritories (integration)', () => { it('returns prev=null and next=T02 for the first territory T01', async () => { const result = await withScope(primaryCongId, tx => - findAdjacentTerritories(tx, 'T01', TerritoryKind.Classical, primaryCongId), + findAdjacentTerritories(tx, 'T01', TerritoryKindKey.Classical, primaryCongId), ) expect(result.prev).toBeNull() expect(result.next).toEqual({ id: t02Id, number: 'T02' }) @@ -100,7 +100,7 @@ describe('findAdjacentTerritories (integration)', () => { it('returns prev=T02 and next=null for the last territory T03', async () => { const result = await withScope(primaryCongId, tx => - findAdjacentTerritories(tx, 'T03', TerritoryKind.Classical, primaryCongId), + findAdjacentTerritories(tx, 'T03', TerritoryKindKey.Classical, primaryCongId), ) expect(result.prev).toEqual({ id: t02Id, number: 'T02' }) expect(result.next).toBeNull() @@ -108,7 +108,7 @@ describe('findAdjacentTerritories (integration)', () => { it('does not cross territory types (P01 has no Classical neighbours)', async () => { const result = await withScope(primaryCongId, tx => - findAdjacentTerritories(tx, 'P01', TerritoryKind.Phone, primaryCongId), + findAdjacentTerritories(tx, 'P01', TerritoryKindKey.Phone, primaryCongId), ) expect(result.prev).toBeNull() expect(result.next).toBeNull() @@ -116,7 +116,7 @@ describe('findAdjacentTerritories (integration)', () => { it('does not leak across congregations (other-cong T02 is invisible)', async () => { const result = await withScope(primaryCongId, tx => - findAdjacentTerritories(tx, 'T02', TerritoryKind.Classical, primaryCongId), + findAdjacentTerritories(tx, 'T02', TerritoryKindKey.Classical, primaryCongId), ) // Sanity: result.prev/next must reference primary's T01/T03, not the cross-cong T02. expect(result.prev?.id).not.toBe(crossCongT02Id) diff --git a/app/features/territories/server/get-entrances-in-bbox.integration.test.ts b/app/features/territories/server/get-entrances-in-bbox.integration.test.ts index 48bd9446..3cca88ea 100644 --- a/app/features/territories/server/get-entrances-in-bbox.integration.test.ts +++ b/app/features/territories/server/get-entrances-in-bbox.integration.test.ts @@ -2,7 +2,7 @@ import { PrismaPg } from '@prisma/adapter-pg' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { PrismaClient } from '~/database/generated/client' import { EntranceKind } from '~/features/territories/model/entrance-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' const adapter = new PrismaPg({ connectionString: process.env.DB_RUNTIME_URL ?? process.env.DB_URL, @@ -52,12 +52,12 @@ beforeAll(async () => { await withScope(primaryCongId, async tx => { const primaryTerritory = await tx.territory.create({ - data: { number: `T-PRIM-${ts}`, type: TerritoryKind.Classical, congregationId: primaryCongId }, + data: { number: `T-PRIM-${ts}`, type: TerritoryKindKey.Classical, congregationId: primaryCongId }, }) primaryTerritoryId = primaryTerritory.id const otherTerritory = await tx.territory.create({ - data: { number: `T-OTHR-${ts}`, type: TerritoryKind.Classical, congregationId: primaryCongId }, + data: { number: `T-OTHR-${ts}`, type: TerritoryKindKey.Classical, congregationId: primaryCongId }, }) otherTerritoryId = otherTerritory.id @@ -154,7 +154,7 @@ beforeAll(async () => { entranceWrongKindId = entranceShop.id const commerceTerritory = await tx.territory.create({ - data: { number: `T-COM-${ts}`, type: TerritoryKind.Commerces, congregationId: primaryCongId }, + data: { number: `T-COM-${ts}`, type: TerritoryKindKey.Commerces, congregationId: primaryCongId }, }) commerceTerritoryId = commerceTerritory.id @@ -224,7 +224,7 @@ beforeAll(async () => { await withScope(otherCongId, async tx => { const crossCongTerritory = await tx.territory.create({ - data: { number: `T-XC-${ts}`, type: TerritoryKind.Classical, congregationId: otherCongId }, + data: { number: `T-XC-${ts}`, type: TerritoryKindKey.Classical, congregationId: otherCongId }, }) crossCongTerritoryId = crossCongTerritory.id @@ -273,7 +273,7 @@ describe('getEntrancesInBbox (integration)', () => { it('classifies entrances by status relative to the requested territory', async () => { const result = await withScope(primaryCongId, tx => - getEntrancesInBbox(tx, primaryCongId, primaryTerritoryId, TerritoryKind.Classical, wideBbox, { + getEntrancesInBbox(tx, primaryCongId, primaryTerritoryId, TerritoryKindKey.Classical, wideBbox, { phoneTypeActive: true, }), ) @@ -292,7 +292,7 @@ describe('getEntrancesInBbox (integration)', () => { it('filters out entrances whose kind does not match the territory type', async () => { const result = await withScope(primaryCongId, tx => - getEntrancesInBbox(tx, primaryCongId, primaryTerritoryId, TerritoryKind.Classical, wideBbox, { + getEntrancesInBbox(tx, primaryCongId, primaryTerritoryId, TerritoryKindKey.Classical, wideBbox, { phoneTypeActive: true, }), ) @@ -302,7 +302,7 @@ describe('getEntrancesInBbox (integration)', () => { it('only returns entrances whose centroid falls within the bbox', async () => { const tightBbox = { swLat: 48.849, swLng: 2.349, neLat: 48.851, neLng: 2.351 } const result = await withScope(primaryCongId, tx => - getEntrancesInBbox(tx, primaryCongId, primaryTerritoryId, TerritoryKind.Classical, tightBbox, { + getEntrancesInBbox(tx, primaryCongId, primaryTerritoryId, TerritoryKindKey.Classical, tightBbox, { phoneTypeActive: true, }), ) @@ -311,7 +311,7 @@ describe('getEntrancesInBbox (integration)', () => { it('does not leak entrances from another congregation', async () => { const result = await withScope(primaryCongId, tx => - getEntrancesInBbox(tx, primaryCongId, primaryTerritoryId, TerritoryKind.Classical, wideBbox, { + getEntrancesInBbox(tx, primaryCongId, primaryTerritoryId, TerritoryKindKey.Classical, wideBbox, { phoneTypeActive: true, }), ) @@ -324,7 +324,7 @@ describe('getEntrancesInBbox (integration)', () => { tx, primaryCongId, primaryTerritoryId, - TerritoryKind.Classical, + TerritoryKindKey.Classical, wideBbox, { phoneTypeActive: true, @@ -339,7 +339,7 @@ describe('getEntrancesInBbox (integration)', () => { it('returns total=null when not truncated', async () => { const result = await withScope(primaryCongId, tx => - getEntrancesInBbox(tx, primaryCongId, primaryTerritoryId, TerritoryKind.Classical, wideBbox, { + getEntrancesInBbox(tx, primaryCongId, primaryTerritoryId, TerritoryKindKey.Classical, wideBbox, { phoneTypeActive: true, }), ) @@ -350,7 +350,7 @@ describe('getEntrancesInBbox (integration)', () => { it('returns an empty array when no entrance is in the bbox', async () => { const emptyBbox = { swLat: 0, swLng: 0, neLat: 1, neLng: 1 } const result = await withScope(primaryCongId, tx => - getEntrancesInBbox(tx, primaryCongId, primaryTerritoryId, TerritoryKind.Classical, emptyBbox, { + getEntrancesInBbox(tx, primaryCongId, primaryTerritoryId, TerritoryKindKey.Classical, emptyBbox, { phoneTypeActive: true, }), ) @@ -364,7 +364,7 @@ describe('getAvailableEntrancesInBbox (integration)', () => { it('returns commerce entrances not yet attached to any Commerces territory', async () => { const result = await withScope(primaryCongId, tx => - getAvailableEntrancesInBbox(tx, primaryCongId, TerritoryKind.Commerces, wideBbox, { + getAvailableEntrancesInBbox(tx, primaryCongId, TerritoryKindKey.Commerces, wideBbox, { phoneTypeActive: true, }), ) @@ -375,7 +375,7 @@ describe('getAvailableEntrancesInBbox (integration)', () => { it('excludes commerce entrances already attached to a Commerces territory', async () => { const result = await withScope(primaryCongId, tx => - getAvailableEntrancesInBbox(tx, primaryCongId, TerritoryKind.Commerces, wideBbox, { + getAvailableEntrancesInBbox(tx, primaryCongId, TerritoryKindKey.Commerces, wideBbox, { phoneTypeActive: true, }), ) @@ -386,7 +386,7 @@ describe('getAvailableEntrancesInBbox (integration)', () => { it('excludes commerce entrances whose building lacks a prospection date', async () => { const result = await withScope(primaryCongId, tx => - getAvailableEntrancesInBbox(tx, primaryCongId, TerritoryKind.Commerces, wideBbox, { + getAvailableEntrancesInBbox(tx, primaryCongId, TerritoryKindKey.Commerces, wideBbox, { phoneTypeActive: true, }), ) @@ -397,7 +397,7 @@ describe('getAvailableEntrancesInBbox (integration)', () => { it('excludes entrances of a different kind', async () => { const result = await withScope(primaryCongId, tx => - getAvailableEntrancesInBbox(tx, primaryCongId, TerritoryKind.Commerces, wideBbox, { + getAvailableEntrancesInBbox(tx, primaryCongId, TerritoryKindKey.Commerces, wideBbox, { phoneTypeActive: true, }), ) @@ -409,7 +409,7 @@ describe('getAvailableEntrancesInBbox (integration)', () => { it('never surfaces the in-this-territory status (no territory context in create mode)', async () => { const result = await withScope(primaryCongId, tx => - getAvailableEntrancesInBbox(tx, primaryCongId, TerritoryKind.Commerces, wideBbox, { + getAvailableEntrancesInBbox(tx, primaryCongId, TerritoryKindKey.Commerces, wideBbox, { phoneTypeActive: true, }), ) @@ -421,7 +421,7 @@ describe('getAvailableEntrancesInBbox (integration)', () => { it('does not leak entrances from another congregation', async () => { const result = await withScope(primaryCongId, tx => - getAvailableEntrancesInBbox(tx, primaryCongId, TerritoryKind.Commerces, wideBbox, { + getAvailableEntrancesInBbox(tx, primaryCongId, TerritoryKindKey.Commerces, wideBbox, { phoneTypeActive: true, }), ) @@ -433,7 +433,7 @@ describe('getAvailableEntrancesInBbox (integration)', () => { it('returns an empty array when no entrance is in the bbox', async () => { const emptyBbox = { swLat: 0, swLng: 0, neLat: 1, neLng: 1 } const result = await withScope(primaryCongId, tx => - getAvailableEntrancesInBbox(tx, primaryCongId, TerritoryKind.Commerces, emptyBbox, { + getAvailableEntrancesInBbox(tx, primaryCongId, TerritoryKindKey.Commerces, emptyBbox, { phoneTypeActive: true, }), ) @@ -445,7 +445,7 @@ describe('getAvailableEntrancesInBbox (integration)', () => { describe('countAvailableEntrances (integration)', () => { it('counts commerce entrances that pass availableForCreateWhere (with and without coords)', async () => { const result = await withScope(primaryCongId, tx => - countAvailableEntrances(tx, primaryCongId, TerritoryKind.Commerces, { phoneTypeActive: true }), + countAvailableEntrances(tx, primaryCongId, TerritoryKindKey.Commerces, { phoneTypeActive: true }), ) // free (with coords) + no-coords ; excludes taken and wrong-kind expect(result.total).toBe(2) @@ -454,7 +454,7 @@ describe('countAvailableEntrances (integration)', () => { it('does not count entrances already attached to a territory of the same kind', async () => { const result = await withScope(primaryCongId, tx => - countAvailableEntrances(tx, primaryCongId, TerritoryKind.Commerces, { phoneTypeActive: true }), + countAvailableEntrances(tx, primaryCongId, TerritoryKindKey.Commerces, { phoneTypeActive: true }), ) // If entranceCommerceTaken was counted, total would be 3 expect(result.total).toBe(2) @@ -464,12 +464,12 @@ describe('countAvailableEntrances (integration)', () => { it('does not leak entrances from another congregation', async () => { const result = await withScope(primaryCongId, tx => - countAvailableEntrances(tx, primaryCongId, TerritoryKind.Commerces, { phoneTypeActive: true }), + countAvailableEntrances(tx, primaryCongId, TerritoryKindKey.Commerces, { phoneTypeActive: true }), ) // Cross-cong entrance is Residential in fixtures; use Hotel kind (empty in other cong) as a // sanity check that we don't spuriously match across congregations either. const otherCongResult = await withScope(otherCongId, tx => - countAvailableEntrances(tx, otherCongId, TerritoryKind.Commerces, { phoneTypeActive: true }), + countAvailableEntrances(tx, otherCongId, TerritoryKindKey.Commerces, { phoneTypeActive: true }), ) expect(otherCongResult.total).toBe(0) expect(result.total).toBe(2) diff --git a/app/features/territories/server/map-visibility.integration.test.ts b/app/features/territories/server/map-visibility.integration.test.ts index 2f5fd264..180a055b 100644 --- a/app/features/territories/server/map-visibility.integration.test.ts +++ b/app/features/territories/server/map-visibility.integration.test.ts @@ -3,7 +3,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { PrismaClient } from '~/database/generated/client' import { EntranceKind } from '~/features/territories/model/entrance-kind.type' import { TerritoryAccess } from '~/features/territories/model/territory-access.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { flushPendingAuditWrites } from '~/shared/domain/audit.server' const adapter = new PrismaPg({ @@ -103,15 +103,15 @@ beforeAll(async () => { await withScope(congId, async tx => { const own = await tx.territory.create({ - data: { number: `T-own-${ts}`, type: TerritoryKind.Classical, congregationId: congId }, + data: { number: `T-own-${ts}`, type: TerritoryKindKey.Classical, congregationId: congId }, }) ownClassicalId = own.id const other = await tx.territory.create({ - data: { number: `T-other-${ts}`, type: TerritoryKind.Classical, congregationId: congId }, + data: { number: `T-other-${ts}`, type: TerritoryKindKey.Classical, congregationId: congId }, }) otherClassicalId = other.id const phone = await tx.territory.create({ - data: { number: `T-phone-${ts}`, type: TerritoryKind.Phone, congregationId: congId }, + data: { number: `T-phone-${ts}`, type: TerritoryKindKey.Phone, congregationId: congId }, }) phoneTerritoryId = phone.id @@ -250,7 +250,11 @@ afterAll(async () => { await testDb.$disconnect() }) -async function idsFor(territoryId: number, territoryType: TerritoryKind, phoneTypeActive: boolean): Promise { +async function idsFor( + territoryId: number, + territoryType: TerritoryKindKey, + phoneTypeActive: boolean, +): Promise { return withScope(congId, async tx => { const result = await getEntrancesInBbox(tx as never, congId, territoryId, territoryType, BBOX, { phoneTypeActive }) return result.entrances.map(e => e.id).sort((a, b) => a - b) @@ -259,7 +263,7 @@ async function idsFor(territoryId: number, territoryType: TerritoryKind, phoneTy describe('getEntrancesInBbox — map-visibility rule', () => { it('Classical territory with phone-toggle ON: only homes>0 or digicode; own bypass; commerce/homes=0/phones-only excluded', async () => { - const ids = await idsFor(ownClassicalId, TerritoryKind.Classical, true) + const ids = await idsFor(ownClassicalId, TerritoryKindKey.Classical, true) expect(ids).toContain(ownProspected) expect(ids).toContain(ownBypassNoProspection) expect(ids).toContain(availableHomesOnly) @@ -272,14 +276,14 @@ describe('getEntrancesInBbox — map-visibility rule', () => { }) it('Classical territory with phone-toggle OFF: also includes phones-only', async () => { - const ids = await idsFor(ownClassicalId, TerritoryKind.Classical, false) + const ids = await idsFor(ownClassicalId, TerritoryKindKey.Classical, false) expect(ids).toContain(availablePhonesOnly) expect(ids).not.toContain(availableHomesZero) expect(ids).not.toContain(unprospected) }) it('Phone territory (toggle ON): only phones>0 or digicode; homes-only excluded', async () => { - const ids = await idsFor(phoneTerritoryId, TerritoryKind.Phone, true) + const ids = await idsFor(phoneTerritoryId, TerritoryKindKey.Phone, true) expect(ids).toContain(availablePhonesOnly) expect(ids).toContain(availableDigicode) expect(ids).not.toContain(availableHomesOnly) @@ -289,7 +293,7 @@ describe('getEntrancesInBbox — map-visibility rule', () => { it('exposes buildingId on each returned entrance so the popup can link to the building view', async () => { const result = await withScope(congId, async tx => - getEntrancesInBbox(tx as never, congId, ownClassicalId, TerritoryKind.Classical, BBOX, { + getEntrancesInBbox(tx as never, congId, ownClassicalId, TerritoryKindKey.Classical, BBOX, { phoneTypeActive: true, }), ) @@ -299,8 +303,8 @@ describe('getEntrancesInBbox — map-visibility rule', () => { }) it('shows an entrance attached to multiple territories as own from each perspective (Classical and Phone)', async () => { - const classicalIds = await idsFor(ownClassicalId, TerritoryKind.Classical, true) - const phoneIds = await idsFor(phoneTerritoryId, TerritoryKind.Phone, true) + const classicalIds = await idsFor(ownClassicalId, TerritoryKindKey.Classical, true) + const phoneIds = await idsFor(phoneTerritoryId, TerritoryKindKey.Phone, true) // Content clause would exclude the dual entrance (homes=null, phones=null, no code, no prospection). // Only the own-visible OR branch can surface it. expect(classicalIds).toContain(dualAttribution) @@ -309,7 +313,7 @@ describe('getEntrancesInBbox — map-visibility rule', () => { it('returns the first building id for entrances attached to multiple buildings', async () => { const result = await withScope(congId, async tx => - getEntrancesInBbox(tx as never, congId, ownClassicalId, TerritoryKind.Classical, BBOX, { + getEntrancesInBbox(tx as never, congId, ownClassicalId, TerritoryKindKey.Classical, BBOX, { phoneTypeActive: true, }), ) diff --git a/app/features/territories/server/map-visibility.test.ts b/app/features/territories/server/map-visibility.test.ts index 4c0105e2..5185834f 100644 --- a/app/features/territories/server/map-visibility.test.ts +++ b/app/features/territories/server/map-visibility.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { TerritoryAccess } from '~/features/territories/model/territory-access.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { availableForCreateWhere, contentPresentClause, mapVisibleWhere } from './map-visibility' const digicodeUnknown = { @@ -9,32 +9,32 @@ const digicodeUnknown = { describe('contentPresentClause', () => { it('returns null for Commerces territories (prospection alone is enough)', () => { - expect(contentPresentClause(TerritoryKind.Commerces, { phoneTypeActive: true })).toBeNull() - expect(contentPresentClause(TerritoryKind.Commerces, { phoneTypeActive: false })).toBeNull() + expect(contentPresentClause(TerritoryKindKey.Commerces, { phoneTypeActive: true })).toBeNull() + expect(contentPresentClause(TerritoryKindKey.Commerces, { phoneTypeActive: false })).toBeNull() }) it('returns null for Hotel territories', () => { - expect(contentPresentClause(TerritoryKind.Hotel, { phoneTypeActive: true })).toBeNull() + expect(contentPresentClause(TerritoryKindKey.Hotel, { phoneTypeActive: true })).toBeNull() }) it('returns null for Univ territories', () => { - expect(contentPresentClause(TerritoryKind.Univ, { phoneTypeActive: false })).toBeNull() + expect(contentPresentClause(TerritoryKindKey.Univ, { phoneTypeActive: false })).toBeNull() }) it('Classical with phone toggle ON requires homes > 0 or digicode-unknown', () => { - expect(contentPresentClause(TerritoryKind.Classical, { phoneTypeActive: true })).toEqual({ + expect(contentPresentClause(TerritoryKindKey.Classical, { phoneTypeActive: true })).toEqual({ OR: [{ homes: { gt: 0 } }, digicodeUnknown], }) }) it('Classical with phone toggle OFF falls back to homes OR phones OR digicode-unknown', () => { - expect(contentPresentClause(TerritoryKind.Classical, { phoneTypeActive: false })).toEqual({ + expect(contentPresentClause(TerritoryKindKey.Classical, { phoneTypeActive: false })).toEqual({ OR: [{ homes: { gt: 0 } }, { phones: { gt: 0 } }, digicodeUnknown], }) }) it('Phone requires phones > 0 or digicode-unknown', () => { - expect(contentPresentClause(TerritoryKind.Phone, { phoneTypeActive: true })).toEqual({ + expect(contentPresentClause(TerritoryKindKey.Phone, { phoneTypeActive: true })).toEqual({ OR: [{ phones: { gt: 0 } }, digicodeUnknown], }) }) @@ -42,12 +42,12 @@ describe('contentPresentClause', () => { describe('mapVisibleWhere', () => { it('always includes the own-territory branch, keyed by territoryId', () => { - const result = mapVisibleWhere(TerritoryKind.Classical, 42, { phoneTypeActive: true }) + const result = mapVisibleWhere(TerritoryKindKey.Classical, 42, { phoneTypeActive: true }) expect(result.OR?.[0]).toEqual({ territories: { some: { id: 42 } } }) }) it('wraps the content clause under a prospected-buildings AND for residential kinds', () => { - const result = mapVisibleWhere(TerritoryKind.Classical, 1, { phoneTypeActive: true }) + const result = mapVisibleWhere(TerritoryKindKey.Classical, 1, { phoneTypeActive: true }) expect(result.OR?.[1]).toEqual({ AND: [ { buildings: { some: { prospectionDate: { not: null } } } }, @@ -57,12 +57,12 @@ describe('mapVisibleWhere', () => { }) it('drops the content clause for non-residential kinds — prospection alone', () => { - const result = mapVisibleWhere(TerritoryKind.Commerces, 1, { phoneTypeActive: true }) + const result = mapVisibleWhere(TerritoryKindKey.Commerces, 1, { phoneTypeActive: true }) expect(result.OR?.[1]).toEqual({ buildings: { some: { prospectionDate: { not: null } } } }) }) it('composes the Classical toggle-off clause into the prospected branch', () => { - const result = mapVisibleWhere(TerritoryKind.Classical, 7, { phoneTypeActive: false }) + const result = mapVisibleWhere(TerritoryKindKey.Classical, 7, { phoneTypeActive: false }) expect(result).toEqual({ OR: [ { territories: { some: { id: 7 } } }, @@ -77,7 +77,7 @@ describe('mapVisibleWhere', () => { }) it('composes the Phone clause into the prospected branch', () => { - const result = mapVisibleWhere(TerritoryKind.Phone, 3, { phoneTypeActive: true }) + const result = mapVisibleWhere(TerritoryKindKey.Phone, 3, { phoneTypeActive: true }) expect(result).toEqual({ OR: [ { territories: { some: { id: 3 } } }, @@ -96,17 +96,17 @@ describe('availableForCreateWhere', () => { const prospectedAndActive = { buildings: { some: { active: true, prospectionDate: { not: null } } } } it('excludes entrances already attached to a territory of the same kind', () => { - const result = availableForCreateWhere(TerritoryKind.Commerces, { phoneTypeActive: true }) - expect(result.AND).toContainEqual({ territories: { none: { type: TerritoryKind.Commerces } } }) + const result = availableForCreateWhere(TerritoryKindKey.Commerces, { phoneTypeActive: true }) + expect(result.AND).toContainEqual({ territories: { none: { type: TerritoryKindKey.Commerces } } }) }) it('always requires at least one active building with a prospection date', () => { for (const kind of [ - TerritoryKind.Classical, - TerritoryKind.Phone, - TerritoryKind.Commerces, - TerritoryKind.Hotel, - TerritoryKind.Univ, + TerritoryKindKey.Classical, + TerritoryKindKey.Phone, + TerritoryKindKey.Commerces, + TerritoryKindKey.Hotel, + TerritoryKindKey.Univ, ]) { const result = availableForCreateWhere(kind, { phoneTypeActive: false }) expect(result.AND).toContainEqual(prospectedAndActive) @@ -114,10 +114,10 @@ describe('availableForCreateWhere', () => { }) it('Classical with phone toggle ON only shows intercom / doorbell / (code + isOpenEarly)', () => { - const result = availableForCreateWhere(TerritoryKind.Classical, { phoneTypeActive: true }) + const result = availableForCreateWhere(TerritoryKindKey.Classical, { phoneTypeActive: true }) expect(result).toEqual({ AND: [ - { territories: { none: { type: TerritoryKind.Classical } } }, + { territories: { none: { type: TerritoryKindKey.Classical } } }, prospectedAndActive, { OR: [ @@ -131,7 +131,7 @@ describe('availableForCreateWhere', () => { }) it('Classical with phone toggle OFF widens the code branch to any code entrance', () => { - const result = availableForCreateWhere(TerritoryKind.Classical, { phoneTypeActive: false }) + const result = availableForCreateWhere(TerritoryKindKey.Classical, { phoneTypeActive: false }) expect(result.AND).toContainEqual({ OR: [ { access: TerritoryAccess.Intercom }, @@ -142,10 +142,10 @@ describe('availableForCreateWhere', () => { }) it('Phone requires phones > 0 or a code-locked entrance that stays locked in the morning', () => { - const result = availableForCreateWhere(TerritoryKind.Phone, { phoneTypeActive: true }) + const result = availableForCreateWhere(TerritoryKindKey.Phone, { phoneTypeActive: true }) expect(result).toEqual({ AND: [ - { territories: { none: { type: TerritoryKind.Phone } } }, + { territories: { none: { type: TerritoryKindKey.Phone } } }, prospectedAndActive, { OR: [{ phones: { gt: 0 } }, { access: TerritoryAccess.Code, isOpenEarly: false }], @@ -155,29 +155,29 @@ describe('availableForCreateWhere', () => { }) it('Phone clause is independent of phoneTypeActive (tab loader gates access to the whole flow)', () => { - const on = availableForCreateWhere(TerritoryKind.Phone, { phoneTypeActive: true }) - const off = availableForCreateWhere(TerritoryKind.Phone, { phoneTypeActive: false }) + const on = availableForCreateWhere(TerritoryKindKey.Phone, { phoneTypeActive: true }) + const off = availableForCreateWhere(TerritoryKindKey.Phone, { phoneTypeActive: false }) expect(off).toEqual(on) }) it('Commerces has no access clause — prospection + not-already-typed is enough', () => { - const result = availableForCreateWhere(TerritoryKind.Commerces, { phoneTypeActive: false }) + const result = availableForCreateWhere(TerritoryKindKey.Commerces, { phoneTypeActive: false }) expect(result).toEqual({ - AND: [{ territories: { none: { type: TerritoryKind.Commerces } } }, prospectedAndActive], + AND: [{ territories: { none: { type: TerritoryKindKey.Commerces } } }, prospectedAndActive], }) }) it('Hotel has no access clause', () => { - const result = availableForCreateWhere(TerritoryKind.Hotel, { phoneTypeActive: true }) + const result = availableForCreateWhere(TerritoryKindKey.Hotel, { phoneTypeActive: true }) expect(result).toEqual({ - AND: [{ territories: { none: { type: TerritoryKind.Hotel } } }, prospectedAndActive], + AND: [{ territories: { none: { type: TerritoryKindKey.Hotel } } }, prospectedAndActive], }) }) it('Univ has no access clause', () => { - const result = availableForCreateWhere(TerritoryKind.Univ, { phoneTypeActive: false }) + const result = availableForCreateWhere(TerritoryKindKey.Univ, { phoneTypeActive: false }) expect(result).toEqual({ - AND: [{ territories: { none: { type: TerritoryKind.Univ } } }, prospectedAndActive], + AND: [{ territories: { none: { type: TerritoryKindKey.Univ } } }, prospectedAndActive], }) }) }) diff --git a/app/features/territories/server/map-visibility.ts b/app/features/territories/server/map-visibility.ts index 22513d8c..12bf38e2 100644 --- a/app/features/territories/server/map-visibility.ts +++ b/app/features/territories/server/map-visibility.ts @@ -1,6 +1,6 @@ import type { Prisma } from '~/database/generated/client' import { TerritoryAccess } from '~/features/territories/model/territory-access.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' /** * Which entrances appear on the territory edit map, as a Prisma `where` fragment. @@ -29,29 +29,29 @@ const digicodeUnknown: Prisma.BuildingEntranceWhereInput = { } export function contentPresentClause( - territoryType: TerritoryKind, + territoryType: TerritoryKindKey, { phoneTypeActive }: MapVisibilityContext, ): Prisma.BuildingEntranceWhereInput | null { switch (territoryType) { - case TerritoryKind.Commerces: - case TerritoryKind.Hotel: - case TerritoryKind.Univ: + case TerritoryKindKey.Commerces: + case TerritoryKindKey.Hotel: + case TerritoryKindKey.Univ: return null - case TerritoryKind.Phone: + case TerritoryKindKey.Phone: return { OR: [{ phones: { gt: 0 } }, digicodeUnknown] } - case TerritoryKind.Classical: + case TerritoryKindKey.Classical: return phoneTypeActive ? { OR: [{ homes: { gt: 0 } }, digicodeUnknown] } : { OR: [{ homes: { gt: 0 } }, { phones: { gt: 0 } }, digicodeUnknown] } default: { const exhaustiveCheck: never = territoryType - throw new Error(`Unhandled TerritoryKind in contentPresentClause: ${String(exhaustiveCheck)}`) + throw new Error(`Unhandled TerritoryKindKey in contentPresentClause: ${String(exhaustiveCheck)}`) } } } export function mapVisibleWhere( - territoryType: TerritoryKind, + territoryType: TerritoryKindKey, territoryId: number, ctx: MapVisibilityContext, ): Prisma.BuildingEntranceWhereInput { @@ -84,7 +84,7 @@ export function mapVisibleWhere( * Univ → no access filter */ export function availableForCreateWhere( - kind: TerritoryKind, + kind: TerritoryKindKey, ctx: MapVisibilityContext, ): Prisma.BuildingEntranceWhereInput { const notInSameKindTerritory: Prisma.BuildingEntranceWhereInput = { @@ -99,11 +99,11 @@ export function availableForCreateWhere( } function accessClauseForCreate( - kind: TerritoryKind, + kind: TerritoryKindKey, { phoneTypeActive }: MapVisibilityContext, ): Prisma.BuildingEntranceWhereInput | null { switch (kind) { - case TerritoryKind.Classical: + case TerritoryKindKey.Classical: return { OR: [ { access: TerritoryAccess.Intercom }, @@ -111,17 +111,17 @@ function accessClauseForCreate( phoneTypeActive ? { access: TerritoryAccess.Code, isOpenEarly: true } : { access: TerritoryAccess.Code }, ], } - case TerritoryKind.Phone: + case TerritoryKindKey.Phone: return { OR: [{ phones: { gt: 0 } }, { access: TerritoryAccess.Code, isOpenEarly: false }], } - case TerritoryKind.Commerces: - case TerritoryKind.Hotel: - case TerritoryKind.Univ: + case TerritoryKindKey.Commerces: + case TerritoryKindKey.Hotel: + case TerritoryKindKey.Univ: return null default: { const exhaustiveCheck: never = kind - throw new Error(`Unhandled TerritoryKind in accessClauseForCreate: ${String(exhaustiveCheck)}`) + throw new Error(`Unhandled TerritoryKindKey in accessClauseForCreate: ${String(exhaustiveCheck)}`) } } } diff --git a/app/features/territories/server/parse-stats-filter-params.server.test.ts b/app/features/territories/server/parse-stats-filter-params.server.test.ts index 1827d43f..88f76bb7 100644 --- a/app/features/territories/server/parse-stats-filter-params.server.test.ts +++ b/app/features/territories/server/parse-stats-filter-params.server.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { AttributionCategory } from '~/features/territories/model/attribution-category' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { parseStatsFilterParams } from './parse-stats-filter-params.server' describe('parseStatsFilterParams', () => { @@ -8,7 +8,7 @@ describe('parseStatsFilterParams', () => { const params = new URLSearchParams() const result = parseStatsFilterParams(params, 2025) - expect(result.territoryKind).toEqual([TerritoryKind.Classical]) + expect(result.territoryKind).toEqual([TerritoryKindKey.Classical]) expect(result.attributionKind).toEqual([AttributionCategory.Default, AttributionCategory.Campaign]) expect(result.startDate).toEqual(new Date(2025, 8, 1)) expect(result.endDate).toEqual(new Date(2026, 7, 31)) @@ -17,8 +17,8 @@ describe('parseStatsFilterParams', () => { it('utilise les paramètres fournis', () => { const params = new URLSearchParams() - params.append('kind', TerritoryKind.Phone) - params.append('kind', TerritoryKind.Commerces) + params.append('kind', TerritoryKindKey.Phone) + params.append('kind', TerritoryKindKey.Commerces) params.append('attributionKind', AttributionCategory.Campaign) params.set('startDate', '2025-01-01') params.set('endDate', '2025-06-30') @@ -26,7 +26,7 @@ describe('parseStatsFilterParams', () => { const result = parseStatsFilterParams(params, 2025) - expect(result.territoryKind).toEqual([TerritoryKind.Phone, TerritoryKind.Commerces]) + expect(result.territoryKind).toEqual([TerritoryKindKey.Phone, TerritoryKindKey.Commerces]) expect(result.attributionKind).toEqual([AttributionCategory.Campaign]) expect(result.startDate).toEqual(new Date(2025, 0, 1)) expect(result.endDate).toEqual(new Date(2025, 5, 30)) diff --git a/app/features/territories/server/parse-stats-filter-params.server.ts b/app/features/territories/server/parse-stats-filter-params.server.ts index 7c0475c7..02778b5a 100644 --- a/app/features/territories/server/parse-stats-filter-params.server.ts +++ b/app/features/territories/server/parse-stats-filter-params.server.ts @@ -1,6 +1,6 @@ import type { AttributionCategory } from '~/features/territories/model/attribution-category' import { DEFAULT_ATTRIBUTION_KINDS, DEFAULT_TERRITORY_KINDS } from '~/features/territories/model/stats-filter-defaults' -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { parseLocalDate } from '~/shared/utils/date.server' import type { StatsFilterParams } from './stats-filter-params.type' import { getBeginingDateOfTheocraticYear, getEndDateOfTheocraticYear } from './theocratic-year.server' @@ -22,7 +22,7 @@ export function parseStatsFilterParams(params: URLSearchParams, theocraticYear: const territoryKind = rawKinds.includes('none') ? [] : rawKinds.length > 0 - ? (rawKinds as TerritoryKind[]) + ? (rawKinds as TerritoryKindKey[]) : DEFAULT_TERRITORY_KINDS let startDate = parseLocalDateOrDefault(params.get('startDate'), getBeginingDateOfTheocraticYear(theocraticYear)) diff --git a/app/features/territories/server/proximity-loader.integration.test.ts b/app/features/territories/server/proximity-loader.integration.test.ts index d482e163..2f3cc856 100644 --- a/app/features/territories/server/proximity-loader.integration.test.ts +++ b/app/features/territories/server/proximity-loader.integration.test.ts @@ -11,7 +11,7 @@ import { PrismaPg } from '@prisma/adapter-pg' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { PrismaClient } from '~/database/generated/client' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' vi.mock('~/shared/domain/audit.server', () => ({ audit: vi.fn(), @@ -58,7 +58,7 @@ beforeAll(async () => { await withScope(congregationId, async tx => { // Near territory — building ~500m from origin. const near = await tx.territory.create({ - data: { number: '01', type: TerritoryKind.Classical, congregationId }, + data: { number: '01', type: TerritoryKindKey.Classical, congregationId }, }) nearId = near.id await tx.buildingEntrance.create({ @@ -75,7 +75,7 @@ beforeAll(async () => { // Far territory — ~5km from origin. const far = await tx.territory.create({ - data: { number: '02', type: TerritoryKind.Classical, congregationId }, + data: { number: '02', type: TerritoryKindKey.Classical, congregationId }, }) farId = far.id await tx.buildingEntrance.create({ @@ -92,7 +92,7 @@ beforeAll(async () => { // No-coords territory — entrance and building both null lat/lng. const noCoords = await tx.territory.create({ - data: { number: '03', type: TerritoryKind.Classical, congregationId }, + data: { number: '03', type: TerritoryKindKey.Classical, congregationId }, }) noCoordsId = noCoords.id await tx.buildingEntrance.create({ diff --git a/app/features/territories/server/split-tool-create.workflow.test.ts b/app/features/territories/server/split-tool-create.workflow.test.ts index 57df698b..9869797b 100644 --- a/app/features/territories/server/split-tool-create.workflow.test.ts +++ b/app/features/territories/server/split-tool-create.workflow.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { ConflictError, LimitReachedError } from '~/shared/errors/app-error.server' vi.mock('~/shared/infra/db.server', () => ({ @@ -27,7 +27,7 @@ const limitBreached: LimitStub = { } const validParams = { - type: TerritoryKind.Classical, + type: TerritoryKindKey.Classical, entranceIds: [1, 2, 3], congregationId: 42, actorId: 99, @@ -44,7 +44,7 @@ describe('splitToolCreateWorkflow', () => { vi.mocked(createTerritoryFromSplit).mockResolvedValue({ id: 7, number: 'D001', - type: TerritoryKind.Classical, + type: TerritoryKindKey.Classical, } as never) const result = await splitToolCreateWorkflow(db as never, validParams, okLimits) diff --git a/app/features/territories/server/split-tool-create.workflow.ts b/app/features/territories/server/split-tool-create.workflow.ts index b5b0c662..6b60a08b 100644 --- a/app/features/territories/server/split-tool-create.workflow.ts +++ b/app/features/territories/server/split-tool-create.workflow.ts @@ -1,4 +1,4 @@ -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { createTerritoryFromSplit } from '~/features/territories/server/create-territory-from-split.server' import * as m from '~/i18n/paraglide/messages' import { AppError } from '~/shared/errors/app-error.server' @@ -14,7 +14,7 @@ type LimitGuard = { } type WorkflowParams = { - type: TerritoryKind + type: TerritoryKindKey entranceIds: number[] congregationId: number actorId: number diff --git a/app/features/territories/server/stats-aggregate.integration.test.ts b/app/features/territories/server/stats-aggregate.integration.test.ts index 68508955..88408ce2 100644 --- a/app/features/territories/server/stats-aggregate.integration.test.ts +++ b/app/features/territories/server/stats-aggregate.integration.test.ts @@ -2,7 +2,7 @@ import { PrismaPg } from '@prisma/adapter-pg' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { PrismaClient } from '~/database/generated/client' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' const adapter = new PrismaPg({ connectionString: process.env.DB_RUNTIME_URL ?? process.env.DB_URL, @@ -101,7 +101,7 @@ beforeAll(async () => { // ── Boundary scenario: attribution on the last day of the filter window ── const lastDayTerritory = await tx.territory.create({ - data: { number: `T-BOUNDARY-${ts}`, type: TerritoryKind.Classical, congregationId }, + data: { number: `T-BOUNDARY-${ts}`, type: TerritoryKindKey.Classical, congregationId }, }) lastDayTerritoryId = lastDayTerritory.id @@ -118,10 +118,10 @@ beforeAll(async () => { // ── Group-filter scenario: each group has its own attribution ── const groupATerritory = await tx.territory.create({ - data: { number: `T-GA-${ts}`, type: TerritoryKind.Classical, congregationId }, + data: { number: `T-GA-${ts}`, type: TerritoryKindKey.Classical, congregationId }, }) const groupBTerritory = await tx.territory.create({ - data: { number: `T-GB-${ts}`, type: TerritoryKind.Classical, congregationId }, + data: { number: `T-GB-${ts}`, type: TerritoryKindKey.Classical, congregationId }, }) await tx.attribution.create({ data: { @@ -151,7 +151,7 @@ beforeAll(async () => { // 2. An in-progress attribution // It should count as `active working`, NOT `resting`, NOT `available`. const workingResting = await tx.territory.create({ - data: { number: `T-WR-${ts}`, type: TerritoryKind.Classical, congregationId }, + data: { number: `T-WR-${ts}`, type: TerritoryKindKey.Classical, congregationId }, }) workingPlusRestingTerritoryId = workingResting.id @@ -188,7 +188,7 @@ beforeAll(async () => { const oldTerritory = await tx.territory.create({ data: { number: `T-OLD-${ts}`, - type: TerritoryKind.Classical, + type: TerritoryKindKey.Classical, congregationId, createdAt: new Date(2024, 0, 1), // before Aug 31, 2025 cutoff }, @@ -198,7 +198,7 @@ beforeAll(async () => { const recentTerritory = await tx.territory.create({ data: { number: `T-NEW-${ts}`, - type: TerritoryKind.Classical, + type: TerritoryKindKey.Classical, congregationId, createdAt: new Date(2026, 0, 1), // after Aug 31, 2025 cutoff }, @@ -209,7 +209,7 @@ beforeAll(async () => { // Seed enough untouched territories that getTerritoriesNeverWorked must cap. for (let i = 0; i < NEVER_WORKED_MAX + 1; i += 1) { await tx.territory.create({ - data: { number: `T-CAP-${ts}-${String(i).padStart(2, '0')}`, type: TerritoryKind.Classical, congregationId }, + data: { number: `T-CAP-${ts}-${String(i).padStart(2, '0')}`, type: TerritoryKindKey.Classical, congregationId }, }) } @@ -218,7 +218,7 @@ beforeAll(async () => { // lateDate falls BEFORE the filter window. The in-window aggregate should only // count the in-window late event. const overdueTerritory = await tx.territory.create({ - data: { number: `T-OVERDUE-${ts}`, type: TerritoryKind.Classical, congregationId }, + data: { number: `T-OVERDUE-${ts}`, type: TerritoryKindKey.Classical, congregationId }, }) overdueTerritoryId = overdueTerritory.id @@ -272,7 +272,7 @@ describe('stats aggregates — boundary semantics (R1)', () => { return fetchAttributionsForStats( tx as never, { - territoryKind: [TerritoryKind.Classical], + territoryKind: [TerritoryKindKey.Classical], attributionKind: [TerritoryAttributionKind.Default], startDate: FILTER_START, endDate: FILTER_END, @@ -292,7 +292,7 @@ describe('stats aggregates — group scoping (#8)', () => { computeTerritoryCoverage( tx as never, congregationId, - [TerritoryKind.Classical], + [TerritoryKindKey.Classical], [TerritoryAttributionKind.Default], FILTER_START, FILTER_END, @@ -300,7 +300,7 @@ describe('stats aggregates — group scoping (#8)', () => { computeTerritoryCoverage( tx as never, congregationId, - [TerritoryKind.Classical], + [TerritoryKindKey.Classical], [TerritoryAttributionKind.Default], FILTER_START, FILTER_END, @@ -309,7 +309,7 @@ describe('stats aggregates — group scoping (#8)', () => { computeTerritoryCoverage( tx as never, congregationId, - [TerritoryKind.Classical], + [TerritoryKindKey.Classical], [TerritoryAttributionKind.Default], FILTER_START, FILTER_END, @@ -366,7 +366,7 @@ describe('stats aggregates — never-worked cap (T17)', () => { return getTerritoriesNeverWorked( tx as never, { - territoryKind: [TerritoryKind.Classical], + territoryKind: [TerritoryKindKey.Classical], attributionKind: [TerritoryAttributionKind.Default], startDate: FILTER_START, endDate: FILTER_END, @@ -386,7 +386,7 @@ describe('stats aggregates — overdue rate restricted to in-window lateDate (#1 return aggregateAttributionStatsForWindow( tx as never, { - territoryKind: [TerritoryKind.Classical], + territoryKind: [TerritoryKindKey.Classical], attributionKind: [TerritoryAttributionKind.Default], startDate: FILTER_START, endDate: FILTER_END, @@ -413,7 +413,7 @@ describe('stats aggregates — previous-year denominator (#11)', () => { const [allCount, beforeCutoffCount] = await withScope(congregationId, async tx => { return Promise.all([ tx.territory.count({ where: { congregationId } }), - countTerritoriesExistingBefore(tx as never, congregationId, cutoff, [TerritoryKind.Classical]), + countTerritoriesExistingBefore(tx as never, congregationId, cutoff, [TerritoryKindKey.Classical]), ]) }) diff --git a/app/features/territories/server/stats-attribution.type.ts b/app/features/territories/server/stats-attribution.type.ts index 484f6a39..294c7a18 100644 --- a/app/features/territories/server/stats-attribution.type.ts +++ b/app/features/territories/server/stats-attribution.type.ts @@ -1,11 +1,11 @@ import type { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' export interface StatsAttribution { id: number territoryId: number territoryNumber: string - territoryType: TerritoryKind + territoryType: TerritoryKindKey type: TerritoryAttributionKind campaignId: number | null campaignRestPeriodDays: number | null diff --git a/app/features/territories/server/stats-filter-params.type.ts b/app/features/territories/server/stats-filter-params.type.ts index 79b62de6..d9fa910c 100644 --- a/app/features/territories/server/stats-filter-params.type.ts +++ b/app/features/territories/server/stats-filter-params.type.ts @@ -1,8 +1,8 @@ import type { AttributionCategory } from '~/features/territories/model/attribution-category' -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' export interface StatsFilterParams { - territoryKind: TerritoryKind[] + territoryKind: TerritoryKindKey[] attributionKind: AttributionCategory[] startDate: Date endDate: Date diff --git a/app/features/territories/server/territories-never-worked.server.test.ts b/app/features/territories/server/territories-never-worked.server.test.ts index 54833db6..618bb2b5 100644 --- a/app/features/territories/server/territories-never-worked.server.test.ts +++ b/app/features/territories/server/territories-never-worked.server.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { @@ -12,7 +12,7 @@ const { getTerritoriesNeverWorked, NEVER_WORKED_MAX } = await import('./territor const { unscopedDb: db } = await import('~/shared/infra/db.server') const baseParams = { - territoryKind: [TerritoryKind.Classical], + territoryKind: [TerritoryKindKey.Classical], attributionKind: [TerritoryAttributionKind.Default], startDate: new Date(2025, 8, 1), endDate: new Date(2026, 7, 31), diff --git a/app/features/territories/server/territory-content-label.test.ts b/app/features/territories/server/territory-content-label.test.ts index 63fa5983..49845d12 100644 --- a/app/features/territories/server/territory-content-label.test.ts +++ b/app/features/territories/server/territory-content-label.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { territoryContentLabel } from './territory-content-label' const e = (homes: number | null, phones: number | null = null) => ({ homes, phones }) @@ -7,63 +7,63 @@ const e = (homes: number | null, phones: number | null = null) => ({ homes, phon describe('territoryContentLabel', () => { describe('Phone territories', () => { it('sums the phones field across entrances', () => { - expect(territoryContentLabel(TerritoryKind.Phone, [e(0, 12), e(0, 8)])).toBe('20 tél.') + expect(territoryContentLabel(TerritoryKindKey.Phone, [e(0, 12), e(0, 8)])).toBe('20 tél.') }) it('treats null phones as 0', () => { - expect(territoryContentLabel(TerritoryKind.Phone, [e(0, null), e(0, 5)])).toBe('5 tél.') + expect(territoryContentLabel(TerritoryKindKey.Phone, [e(0, null), e(0, 5)])).toBe('5 tél.') }) it('returns 0 for an empty list', () => { - expect(territoryContentLabel(TerritoryKind.Phone, [])).toBe('0 tél.') + expect(territoryContentLabel(TerritoryKindKey.Phone, [])).toBe('0 tél.') }) }) describe('Classical / Univ territories', () => { - it.each([TerritoryKind.Classical, TerritoryKind.Univ])('sums homes for %s', kind => { + it.each([TerritoryKindKey.Classical, TerritoryKindKey.Univ])('sums homes for %s', kind => { expect(territoryContentLabel(kind, [e(10), e(20), e(5)])).toBe('35 foyers') }) it('falls back to phones when homes is 0 (e.g. residential entrance with no doors but phones)', () => { - expect(territoryContentLabel(TerritoryKind.Classical, [e(0, 8)])).toBe('8 foyers') + expect(territoryContentLabel(TerritoryKindKey.Classical, [e(0, 8)])).toBe('8 foyers') }) it('uses the singular key when count is exactly 1', () => { - expect(territoryContentLabel(TerritoryKind.Classical, [e(1)])).toBe('1 foyer') + expect(territoryContentLabel(TerritoryKindKey.Classical, [e(1)])).toBe('1 foyer') }) it('returns 0 for an empty list (singular branch)', () => { - expect(territoryContentLabel(TerritoryKind.Classical, [])).toBe('0 foyer') + expect(territoryContentLabel(TerritoryKindKey.Classical, [])).toBe('0 foyer') }) }) describe('Commerces territories', () => { it('counts entrances regardless of homes/phones', () => { - expect(territoryContentLabel(TerritoryKind.Commerces, [e(0), e(0), e(0)])).toBe('3 commerces') + expect(territoryContentLabel(TerritoryKindKey.Commerces, [e(0), e(0), e(0)])).toBe('3 commerces') }) it('uses the singular key when there is exactly one entrance', () => { - expect(territoryContentLabel(TerritoryKind.Commerces, [e(0)])).toBe('1 commerce') + expect(territoryContentLabel(TerritoryKindKey.Commerces, [e(0)])).toBe('1 commerce') }) it('returns 0 for an empty list (singular branch)', () => { - expect(territoryContentLabel(TerritoryKind.Commerces, [])).toBe('0 commerce') + expect(territoryContentLabel(TerritoryKindKey.Commerces, [])).toBe('0 commerce') }) }) describe('Hotel territories', () => { it('counts entrances regardless of homes/phones', () => { - expect(territoryContentLabel(TerritoryKind.Hotel, [e(0), e(0)])).toBe('2 hôtels') + expect(territoryContentLabel(TerritoryKindKey.Hotel, [e(0), e(0)])).toBe('2 hôtels') }) it('uses the singular key when there is exactly one entrance', () => { - expect(territoryContentLabel(TerritoryKind.Hotel, [e(0)])).toBe('1 hôtel') + expect(territoryContentLabel(TerritoryKindKey.Hotel, [e(0)])).toBe('1 hôtel') }) }) it('falls back to the entrances key for unknown territory types', () => { // Cast through unknown to satisfy the strict signature while exercising the default branch. - expect(territoryContentLabel('unknown-kind' as unknown as TerritoryKind, [e(0), e(0)])).toBe('2 entrées') - expect(territoryContentLabel('unknown-kind' as unknown as TerritoryKind, [e(0)])).toBe('1 entrée') + expect(territoryContentLabel('unknown-kind' as unknown as TerritoryKindKey, [e(0), e(0)])).toBe('2 entrées') + expect(territoryContentLabel('unknown-kind' as unknown as TerritoryKindKey, [e(0)])).toBe('1 entrée') }) }) diff --git a/app/features/territories/server/territory-content-label.ts b/app/features/territories/server/territory-content-label.ts index 09d4678f..ebcea9a4 100644 --- a/app/features/territories/server/territory-content-label.ts +++ b/app/features/territories/server/territory-content-label.ts @@ -1,22 +1,22 @@ -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import * as m from '~/i18n/paraglide/messages' type EntranceLike = { homes: number | null; phones: number | null } -export function territoryContentLabel(type: TerritoryKind, entrances: EntranceLike[]): string { - if (type === TerritoryKind.Phone) { +export function territoryContentLabel(type: TerritoryKindKey, entrances: EntranceLike[]): string { + if (type === TerritoryKindKey.Phone) { const count = entrances.reduce((acc, e) => acc + (e.phones ?? 0), 0) return m.territories_content_phones({ count }) } - if (type === TerritoryKind.Classical || type === TerritoryKind.Univ) { + if (type === TerritoryKindKey.Classical || type === TerritoryKindKey.Univ) { const count = entrances.reduce((acc, e) => acc + ((e.homes ?? 0) || (e.phones ?? 0)), 0) return count > 1 ? m.territories_content_homes_other({ count }) : m.territories_content_homes_one({ count }) } const count = entrances.length - if (type === TerritoryKind.Commerces) { + if (type === TerritoryKindKey.Commerces) { return count > 1 ? m.territories_content_commerces_other({ count }) : m.territories_content_commerces_one({ count }) } - if (type === TerritoryKind.Hotel) { + if (type === TerritoryKindKey.Hotel) { return count > 1 ? m.territories_content_hotels_other({ count }) : m.territories_content_hotels_one({ count }) } return count > 1 ? m.territories_content_entrances_other({ count }) : m.territories_content_entrances_one({ count }) diff --git a/app/features/territories/server/territory-content.queries.integration.test.ts b/app/features/territories/server/territory-content.queries.integration.test.ts index 74878721..6be4a541 100644 --- a/app/features/territories/server/territory-content.queries.integration.test.ts +++ b/app/features/territories/server/territory-content.queries.integration.test.ts @@ -2,7 +2,7 @@ import { PrismaPg } from '@prisma/adapter-pg' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { PrismaClient } from '~/database/generated/client' import { EntranceKind } from '~/features/territories/model/entrance-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { flushPendingAuditWrites } from '~/shared/domain/audit.server' const adapter = new PrismaPg({ @@ -43,11 +43,11 @@ beforeAll(async () => { await withScope(congId, async tx => { const classical = await tx.territory.create({ - data: { number: `C-${ts}`, type: TerritoryKind.Classical, congregationId: congId }, + data: { number: `C-${ts}`, type: TerritoryKindKey.Classical, congregationId: congId }, }) classicalTerritoryId = classical.id const commerce = await tx.territory.create({ - data: { number: `S-${ts}`, type: TerritoryKind.Commerces, congregationId: congId }, + data: { number: `S-${ts}`, type: TerritoryKindKey.Commerces, congregationId: congId }, }) commerceTerritoryId = commerce.id @@ -96,7 +96,7 @@ beforeAll(async () => { await withScope(otherCongId, async tx => { const foreign = await tx.territory.create({ - data: { number: `O-${ts}`, type: TerritoryKind.Classical, congregationId: otherCongId }, + data: { number: `O-${ts}`, type: TerritoryKindKey.Classical, congregationId: otherCongId }, }) otherCongTerritoryId = foreign.id const b = await tx.building.create({ @@ -143,7 +143,7 @@ describe('getTerritoryContent', () => { it('aggregates residential homes for a Classical territory (nulls treated as 0)', async () => { const result = await withScope(congId, tx => getTerritoryContent(tx as never, classicalTerritoryId, congId)) expect(result).not.toBeNull() - expect(result?.kind).toBe(TerritoryKind.Classical) + expect(result?.kind).toBe(TerritoryKindKey.Classical) expect(result?.entranceCount).toBe(3) expect(result?.homes).toBe(5) expect(result?.quantity).toBe(5) @@ -153,7 +153,7 @@ describe('getTerritoryContent', () => { const result = await withScope(congId, tx => getTerritoryContent(tx as never, commerceTerritoryId, congId)) expect(result?.entranceCount).toBe(3) expect(result?.quantity).toBe(3) - expect(result?.kind).toBe(TerritoryKind.Commerces) + expect(result?.kind).toBe(TerritoryKindKey.Commerces) }) it('returns null when the territory does not exist', async () => { diff --git a/app/features/territories/server/territory-content.queries.ts b/app/features/territories/server/territory-content.queries.ts index 101fd2de..6b93c86d 100644 --- a/app/features/territories/server/territory-content.queries.ts +++ b/app/features/territories/server/territory-content.queries.ts @@ -1,4 +1,4 @@ -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { aggregateEntrance } from '~/features/territories/server/buildings.server' import { computeTerritoryQuantity } from '~/features/territories/server/compute-territory-quantity' import type { TransactionClient } from '~/shared/infra/db.server' @@ -6,7 +6,7 @@ import type { TransactionClient } from '~/shared/infra/db.server' export type TerritoryContent = { id: number number: string - kind: TerritoryKind + kind: TerritoryKindKey entranceCount: number quantity: number homes: number diff --git a/app/features/territories/server/territory-count-by-type.type.ts b/app/features/territories/server/territory-count-by-type.type.ts index 5b50aaaf..7afe63bb 100644 --- a/app/features/territories/server/territory-count-by-type.type.ts +++ b/app/features/territories/server/territory-count-by-type.type.ts @@ -1,7 +1,7 @@ -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' export interface TerritoryCountByType { - type: TerritoryKind + type: TerritoryKindKey count: number } diff --git a/app/features/territories/server/territory-coverage-total.server.test.ts b/app/features/territories/server/territory-coverage-total.server.test.ts index 7c0e885d..04bdac3b 100644 --- a/app/features/territories/server/territory-coverage-total.server.test.ts +++ b/app/features/territories/server/territory-coverage-total.server.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { @@ -11,8 +11,8 @@ vi.mock('~/shared/infra/db.server', () => ({ const { computeTerritoryCoverageTotal } = await import('./territory-coverage-total.server') const { unscopedDb: db } = await import('~/shared/infra/db.server') -const baseArgs: [TerritoryKind[], TerritoryAttributionKind[], Date, Date] = [ - [TerritoryKind.Classical], +const baseArgs: [TerritoryKindKey[], TerritoryAttributionKind[], Date, Date] = [ + [TerritoryKindKey.Classical], [TerritoryAttributionKind.Default], new Date(2025, 8, 1), new Date(2026, 7, 31), diff --git a/app/features/territories/server/territory-coverage-total.server.ts b/app/features/territories/server/territory-coverage-total.server.ts index 9a732904..182808eb 100644 --- a/app/features/territories/server/territory-coverage-total.server.ts +++ b/app/features/territories/server/territory-coverage-total.server.ts @@ -1,5 +1,5 @@ import type { AttributionCategory } from '~/features/territories/model/attribution-category' -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { TransactionClient } from '~/shared/infra/db.server' import { buildAttributionCategoryWhere } from './attribution-category-where.server' import { buildAttributionDateOverlapWhere } from './attribution-date-overlap.server' @@ -7,7 +7,7 @@ import { buildAttributionDateOverlapWhere } from './attribution-date-overlap.ser export async function computeTerritoryCoverageTotal( db: TransactionClient, congregationId: number, - territoryKind: TerritoryKind[], + territoryKind: TerritoryKindKey[], attributionKind: AttributionCategory[], startDate: Date, endDate: Date, diff --git a/app/features/territories/server/territory-coverage.server.test.ts b/app/features/territories/server/territory-coverage.server.test.ts index 6bd91111..098c7abd 100644 --- a/app/features/territories/server/territory-coverage.server.test.ts +++ b/app/features/territories/server/territory-coverage.server.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { @@ -12,8 +12,8 @@ vi.mock('~/shared/infra/db.server', () => ({ const { computeTerritoryCoverage } = await import('./territory-coverage.server') const { unscopedDb: db } = await import('~/shared/infra/db.server') -const baseArgs: [TerritoryKind[], TerritoryAttributionKind[], Date, Date] = [ - [TerritoryKind.Classical], +const baseArgs: [TerritoryKindKey[], TerritoryAttributionKind[], Date, Date] = [ + [TerritoryKindKey.Classical], [TerritoryAttributionKind.Default], new Date(2025, 8, 1), new Date(2026, 7, 31), diff --git a/app/features/territories/server/territory-coverage.server.ts b/app/features/territories/server/territory-coverage.server.ts index a2e4fce4..1f377074 100644 --- a/app/features/territories/server/territory-coverage.server.ts +++ b/app/features/territories/server/territory-coverage.server.ts @@ -1,5 +1,5 @@ import type { AttributionCategory } from '~/features/territories/model/attribution-category' -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { TransactionClient } from '~/shared/infra/db.server' import { buildAttributionCategoryWhere } from './attribution-category-where.server' import { buildAttributionDateOverlapWhere } from './attribution-date-overlap.server' @@ -7,7 +7,7 @@ import { buildAttributionDateOverlapWhere } from './attribution-date-overlap.ser export async function computeTerritoryCoverage( db: TransactionClient, congregationId: number, - territoryKind: TerritoryKind[], + territoryKind: TerritoryKindKey[], attributionKind: AttributionCategory[], startDate: Date, endDate: Date, diff --git a/app/features/territories/server/territory-filters.server.test.ts b/app/features/territories/server/territory-filters.server.test.ts index deca7966..a6dd23a1 100644 --- a/app/features/territories/server/territory-filters.server.test.ts +++ b/app/features/territories/server/territory-filters.server.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { computeFilters } from './territory-filters.server' describe('computeFilters', () => { @@ -29,8 +29,8 @@ describe('computeFilters', () => { }) it('applies type filter', () => { - const result = computeFilters(new URLSearchParams({ type: TerritoryKind.Classical })) - expect(result).toMatchObject({ type: { equals: TerritoryKind.Classical } }) + const result = computeFilters(new URLSearchParams({ type: TerritoryKindKey.Classical })) + expect(result).toMatchObject({ type: { equals: TerritoryKindKey.Classical } }) }) it('ignores type filter when type is "none"', () => { diff --git a/app/features/territories/server/territory-filters.server.ts b/app/features/territories/server/territory-filters.server.ts index f27f58d5..d96e1128 100644 --- a/app/features/territories/server/territory-filters.server.ts +++ b/app/features/territories/server/territory-filters.server.ts @@ -1,5 +1,5 @@ import type { Prisma } from '~/database/generated/client' -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { stripDiacritics } from '~/shared/utils/strip-diacritics' import { addressRegex, proximityPrefix } from './address-regex' @@ -41,7 +41,7 @@ function applyTypeFilter(filters: Prisma.TerritoryWhereInput, params: URLSearchP ...filters, type: { ...(typeof filters.type !== 'string' ? filters.type : {}), - equals: params.get('type') as TerritoryKind, + equals: params.get('type') as TerritoryKindKey, }, } } diff --git a/app/features/territories/server/territory-kinds.integration.test.ts b/app/features/territories/server/territory-kinds.integration.test.ts new file mode 100644 index 00000000..9217cf29 --- /dev/null +++ b/app/features/territories/server/territory-kinds.integration.test.ts @@ -0,0 +1,355 @@ +import { PrismaPg } from '@prisma/adapter-pg' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { PrismaClient } from '~/database/generated/client' +import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' +import { flushPendingAuditWrites } from '~/shared/domain/audit.server' +import { findAttributablePublishers } from './attributable-publishers.queries' +import * as attributionAggregate from './attribution.aggregate' +import { assertPublisherAllowedForKind } from './attribution-eligibility.policy' +import { createAttribution } from './create-attribution.server' +import { getKindAllowedRoleIds, listTerritoryKindsWithRoles } from './territory-kinds.queries' +import { seedBuiltInTerritoryKinds, setKindAllowedRoles } from './territory-kinds.server' +import { updateAttribution } from './update-attribution.server' + +const adapter = new PrismaPg({ + connectionString: process.env.DB_RUNTIME_URL ?? process.env.DB_URL, + max: 5, + connectionTimeoutMillis: 5000, +}) +const testDb = new PrismaClient({ adapter }) + +type Tx = Parameters[0]>[0] + +function withScope(congregationId: number, fn: (tx: Tx) => Promise): Promise { + return testDb.$transaction(async tx => { + await tx.$executeRawUnsafe(`SET LOCAL app.congregation_id = '${String(congregationId)}'`) + return fn(tx) + }) +} + +const ts = Date.now() +let primaryCongId: number +let foreignCongId: number +let elderRoleId: number +let publisherRoleId: number +/** Holds the elder role directly on the member (identity-role path). */ +let elderMemberId: number +/** Holds the elder role through their linked account (custom-role path). */ +let accountElderMemberId: number +/** Publisher with neither. */ +let plainMemberId: number +let actorId: number + +beforeAll(async () => { + const primary = await testDb.congregation.create({ + data: { name: `TerritoryKinds Primary ${ts}`, slug: `tk-primary-${ts}`, active: true }, + }) + primaryCongId = primary.id + + const foreign = await testDb.congregation.create({ + data: { name: `TerritoryKinds Foreign ${ts}`, slug: `tk-foreign-${ts}`, active: true }, + }) + foreignCongId = foreign.id + + await withScope(primaryCongId, async tx => { + await seedBuiltInTerritoryKinds(tx, primaryCongId) + + const elder = await tx.role.create({ data: { key: 'elder', isBuiltIn: true, congregationId: primaryCongId } }) + elderRoleId = elder.id + const publisher = await tx.role.create({ + data: { key: 'publisher', isBuiltIn: true, congregationId: primaryCongId }, + }) + publisherRoleId = publisher.id + + const elderMember = await tx.member.create({ + data: { firstname: 'Aline', lastname: 'Ancien', isPublisher: true, congregationId: primaryCongId }, + }) + elderMemberId = elderMember.id + await tx.memberRoleAssignment.create({ + data: { memberId: elderMember.id, roleId: elder.id, congregationId: primaryCongId }, + }) + + const accountElderMember = await tx.member.create({ + data: { firstname: 'Bruno', lastname: 'Compte', isPublisher: true, congregationId: primaryCongId }, + }) + accountElderMemberId = accountElderMember.id + const account = await tx.userAccount.create({ + data: { + email: `tk-account-${ts}@test.com`, + password: 'h', + active: true, + memberId: accountElderMember.id, + congregationId: primaryCongId, + }, + }) + actorId = account.id + await tx.userRoleAssignment.create({ + data: { userId: account.id, roleId: elder.id, congregationId: primaryCongId }, + }) + + const plainMember = await tx.member.create({ + data: { firstname: 'Chloé', lastname: 'Simple', isPublisher: true, congregationId: primaryCongId }, + }) + plainMemberId = plainMember.id + }) + + await withScope(foreignCongId, async tx => { + await seedBuiltInTerritoryKinds(tx, foreignCongId) + const foreignElder = await tx.role.create({ + data: { key: 'elder', isBuiltIn: true, congregationId: foreignCongId }, + }) + const foreignPhone = await tx.territoryKind.findFirstOrThrow({ + where: { key: TerritoryKindKey.Phone, congregationId: foreignCongId }, + }) + await tx.territoryKindAllowedRole.create({ + data: { kindId: foreignPhone.id, roleId: foreignElder.id, congregationId: foreignCongId }, + }) + }) +}) + +afterAll(async () => { + const scope = { congregationId: { in: [primaryCongId, foreignCongId] } } + await testDb.territoryKindAllowedRole.deleteMany({ where: scope }) + await testDb.territoryKind.deleteMany({ where: scope }) + await testDb.userRoleAssignment.deleteMany({ where: scope }) + await testDb.memberRoleAssignment.deleteMany({ where: scope }) + await testDb.userAccount.deleteMany({ where: scope }) + await testDb.member.deleteMany({ where: scope }) + await testDb.role.deleteMany({ where: scope }) + // Drain fire-and-forget audit writes before deleting the congregations, + // otherwise an in-flight write lands after cleanup and breaks the FK. + await flushPendingAuditWrites() + await testDb.auditLog.deleteMany({ where: scope }) + await testDb.congregation.deleteMany({ where: { id: { in: [primaryCongId, foreignCongId] } } }) + await testDb.$disconnect() +}) + +describe('seedBuiltInTerritoryKinds', () => { + it('creates the five built-in kinds and is safe to re-run', async () => { + await withScope(primaryCongId, tx => seedBuiltInTerritoryKinds(tx, primaryCongId)) + + const kinds = await withScope(primaryCongId, tx => listTerritoryKindsWithRoles(tx, primaryCongId)) + expect(kinds.map(k => k.key).sort()).toEqual(['Classical', 'Commerces', 'Hotel', 'Phone', 'Univ']) + expect(kinds.every(k => k.isBuiltIn)).toBe(true) + }) +}) + +describe('setKindAllowedRoles', () => { + it('persists the selection and reads it back', async () => { + await withScope(primaryCongId, tx => + setKindAllowedRoles(tx, TerritoryKindKey.Phone, [elderRoleId], primaryCongId, actorId), + ) + + const roleIds = await withScope(primaryCongId, tx => + getKindAllowedRoleIds(tx, TerritoryKindKey.Phone, primaryCongId), + ) + expect(roleIds).toEqual([elderRoleId]) + + await withScope(primaryCongId, tx => setKindAllowedRoles(tx, TerritoryKindKey.Phone, [], primaryCongId, actorId)) + }) + + it('replaces rather than accumulates across saves', async () => { + await withScope(primaryCongId, tx => + setKindAllowedRoles(tx, TerritoryKindKey.Commerces, [elderRoleId, publisherRoleId], primaryCongId, actorId), + ) + await withScope(primaryCongId, tx => + setKindAllowedRoles(tx, TerritoryKindKey.Commerces, [publisherRoleId], primaryCongId, actorId), + ) + + const roleIds = await withScope(primaryCongId, tx => + getKindAllowedRoleIds(tx, TerritoryKindKey.Commerces, primaryCongId), + ) + expect(roleIds).toEqual([publisherRoleId]) + + await withScope(primaryCongId, tx => + setKindAllowedRoles(tx, TerritoryKindKey.Commerces, [], primaryCongId, actorId), + ) + }) + + it('drops the link when the role is deleted, leaving the kind unrestricted', async () => { + const doomed = await withScope(primaryCongId, tx => + tx.role.create({ data: { key: `temp-${ts}`, name: 'Temp', congregationId: primaryCongId } }), + ) + await withScope(primaryCongId, tx => + setKindAllowedRoles(tx, TerritoryKindKey.Hotel, [doomed.id], primaryCongId, actorId), + ) + + await withScope(primaryCongId, tx => tx.role.delete({ where: { id: doomed.id } })) + + const roleIds = await withScope(primaryCongId, tx => + getKindAllowedRoleIds(tx, TerritoryKindKey.Hotel, primaryCongId), + ) + expect(roleIds).toEqual([]) + }) +}) + +describe('tenant isolation', () => { + it('does not surface another congregation configuration', async () => { + const roleIds = await withScope(primaryCongId, tx => + getKindAllowedRoleIds(tx, TerritoryKindKey.Phone, primaryCongId), + ) + expect(roleIds).toEqual([]) + + const rows = await withScope(primaryCongId, tx => tx.territoryKindAllowedRole.findMany({})) + expect(rows.every(row => row.congregationId === primaryCongId)).toBe(true) + }) +}) + +describe('findAttributablePublishers', () => { + it('lists every active publisher for an unrestricted kind', async () => { + const result = await withScope(primaryCongId, tx => + findAttributablePublishers(tx, TerritoryKindKey.Classical, primaryCongId), + ) + + expect(result.map(p => p.id).sort()).toEqual([elderMemberId, accountElderMemberId, plainMemberId].sort()) + }) + + it('reaches the role through both the member and their account', async () => { + await withScope(primaryCongId, tx => + setKindAllowedRoles(tx, TerritoryKindKey.Univ, [elderRoleId], primaryCongId, actorId), + ) + + const result = await withScope(primaryCongId, tx => + findAttributablePublishers(tx, TerritoryKindKey.Univ, primaryCongId), + ) + + expect(result.map(p => p.id).sort()).toEqual([elderMemberId, accountElderMemberId].sort()) + }) + + it('keeps the already-attributed publisher listed even when they no longer qualify', async () => { + const result = await withScope(primaryCongId, tx => + findAttributablePublishers(tx, TerritoryKindKey.Univ, primaryCongId, { alwaysIncludeMemberId: plainMemberId }), + ) + + expect(result.map(p => p.id)).toContain(plainMemberId) + }) +}) + +describe('role gating on the attribution paths', () => { + it('blocks the human path but leaves the aggregate open for the campaign sweep', async () => { + const territory = await withScope(primaryCongId, tx => + tx.territory.create({ + data: { number: `TK-${ts}`, type: TerritoryKindKey.Phone, notes: '', congregationId: primaryCongId }, + }), + ) + await withScope(primaryCongId, tx => + setKindAllowedRoles(tx, TerritoryKindKey.Phone, [elderRoleId], primaryCongId, actorId), + ) + + const params = { + publisherId: plainMemberId, + territoryId: territory.id, + startDate: '2026-01-05', + notes: '', + type: TerritoryAttributionKind.Default, + congregationId: primaryCongId, + actorId, + } + + // The routes go through createAttribution, which gates. + await withScope(primaryCongId, async tx => { + await expect(createAttribution(tx, params)).rejects.toThrow('publisher_role_not_allowed') + }) + + // campaign-lifecycle.workflow calls the aggregate directly, and must keep + // carrying an existing pairing across a role change — _reassignIntoCampaign + // swallows ConflictError, so a gate here would silently drop the territory. + const carried = await withScope(primaryCongId, tx => attributionAggregate.assign(tx, params)) + expect(carried.publisherId).toBe(plainMemberId) + + await withScope(primaryCongId, async tx => { + await tx.attribution.deleteMany({ where: { territoryId: territory.id } }) + await tx.territory.delete({ where: { id_congregationId: { id: territory.id, congregationId: primaryCongId } } }) + await setKindAllowedRoles(tx, TerritoryKindKey.Phone, [], primaryCongId, actorId) + }) + }) +}) + +describe('editing an attribution after its kind is restricted', () => { + it('saves an unchanged publisher but rejects a swap to another who does not qualify', async () => { + const territory = await withScope(primaryCongId, tx => + tx.territory.create({ + data: { number: `TK-EDIT-${ts}`, type: TerritoryKindKey.Phone, notes: '', congregationId: primaryCongId }, + }), + ) + const attribution = await withScope(primaryCongId, tx => + attributionAggregate.assign(tx, { + publisherId: plainMemberId, + territoryId: territory.id, + startDate: '2026-01-05', + notes: '', + type: TerritoryAttributionKind.Default, + congregationId: primaryCongId, + actorId, + }), + ) + + // Tighten the kind only after the attribution exists — the publisher on it + // no longer qualifies. + await withScope(primaryCongId, tx => + setKindAllowedRoles(tx, TerritoryKindKey.Phone, [elderRoleId], primaryCongId, actorId), + ) + + const saved = await withScope(primaryCongId, tx => + updateAttribution(tx, attribution.id, primaryCongId, actorId, { + publisherId: plainMemberId, + notes: 'still editable', + type: TerritoryAttributionKind.Default, + startDate: attribution.startDate, + }), + ) + expect(saved.notes).toBe('still editable') + + await withScope(primaryCongId, async tx => { + await expect( + updateAttribution(tx, attribution.id, primaryCongId, actorId, { + publisherId: accountElderMemberId, + notes: '', + type: TerritoryAttributionKind.Default, + startDate: attribution.startDate, + }), + ).resolves.toBeDefined() + }) + + await withScope(primaryCongId, async tx => { + await expect( + updateAttribution(tx, attribution.id, primaryCongId, actorId, { + publisherId: plainMemberId, + notes: '', + type: TerritoryAttributionKind.Default, + startDate: attribution.startDate, + }), + ).rejects.toThrow('publisher_role_not_allowed') + }) + + await withScope(primaryCongId, async tx => { + await tx.attribution.deleteMany({ where: { territoryId: territory.id } }) + await tx.territory.delete({ where: { id_congregationId: { id: territory.id, congregationId: primaryCongId } } }) + await setKindAllowedRoles(tx, TerritoryKindKey.Phone, [], primaryCongId, actorId) + }) + }) +}) + +describe('assertPublisherAllowedForKind', () => { + it('accepts a qualifying publisher and rejects one who is not', async () => { + await withScope(primaryCongId, async tx => { + await expect( + assertPublisherAllowedForKind(tx, TerritoryKindKey.Univ, elderMemberId, primaryCongId), + ).resolves.toBeUndefined() + await expect( + assertPublisherAllowedForKind(tx, TerritoryKindKey.Univ, plainMemberId, primaryCongId), + ).rejects.toThrow('publisher_role_not_allowed') + }) + }) + + it('accepts anyone once the restriction is cleared', async () => { + await withScope(primaryCongId, tx => setKindAllowedRoles(tx, TerritoryKindKey.Univ, [], primaryCongId, actorId)) + + await withScope(primaryCongId, async tx => { + await expect( + assertPublisherAllowedForKind(tx, TerritoryKindKey.Univ, plainMemberId, primaryCongId), + ).resolves.toBeUndefined() + }) + }) +}) diff --git a/app/features/territories/server/territory-kinds.queries.test.ts b/app/features/territories/server/territory-kinds.queries.test.ts new file mode 100644 index 00000000..ed0d6238 --- /dev/null +++ b/app/features/territories/server/territory-kinds.queries.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('~/shared/infra/db.server', () => ({ + unscopedDb: { + territoryKind: { findMany: vi.fn(), findFirst: vi.fn() }, + }, +})) + +const { getKindAllowedRoleIds, listTerritoryKindsWithRoles } = await import('./territory-kinds.queries') +const { unscopedDb: db } = await import('~/shared/infra/db.server') + +beforeEach(() => { + vi.resetAllMocks() +}) + +describe('listTerritoryKindsWithRoles', () => { + it('flattens the join rows into a role-id list per kind', async () => { + vi.mocked(db.territoryKind.findMany).mockResolvedValue([ + { id: 1, key: 'Classical', name: null, isBuiltIn: true, allowedRoles: [] }, + { id: 2, key: 'Phone', name: null, isBuiltIn: true, allowedRoles: [{ roleId: 8 }, { roleId: 3 }] }, + ] as never) + + const result = await listTerritoryKindsWithRoles(db, 4) + + expect(result).toEqual([ + { id: 1, key: 'Classical', name: null, isBuiltIn: true, allowedRoleIds: [] }, + { id: 2, key: 'Phone', name: null, isBuiltIn: true, allowedRoleIds: [8, 3] }, + ]) + }) + + it('scopes the query to the congregation', async () => { + vi.mocked(db.territoryKind.findMany).mockResolvedValue([] as never) + + await listTerritoryKindsWithRoles(db, 12) + + expect(db.territoryKind.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { congregationId: 12 } })) + }) +}) + +describe('getKindAllowedRoleIds', () => { + it('returns the role ids configured for the kind', async () => { + vi.mocked(db.territoryKind.findFirst).mockResolvedValue({ + allowedRoles: [{ roleId: 5 }, { roleId: 6 }], + } as never) + + expect(await getKindAllowedRoleIds(db, 'Phone', 4)).toEqual([5, 6]) + }) + + it('returns an empty list when the kind has no restriction', async () => { + vi.mocked(db.territoryKind.findFirst).mockResolvedValue({ allowedRoles: [] } as never) + + expect(await getKindAllowedRoleIds(db, 'Classical', 4)).toEqual([]) + }) + + it('treats an unknown kind as unrestricted rather than throwing', async () => { + vi.mocked(db.territoryKind.findFirst).mockResolvedValue(null as never) + + expect(await getKindAllowedRoleIds(db, 'Classical', 4)).toEqual([]) + }) +}) diff --git a/app/features/territories/server/territory-kinds.queries.ts b/app/features/territories/server/territory-kinds.queries.ts new file mode 100644 index 00000000..a9214fd2 --- /dev/null +++ b/app/features/territories/server/territory-kinds.queries.ts @@ -0,0 +1,64 @@ +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' +import type { TransactionClient } from '~/shared/infra/db.server' + +export interface TerritoryKindWithRoles { + id: number + key: string + name: string | null + isBuiltIn: boolean + allowedRoleIds: number[] +} + +/** + * Every kind of the congregation with the roles each one requires for + * attribution. Feeds the settings page; an empty `allowedRoleIds` means the kind + * carries no restriction. + */ +export async function listTerritoryKindsWithRoles( + db: TransactionClient, + congregationId: number, +): Promise { + const kinds = await db.territoryKind.findMany({ + where: { congregationId }, + select: { + id: true, + key: true, + name: true, + isBuiltIn: true, + allowedRoles: { select: { roleId: true } }, + }, + orderBy: { id: 'asc' }, + }) + + return kinds.map(kind => ({ + id: kind.id, + key: kind.key, + name: kind.name, + isBuiltIn: kind.isBuiltIn, + allowedRoleIds: kind.allowedRoles.map(row => row.roleId), + })) +} + +/** + * Roles a publisher must hold to be attributed a territory of this kind. Empty + * means no restriction — which is also the answer for a kind that has no row + * yet, so a congregation that predates seeding keeps working. + * + * `kindKey` is the enum rather than `string` precisely because that fallback is + * fail-open: a mistyped key would find no row and read as "unrestricted", so the + * compiler has to be the thing that rejects it. Widen this to `string` when + * congregations can define their own kinds, not before. + */ +export async function getKindAllowedRoleIds( + db: TransactionClient, + kindKey: TerritoryKindKey, + congregationId: number, +): Promise { + const kind = await db.territoryKind.findFirst({ + where: { key: kindKey, congregationId }, + select: { allowedRoles: { select: { roleId: true } } }, + }) + if (kind == null) return [] + + return kind.allowedRoles.map(row => row.roleId) +} diff --git a/app/features/territories/server/territory-kinds.server.test.ts b/app/features/territories/server/territory-kinds.server.test.ts new file mode 100644 index 00000000..e3234288 --- /dev/null +++ b/app/features/territories/server/territory-kinds.server.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('~/shared/domain/audit.server', () => ({ + // biome-ignore lint/style/useNamingConvention: mirrors the AuditAction const shape + AuditAction: { TerritoryKindAllowedRolesChanged: 'territory_kind.allowed_roles.changed' }, + audit: vi.fn(), +})) + +vi.mock('~/shared/infra/db.server', () => ({ + unscopedDb: { + territoryKind: { upsert: vi.fn(), findFirst: vi.fn() }, + territoryKindAllowedRole: { findMany: vi.fn(), createMany: vi.fn(), deleteMany: vi.fn() }, + }, +})) + +const { seedBuiltInTerritoryKinds, setKindAllowedRoles } = await import('./territory-kinds.server') +const { unscopedDb: db } = await import('~/shared/infra/db.server') +const { audit } = await import('~/shared/domain/audit.server') + +beforeEach(() => { + vi.resetAllMocks() +}) + +describe('seedBuiltInTerritoryKinds', () => { + it('upserts one built-in row per kind key', async () => { + await seedBuiltInTerritoryKinds(db, 3) + + expect(db.territoryKind.upsert).toHaveBeenCalledTimes(5) + const keys = vi.mocked(db.territoryKind.upsert).mock.calls.map(([args]) => args.create.key) + expect(keys.sort()).toEqual(['Classical', 'Commerces', 'Hotel', 'Phone', 'Univ']) + }) + + it('marks seeded rows built-in and scopes them to the congregation', async () => { + await seedBuiltInTerritoryKinds(db, 7) + + for (const [args] of vi.mocked(db.territoryKind.upsert).mock.calls) { + expect(args.create).toMatchObject({ isBuiltIn: true, congregationId: 7 }) + expect(args.update).toEqual({ isBuiltIn: true }) + } + }) +}) + +describe('setKindAllowedRoles', () => { + it('returns an empty diff and writes nothing when the selection is unchanged', async () => { + vi.mocked(db.territoryKind.findFirst).mockResolvedValue({ id: 9 } as never) + vi.mocked(db.territoryKindAllowedRole.findMany).mockResolvedValue([{ roleId: 1 }, { roleId: 2 }] as never) + + const diff = await setKindAllowedRoles(db, 'Phone', [2, 1], 4, 100) + + expect(diff).toEqual({ added: [], removed: [] }) + expect(db.territoryKindAllowedRole.createMany).not.toHaveBeenCalled() + expect(db.territoryKindAllowedRole.deleteMany).not.toHaveBeenCalled() + expect(audit).not.toHaveBeenCalled() + }) + + it('adds and removes only what changed', async () => { + vi.mocked(db.territoryKind.findFirst).mockResolvedValue({ id: 9 } as never) + vi.mocked(db.territoryKindAllowedRole.findMany).mockResolvedValue([{ roleId: 1 }, { roleId: 2 }] as never) + + const diff = await setKindAllowedRoles(db, 'Phone', [2, 3], 4, 100) + + expect(diff).toEqual({ added: [3], removed: [1] }) + expect(db.territoryKindAllowedRole.deleteMany).toHaveBeenCalledWith({ + where: { kindId: 9, congregationId: 4, roleId: { in: [1] } }, + }) + expect(db.territoryKindAllowedRole.createMany).toHaveBeenCalledWith({ + data: [{ kindId: 9, roleId: 3, congregationId: 4 }], + skipDuplicates: true, + }) + }) + + it('clears every role when the selection is emptied', async () => { + vi.mocked(db.territoryKind.findFirst).mockResolvedValue({ id: 9 } as never) + vi.mocked(db.territoryKindAllowedRole.findMany).mockResolvedValue([{ roleId: 1 }, { roleId: 2 }] as never) + + const diff = await setKindAllowedRoles(db, 'Phone', [], 4, 100) + + expect(diff).toEqual({ added: [], removed: [1, 2] }) + expect(db.territoryKindAllowedRole.createMany).not.toHaveBeenCalled() + }) + + it('audits the change against the kind', async () => { + vi.mocked(db.territoryKind.findFirst).mockResolvedValue({ id: 9 } as never) + vi.mocked(db.territoryKindAllowedRole.findMany).mockResolvedValue([] as never) + + await setKindAllowedRoles(db, 'Phone', [3], 4, 100) + + expect(audit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'territory_kind.allowed_roles.changed', + congregationId: 4, + actorId: 100, + entityType: 'TerritoryKind', + entityId: 9, + metadata: { key: 'Phone', added: [3], removed: [] }, + }), + ) + }) + + it('throws when the kind does not exist in the congregation', async () => { + vi.mocked(db.territoryKind.findFirst).mockResolvedValue(null as never) + + await expect(setKindAllowedRoles(db, 'Phone', [3], 4, 100)).rejects.toThrow() + expect(db.territoryKindAllowedRole.createMany).not.toHaveBeenCalled() + }) +}) diff --git a/app/features/territories/server/territory-kinds.server.ts b/app/features/territories/server/territory-kinds.server.ts new file mode 100644 index 00000000..d771acef --- /dev/null +++ b/app/features/territories/server/territory-kinds.server.ts @@ -0,0 +1,103 @@ +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' +import { AuditAction, audit } from '~/shared/domain/audit.server' +import { NotFoundError } from '~/shared/errors/app-error.server' +import type { TransactionClient } from '~/shared/infra/db.server' + +/** + * The keys of the built-in territory kinds, as stored in `TerritoryKind.key`. + * + * `Object.values(TerritoryKindKey)` yields the enum *member names* ('Classical', + * 'Univ', …), not the `@map` strings the column stores ('doors-to-doors', + * 'campus', …). Storing the member name is deliberate: it is what the Prisma + * client surfaces as `Territory.type`, so a caller can look a kind up by + * `territory.type` with no translation table. The two must stay in step until + * territories move onto the FK. + */ +export const BUILT_IN_TERRITORY_KIND_KEYS = Object.values(TerritoryKindKey) + +/** + * Idempotent per-congregation seeding of the built-in kinds, mirroring + * `seedBuiltInRoles`. Existing congregations were seeded by the migration that + * created the table; this covers congregations created afterwards. + * + * Built-ins carry no `name` — the label comes from i18n, as it does for Role. + */ +// biome-ignore lint/suspicious/noExplicitAny: accepts both PrismaClient and scoped transaction client +export async function seedBuiltInTerritoryKinds(db: any, congregationId: number) { + for (const key of BUILT_IN_TERRITORY_KIND_KEYS) { + await db.territoryKind.upsert({ + // biome-ignore lint/style/useNamingConvention: Prisma compound-key naming + where: { key_congregationId: { key, congregationId } }, + update: { isBuiltIn: true }, + create: { key, isBuiltIn: true, congregationId }, + }) + } +} + +export interface DiffResult { + added: number[] + removed: number[] +} + +function diffRoleIds(previous: number[], desired: number[]): DiffResult { + const previousSet = new Set(previous) + const desiredSet = new Set(desired) + return { + added: desired.filter(id => !previousSet.has(id)), + removed: previous.filter(id => !desiredSet.has(id)), + } +} + +/** + * Replace the roles a publisher must hold to be attributed a territory of this + * kind. An empty `desiredRoleIds` clears the restriction — no rows means any + * active publisher qualifies. + * + * No transaction wrapper: the caller's `withScopeFromContext` already provides one. + */ +export async function setKindAllowedRoles( + db: TransactionClient, + kindKey: TerritoryKindKey, + desiredRoleIds: number[], + congregationId: number, + actorId: number, +): Promise { + const kind = await db.territoryKind.findFirst({ + where: { key: kindKey, congregationId }, + select: { id: true }, + }) + if (kind == null) throw new NotFoundError('TerritoryKind') + + const previous = await db.territoryKindAllowedRole.findMany({ + where: { kindId: kind.id, congregationId }, + select: { roleId: true }, + }) + const diff = diffRoleIds( + previous.map(row => row.roleId), + desiredRoleIds, + ) + if (diff.added.length === 0 && diff.removed.length === 0) return diff + + if (diff.removed.length > 0) { + await db.territoryKindAllowedRole.deleteMany({ + where: { kindId: kind.id, congregationId, roleId: { in: diff.removed } }, + }) + } + if (diff.added.length > 0) { + await db.territoryKindAllowedRole.createMany({ + data: diff.added.map(roleId => ({ kindId: kind.id, roleId, congregationId })), + skipDuplicates: true, + }) + } + + audit({ + action: AuditAction.TerritoryKindAllowedRolesChanged, + congregationId, + actorId, + entityType: 'TerritoryKind', + entityId: kind.id, + metadata: { key: kindKey, added: diff.added, removed: diff.removed }, + }) + + return diff +} diff --git a/app/features/territories/server/update-attribution.server.test.ts b/app/features/territories/server/update-attribution.server.test.ts index d796dae9..db23a144 100644 --- a/app/features/territories/server/update-attribution.server.test.ts +++ b/app/features/territories/server/update-attribution.server.test.ts @@ -11,9 +11,11 @@ vi.mock('~/shared/infra/db.server', () => ({ }, })) vi.mock('~/shared/domain/audit.server', () => ({ AuditAction: {}, audit: vi.fn() })) +vi.mock('./attribution-eligibility.policy', () => ({ assertPublisherAllowedForAttribution: vi.fn() })) const { updateAttribution } = await import('./update-attribution.server') const { unscopedDb: db } = await import('~/shared/infra/db.server') +const { assertPublisherAllowedForAttribution } = await import('./attribution-eligibility.policy') beforeEach(() => { vi.resetAllMocks() @@ -93,4 +95,18 @@ describe('updateAttribution', () => { const call = vi.mocked(db.attribution.update).mock.calls[0][0] as { data: Record } expect(call.data.endDate).toBe(endDate) }) + + it('updates nothing when the publisher fails the role gate', async () => { + vi.mocked(assertPublisherAllowedForAttribution).mockRejectedValue(new Error('publisher_role_not_allowed')) + + await expect( + updateAttribution(db as never, 5, 2, 99, { + publisherId: 10, + notes: '', + type: TerritoryAttributionKind.Default, + startDate: new Date('2025-01-01'), + }), + ).rejects.toThrow('publisher_role_not_allowed') + expect(db.attribution.update).not.toHaveBeenCalled() + }) }) diff --git a/app/features/territories/server/update-attribution.server.ts b/app/features/territories/server/update-attribution.server.ts index 137e560b..29c39e9c 100644 --- a/app/features/territories/server/update-attribution.server.ts +++ b/app/features/territories/server/update-attribution.server.ts @@ -1,19 +1,25 @@ import type { TransactionClient } from '~/shared/infra/db.server' import * as attributionAggregate from './attribution.aggregate' +import { assertPublisherAllowedForAttribution } from './attribution-eligibility.policy' /** * Update an existing attribution. Thin delegator; the invariants * (`_assertNoActiveOverlap` when dates change, audit) live in * `attribution.aggregate.update`. + * + * Role gating sits here for the same reason as in `createAttribution`: the + * check belongs to the human-initiated path, not to the aggregate. */ export type UpdateAttributionParams = attributionAggregate.UpdateAttributionParams -export function updateAttribution( +export async function updateAttribution( db: TransactionClient, id: number, congregationId: number, actorId: number, params: UpdateAttributionParams, ) { + await assertPublisherAllowedForAttribution(db, id, params.publisherId, congregationId) + return attributionAggregate.update(db, id, congregationId, actorId, params) } diff --git a/app/features/territories/server/update-territory.server.integration.test.ts b/app/features/territories/server/update-territory.server.integration.test.ts index 8b8b3fd9..58196c6d 100644 --- a/app/features/territories/server/update-territory.server.integration.test.ts +++ b/app/features/territories/server/update-territory.server.integration.test.ts @@ -2,7 +2,7 @@ import { PrismaPg } from '@prisma/adapter-pg' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { PrismaClient } from '~/database/generated/client' import { EntranceKind } from '~/features/territories/model/entrance-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { flushPendingAuditWrites } from '~/shared/domain/audit.server' import { ValidationError } from '~/shared/errors/app-error.server' @@ -48,17 +48,17 @@ beforeAll(async () => { await withScope(primaryCongId, async tx => { const targetTerritory = await tx.territory.create({ - data: { number: `UT-TGT-${ts}`, type: TerritoryKind.Classical, congregationId: primaryCongId }, + data: { number: `UT-TGT-${ts}`, type: TerritoryKindKey.Classical, congregationId: primaryCongId }, }) targetTerritoryId = targetTerritory.id const sourceTerritory = await tx.territory.create({ - data: { number: `UT-SRC-${ts}`, type: TerritoryKind.Classical, congregationId: primaryCongId }, + data: { number: `UT-SRC-${ts}`, type: TerritoryKindKey.Classical, congregationId: primaryCongId }, }) sourceTerritoryId = sourceTerritory.id const phoneTerritory = await tx.territory.create({ - data: { number: `UT-PHN-${ts}`, type: TerritoryKind.Phone, congregationId: primaryCongId }, + data: { number: `UT-PHN-${ts}`, type: TerritoryKindKey.Phone, congregationId: primaryCongId }, }) phoneTerritoryId = phoneTerritory.id @@ -90,7 +90,7 @@ beforeAll(async () => { await withScope(otherCongId, async tx => { const crossCong = await tx.territory.create({ - data: { number: `UT-XC-${ts}`, type: TerritoryKind.Classical, congregationId: otherCongId }, + data: { number: `UT-XC-${ts}`, type: TerritoryKindKey.Classical, congregationId: otherCongId }, }) crossCongTerritoryId = crossCong.id diff --git a/app/features/territories/ui/BuildingEntranceMapCreator.tsx b/app/features/territories/ui/BuildingEntranceMapCreator.tsx index f7a735ed..a4d8acad 100644 --- a/app/features/territories/ui/BuildingEntranceMapCreator.tsx +++ b/app/features/territories/ui/BuildingEntranceMapCreator.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useFetcher } from 'react-router' import { toast } from 'sonner' -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { SplitToolCreateActionResult } from '~/features/territories/routes/split-tool/create' import type { BboxEntrance } from '~/features/territories/server/buildings.server' import { DraftTerritoryRail } from '~/features/territories/ui/DraftTerritoryRail' @@ -14,7 +14,7 @@ import * as m from '~/i18n/paraglide/messages' type Props = { apiKey?: string - kind: TerritoryKind + kind: TerritoryKindKey suggestedNumber: string fallbackCenter?: { lat: number; lng: number } /** Total entrances eligible for this kind — the tab count. Passed to the canvas so its "N sur M" chip has a denominator. */ diff --git a/app/features/territories/ui/BuildingEntranceMapEditor.tsx b/app/features/territories/ui/BuildingEntranceMapEditor.tsx index 826495e5..3fd163ed 100644 --- a/app/features/territories/ui/BuildingEntranceMapEditor.tsx +++ b/app/features/territories/ui/BuildingEntranceMapEditor.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useState } from 'react' -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { BboxEntrance } from '~/features/territories/server/buildings.server' import EntrancePopup, { type EditPendingState } from '~/features/territories/ui/EntrancePopup' import { pinVariantFor } from '~/features/territories/ui/entrance-pin-variant' @@ -14,7 +14,7 @@ export type { EntranceFocusRequest } type Props = { apiKey?: string territoryId: number - territoryType: TerritoryKind + territoryType: TerritoryKindKey ownEntrances: BboxEntrance[] pendingAdditions: ReadonlyMap pendingRemovals: ReadonlyMap diff --git a/app/features/territories/ui/DraftTerritoryRail.tsx b/app/features/territories/ui/DraftTerritoryRail.tsx index eef29237..91c285c0 100644 --- a/app/features/territories/ui/DraftTerritoryRail.tsx +++ b/app/features/territories/ui/DraftTerritoryRail.tsx @@ -1,5 +1,5 @@ import type { FetcherWithComponents } from 'react-router' -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { BboxEntrance } from '~/features/territories/server/buildings.server' import { computeDraftTotals } from '~/features/territories/ui/compute-draft-totals' import { PendingEntranceList } from '~/features/territories/ui/PendingEntranceList' @@ -8,7 +8,7 @@ import { Button } from '~/shared/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '~/shared/ui/card' type Props = { - kind: TerritoryKind + kind: TerritoryKindKey draft: ReadonlyMap suggestedNumber: string onFocusEntrance: (entranceId: number) => void @@ -17,7 +17,7 @@ type Props = { fetcher: FetcherWithComponents } -function totalsLabel(kind: TerritoryKind, draft: ReadonlyMap): string { +function totalsLabel(kind: TerritoryKindKey, draft: ReadonlyMap): string { const totals = computeDraftTotals(kind, [...draft.values()]) if (totals.count === 0) return m.split_tool_create_rail_empty() diff --git a/app/features/territories/ui/EntranceImpactBlock.tsx b/app/features/territories/ui/EntranceImpactBlock.tsx index b5aa1c6a..31c4e2ad 100644 --- a/app/features/territories/ui/EntranceImpactBlock.tsx +++ b/app/features/territories/ui/EntranceImpactBlock.tsx @@ -1,4 +1,4 @@ -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { BboxEntrance } from '~/features/territories/server/buildings.server' import type { TerritoryContent } from '~/features/territories/server/territory-content.queries' import { @@ -15,14 +15,14 @@ function errorLine(reason: ForeignContentErrorReason): string { type ImpactSummary = { current: string; afterRemoval: string } function summariseImpact(content: TerritoryContent, entrance: BboxEntrance): ImpactSummary { - if (content.kind === TerritoryKind.Phone) { + if (content.kind === TerritoryKindKey.Phone) { const after = Math.max(0, content.phones - entrance.phones) return { current: m.territories_map_popup_impact_phones({ count: content.phones }), afterRemoval: m.territories_map_popup_impact_after_removal_phones({ count: after }), } } - if (content.kind === TerritoryKind.Classical || content.kind === TerritoryKind.Univ) { + if (content.kind === TerritoryKindKey.Classical || content.kind === TerritoryKindKey.Univ) { // Mirror computeTerritoryQuantity — Classical/Univ aggregate as `homes || phones` per entrance. const contribution = entrance.homes || entrance.phones const after = Math.max(0, content.quantity - contribution) @@ -39,8 +39,8 @@ function summariseImpact(content: TerritoryContent, entrance: BboxEntrance): Imp } function secondaryAggregates(content: TerritoryContent): string[] { - const isPhonePrimary = content.kind === TerritoryKind.Phone - const isHomesPrimary = content.kind === TerritoryKind.Classical || content.kind === TerritoryKind.Univ + const isPhonePrimary = content.kind === TerritoryKindKey.Phone + const isHomesPrimary = content.kind === TerritoryKindKey.Classical || content.kind === TerritoryKindKey.Univ const isEntrancePrimary = !isPhonePrimary && !isHomesPrimary const parts: string[] = [] diff --git a/app/features/territories/ui/EntrancePopup.tsx b/app/features/territories/ui/EntrancePopup.tsx index 8357368b..ec3902e5 100644 --- a/app/features/territories/ui/EntrancePopup.tsx +++ b/app/features/territories/ui/EntrancePopup.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from 'react' import { useState } from 'react' import { EntranceKind } from '~/features/territories/model/entrance-kind.type' import { TerritoryAccess } from '~/features/territories/model/territory-access.type' -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { BboxEntrance } from '~/features/territories/server/buildings.server' import { entranceContentLabel } from '~/features/territories/server/entrance-content-label' import { EntranceImpactBlock } from '~/features/territories/ui/EntranceImpactBlock' @@ -25,7 +25,7 @@ export type EntrancePendingState = EditPendingState | CreatePendingState type Props = { entrance: BboxEntrance - territoryType: TerritoryKind + territoryType: TerritoryKindKey pending: EntrancePendingState onAct: () => void } @@ -94,7 +94,7 @@ function PopupFrame({ accent, children }: { accent: string; children: ReactNode ) } -function PopupHeader({ entrance, territoryType }: { entrance: BboxEntrance; territoryType: TerritoryKind }) { +function PopupHeader({ entrance, territoryType }: { entrance: BboxEntrance; territoryType: TerritoryKindKey }) { const badges = accessBadges(entrance) const prospectedOn = formatProspectionDate(entrance.prospectionDate) return ( diff --git a/app/features/territories/ui/EntrancesWithoutCoordinatesList.tsx b/app/features/territories/ui/EntrancesWithoutCoordinatesList.tsx index dd930b51..a437874d 100644 --- a/app/features/territories/ui/EntrancesWithoutCoordinatesList.tsx +++ b/app/features/territories/ui/EntrancesWithoutCoordinatesList.tsx @@ -1,5 +1,5 @@ import { Trash2 } from 'lucide-react' -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { BboxEntrance } from '~/features/territories/server/buildings.server' import { entranceContentLabel } from '~/features/territories/server/entrance-content-label' import * as m from '~/i18n/paraglide/messages' @@ -8,7 +8,7 @@ import { Button } from '~/shared/ui/button' type EntrancesWithoutCoordinatesListProps = { entrances: AggregatedEntrance[] - territoryType: TerritoryKind + territoryType: TerritoryKindKey pendingRemovals: Map onRemove: (entrance: AggregatedEntrance) => void onRevert: (entranceId: number) => void diff --git a/app/features/territories/ui/PendingChangesRail.tsx b/app/features/territories/ui/PendingChangesRail.tsx index 6ac390f2..2671f5f3 100644 --- a/app/features/territories/ui/PendingChangesRail.tsx +++ b/app/features/territories/ui/PendingChangesRail.tsx @@ -1,5 +1,5 @@ import { Minus, Plus, RotateCcw, X } from 'lucide-react' -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { BboxEntrance } from '~/features/territories/server/buildings.server' import { computeTerritoryQuantity } from '~/features/territories/server/compute-territory-quantity' import * as m from '~/i18n/paraglide/messages' @@ -7,7 +7,7 @@ import type { AggregatedEntrance } from '~/shared/types/entrance' import { Button } from '~/shared/ui/button' type Props = { - territoryType: TerritoryKind + territoryType: TerritoryKindKey initialEntrances: AggregatedEntrance[] pendingAdditions: ReadonlyMap pendingRemovals: ReadonlyMap diff --git a/app/features/territories/ui/PendingEntranceList.tsx b/app/features/territories/ui/PendingEntranceList.tsx index 23eece11..e9f44dcb 100644 --- a/app/features/territories/ui/PendingEntranceList.tsx +++ b/app/features/territories/ui/PendingEntranceList.tsx @@ -1,5 +1,5 @@ import { ExternalLink, Plus, RotateCcw, Trash2 } from 'lucide-react' -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { BboxEntrance } from '~/features/territories/server/buildings.server' import { entranceContentLabel } from '~/features/territories/server/entrance-content-label' import type { EditPendingState } from '~/features/territories/ui/EntrancePopup' @@ -63,7 +63,7 @@ type PendingEntranceListProps = { pendingAdditions: Map pendingRemovals: Map pendingReassignments: Map - territoryType: TerritoryKind + territoryType: TerritoryKindKey showMap: boolean onFocusEntrance: (entranceId: number) => void onRevert: (entranceId: number) => void diff --git a/app/features/territories/ui/StatsFiltersDialog.tsx b/app/features/territories/ui/StatsFiltersDialog.tsx index 6e7d92b8..fb7616d2 100644 --- a/app/features/territories/ui/StatsFiltersDialog.tsx +++ b/app/features/territories/ui/StatsFiltersDialog.tsx @@ -3,7 +3,7 @@ import { Form, useSearchParams } from 'react-router' import type { PublisherGroup } from '~/database/generated/client' import { AttributionCategory } from '~/features/territories/model/attribution-category' import { DEFAULT_ATTRIBUTION_KINDS } from '~/features/territories/model/stats-filter-defaults' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import * as m from '~/i18n/paraglide/messages' import { Button } from '~/shared/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '~/shared/ui/card' @@ -39,10 +39,10 @@ export default function StatsFiltersDialog({ // URL parser accepts `?kind=a&kind=b` (chip bar renders one per value); the // select stays single-value to keep the form simple. `kind=none` is the // explicit "Tous types" placeholder — distinct from the empty-URL default - // which resolves to `[TerritoryKind.Classical]` on the server. + // which resolves to `[TerritoryKindKey.Classical]` on the server. const rawKinds = params.getAll('kind') const isAllTypes = rawKinds.includes('none') - const selectKind = rawKinds.find(k => k !== 'none') ?? TerritoryKind.Classical + const selectKind = rawKinds.find(k => k !== 'none') ?? TerritoryKindKey.Classical const attributionKinds = params.getAll('attributionKind').length > 0 ? params.getAll('attributionKind') : DEFAULT_ATTRIBUTION_KINDS @@ -75,13 +75,13 @@ export default function StatsFiltersDialog({ {m.stats_filter_territory_all_types()} - {m.stats_filter_territory_door()} + {m.stats_filter_territory_door()} {phoneTypeActive && ( - {m.stats_filter_territory_phone()} + {m.stats_filter_territory_phone()} )} - {m.stats_filter_territory_commerce()} - {m.stats_filter_territory_hotel()} - {m.stats_filter_territory_university()} + {m.stats_filter_territory_commerce()} + {m.stats_filter_territory_hotel()} + {m.stats_filter_territory_university()}
diff --git a/app/features/territories/ui/TerritoryAttributionDocument.tsx b/app/features/territories/ui/TerritoryAttributionDocument.tsx index 97f8c2ba..6d9e445f 100644 --- a/app/features/territories/ui/TerritoryAttributionDocument.tsx +++ b/app/features/territories/ui/TerritoryAttributionDocument.tsx @@ -1,6 +1,6 @@ import { Document, Page, StyleSheet, Text, View } from '@react-pdf/renderer' import type { Attribution, Member, Territory } from '~/database/generated/client' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import * as m from '~/i18n/paraglide/messages' type TerritoryRepport = Territory & { attributions: (Attribution & { publisher: Member })[] } @@ -20,7 +20,7 @@ export function TerritoryAttributionDocument({ createdAt: new Date(), updatedAt: new Date(), notes: '', - type: TerritoryKind.Classical, + type: TerritoryKindKey.Classical, congregationId: 0, }, ], diff --git a/app/features/territories/ui/TerritoryCardLink.tsx b/app/features/territories/ui/TerritoryCardLink.tsx index 1c2ddde0..d0be084b 100644 --- a/app/features/territories/ui/TerritoryCardLink.tsx +++ b/app/features/territories/ui/TerritoryCardLink.tsx @@ -1,7 +1,7 @@ import { ExternalLink } from 'lucide-react' import { Link } from 'react-router' import type { Territory } from '~/database/generated/client' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import * as m from '~/i18n/paraglide/messages' import type { AggregatedEntrance } from '~/shared/types/entrance' import { Card, CardContent } from '~/shared/ui/card' @@ -13,11 +13,11 @@ export function TerritoryCardLink({ territory, entrances }: { territory: Territo
{territory.number} - {territory.type === TerritoryKind.Classical && m.territories_type_classical()} - {territory.type === TerritoryKind.Commerces && m.territories_type_commerces()} - {territory.type === TerritoryKind.Phone && m.territories_type_phone()} - {territory.type === TerritoryKind.Hotel && m.territories_type_hotel()} - {territory.type === TerritoryKind.Univ && m.territories_type_university()},{' '} + {territory.type === TerritoryKindKey.Classical && m.territories_type_classical()} + {territory.type === TerritoryKindKey.Commerces && m.territories_type_commerces()} + {territory.type === TerritoryKindKey.Phone && m.territories_type_phone()} + {territory.type === TerritoryKindKey.Hotel && m.territories_type_hotel()} + {territory.type === TerritoryKindKey.Univ && m.territories_type_university()},{' '} {entrances.reduce((aggr, curr) => aggr + (curr.homes ?? curr.phones ?? 0), 0)} {m.territories_card_doors()}
diff --git a/app/features/territories/ui/TerritoryDocument.tsx b/app/features/territories/ui/TerritoryDocument.tsx index 57df8be4..93dbde30 100644 --- a/app/features/territories/ui/TerritoryDocument.tsx +++ b/app/features/territories/ui/TerritoryDocument.tsx @@ -6,7 +6,7 @@ import { shopKindLabels as getShopKindLabels, type ShopKind } from '~/features/t import { buildTerritoryStaticMapUrl } from '~/features/territories/model/static-map-url' import { TerritoryAccess } from '~/features/territories/model/territory-access.type' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import * as m from '~/i18n/paraglide/messages' import type { Entrance } from '~/shared/types/entrance' @@ -102,7 +102,7 @@ const styles = StyleSheet.create({ interface TerritoryDocumentProps { name: string - type: TerritoryKind + type: TerritoryKindKey entrances: Entrance[] googleMapId: string | undefined googleMapKey: string | undefined @@ -117,7 +117,7 @@ interface TerritoryDocumentProps { export function TerritoryDocument({ name, - type = TerritoryKind.Classical, + type = TerritoryKindKey.Classical, entrances = [], googleMapKey, googleMapId, @@ -130,18 +130,18 @@ export function TerritoryDocument({ attributionCampaign = false, }: TerritoryDocumentProps) { let unit = m.territory_doc_unit_entrances() - if (type === TerritoryKind.Phone) { + if (type === TerritoryKindKey.Phone) { unit = m.territory_doc_unit_phones() } - if (type === TerritoryKind.Classical || type === TerritoryKind.Univ) { + if (type === TerritoryKindKey.Classical || type === TerritoryKindKey.Univ) { unit = m.territory_doc_unit_homes() } let quantity = entrances.length - if (type === TerritoryKind.Phone) { + if (type === TerritoryKindKey.Phone) { quantity = entrances.reduce((acc, entrance) => acc + (entrance.phones ?? 0), 0) } - if (type === TerritoryKind.Classical || type === TerritoryKind.Univ) { + if (type === TerritoryKindKey.Classical || type === TerritoryKindKey.Univ) { quantity = entrances.reduce((acc, entrance) => acc + ((entrance.homes ?? 0) || (entrance.phones ?? 0)), 0) } @@ -166,7 +166,7 @@ export function TerritoryDocument({ {entrances.map(entrance => { - if (type === TerritoryKind.Commerces) { + if (type === TerritoryKindKey.Commerces) { return } @@ -220,13 +220,13 @@ function DocumentWaterMark({ type, isCampaign }: { type: TerritoryAttributionKin return null } -function TypeInformations({ type }: { type: TerritoryKind }) { +function TypeInformations({ type }: { type: TerritoryKindKey }) { return ( - {type === TerritoryKind.Phone && m.territories_type_phone_singular()} - {type === TerritoryKind.Univ && m.territories_type_university_singular()} - {type === TerritoryKind.Commerces && m.territory_doc_type_commerce()} - {type === TerritoryKind.Hotel && m.territories_type_hotel_singular()} + {type === TerritoryKindKey.Phone && m.territories_type_phone_singular()} + {type === TerritoryKindKey.Univ && m.territories_type_university_singular()} + {type === TerritoryKindKey.Commerces && m.territory_doc_type_commerce()} + {type === TerritoryKindKey.Hotel && m.territories_type_hotel_singular()} ) } diff --git a/app/features/territories/ui/TerritoryEntranceCard.tsx b/app/features/territories/ui/TerritoryEntranceCard.tsx index 0c0a60fb..cb8edd93 100644 --- a/app/features/territories/ui/TerritoryEntranceCard.tsx +++ b/app/features/territories/ui/TerritoryEntranceCard.tsx @@ -2,7 +2,7 @@ import { DoorOpen, Phone, Store } from 'lucide-react' import { formatAccessSequence } from '~/features/territories/model/access-format' import { shopKindLabels as getShopKindLabels, type ShopKind } from '~/features/territories/model/shop-kind.type' import { TerritoryAccess } from '~/features/territories/model/territory-access.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import * as m from '~/i18n/paraglide/messages' import type { Entrance } from '~/shared/types/entrance' import { Card, CardContent } from '~/shared/ui/card' @@ -20,7 +20,7 @@ export function TerritoryEntranceCard({ entrance, territoryType, showPhone = fal const numbers = entrance.buildings.map(b => b.number).join(', ') const address = `${numbers} ${firstBuilding.street}, ${firstBuilding.zip}` - if (territoryType === TerritoryKind.Commerces) { + if (territoryType === TerritoryKindKey.Commerces) { return } @@ -43,7 +43,7 @@ function ResidentialCard({ (entrance.accesses ?? []).some(a => a.type === TerritoryAccess.Code) || entrance.access === TerritoryAccess.Code const phones = entrance.phones ?? 0 const homes = entrance.homes ?? 0 - const isPhoneTerritory = territoryType === TerritoryKind.Phone + const isPhoneTerritory = territoryType === TerritoryKindKey.Phone return ( diff --git a/app/features/territories/ui/TerritoryFilters.tsx b/app/features/territories/ui/TerritoryFilters.tsx index 0b53ff24..b481dfca 100644 --- a/app/features/territories/ui/TerritoryFilters.tsx +++ b/app/features/territories/ui/TerritoryFilters.tsx @@ -4,7 +4,7 @@ import { Form, useSearchParams } from 'react-router' import type { Prisma } from '~/database/generated/client' import { ShopKind } from '~/features/territories/model/shop-kind.type' import { TerritoryAccess } from '~/features/territories/model/territory-access.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import * as m from '~/i18n/paraglide/messages' import { Button } from '~/shared/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/shared/ui/select' @@ -64,11 +64,11 @@ export default function TerritoryFilters({ {m.territories_filter_type()} - {m.territories_type_classical_capitalized()} - {m.territories_filter_shops()} - {m.territories_type_hotel()} - {m.territories_type_phone()} - {m.territories_type_university_singular()} + {m.territories_type_classical_capitalized()} + {m.territories_filter_shops()} + {m.territories_type_hotel()} + {m.territories_type_phone()} + {m.territories_type_university_singular()} )} diff --git a/app/features/territories/ui/TerritoryInfoCard.tsx b/app/features/territories/ui/TerritoryInfoCard.tsx index d32cfc6f..05dc8ebe 100644 --- a/app/features/territories/ui/TerritoryInfoCard.tsx +++ b/app/features/territories/ui/TerritoryInfoCard.tsx @@ -1,21 +1,21 @@ -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import * as m from '~/i18n/paraglide/messages' import { Card, CardContent } from '~/shared/ui/card' import { Label } from '~/shared/ui/label' import { Textarea } from '~/shared/ui/textarea' type TerritoryInfoCardProps = { - territory: { number: string; type: TerritoryKind; notes: string | null } + territory: { number: string; type: TerritoryKindKey; notes: string | null } projectedContent: string onNotesChange: () => void } -function typeLabel(type: TerritoryKind): string { - if (type === TerritoryKind.Classical) return m.territories_type_classical_capitalized() - if (type === TerritoryKind.Commerces) return m.territories_type_commerces() - if (type === TerritoryKind.Hotel) return m.territories_type_hotel() - if (type === TerritoryKind.Phone) return m.territories_type_phone_singular() - if (type === TerritoryKind.Univ) return m.territories_type_university_singular() +function typeLabel(type: TerritoryKindKey): string { + if (type === TerritoryKindKey.Classical) return m.territories_type_classical_capitalized() + if (type === TerritoryKindKey.Commerces) return m.territories_type_commerces() + if (type === TerritoryKindKey.Hotel) return m.territories_type_hotel() + if (type === TerritoryKindKey.Phone) return m.territories_type_phone_singular() + if (type === TerritoryKindKey.Univ) return m.territories_type_university_singular() return '' } diff --git a/app/features/territories/ui/build-filter-chips.test.ts b/app/features/territories/ui/build-filter-chips.test.ts index 21572c7d..e7010ca7 100644 --- a/app/features/territories/ui/build-filter-chips.test.ts +++ b/app/features/territories/ui/build-filter-chips.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { buildAttributionFilterChips, buildTerritoryFilterChips } from './build-filter-chips' describe('buildTerritoryFilterChips', () => { @@ -23,7 +23,7 @@ describe('buildTerritoryFilterChips', () => { }) it('maps a known type enum to its label', () => { - const chips = buildTerritoryFilterChips(new URLSearchParams({ type: TerritoryKind.Classical })) + const chips = buildTerritoryFilterChips(new URLSearchParams({ type: TerritoryKindKey.Classical })) expect(chips).toHaveLength(1) expect(chips[0].key).toBe('type') expect(chips[0].value.length).toBeGreaterThan(0) diff --git a/app/features/territories/ui/build-filter-chips.ts b/app/features/territories/ui/build-filter-chips.ts index 26f04e99..caad5678 100644 --- a/app/features/territories/ui/build-filter-chips.ts +++ b/app/features/territories/ui/build-filter-chips.ts @@ -1,7 +1,7 @@ import { ShopKind } from '~/features/territories/model/shop-kind.type' import { TerritoryAccess } from '~/features/territories/model/territory-access.type' import { TerritoryAttributionKind } from '~/features/territories/model/territory-attribution-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import * as m from '~/i18n/paraglide/messages' import type { FilterChip } from '~/shared/ui/filters/FilterChipBar' import { formatGroupName } from '~/shared/utils/format-group-name' @@ -24,15 +24,15 @@ interface BuildChipsOptions { function typeChipValue(raw: string): string | null { switch (raw) { - case TerritoryKind.Classical: + case TerritoryKindKey.Classical: return m.territories_type_classical_capitalized() - case TerritoryKind.Commerces: + case TerritoryKindKey.Commerces: return m.territories_type_commerces() - case TerritoryKind.Phone: + case TerritoryKindKey.Phone: return m.territories_type_phone() - case TerritoryKind.Hotel: + case TerritoryKindKey.Hotel: return m.territories_type_hotel() - case TerritoryKind.Univ: + case TerritoryKindKey.Univ: return m.territories_type_university_singular() default: return null diff --git a/app/features/territories/ui/compute-draft-totals.test.ts b/app/features/territories/ui/compute-draft-totals.test.ts index 7361156a..0e4b7c94 100644 --- a/app/features/territories/ui/compute-draft-totals.test.ts +++ b/app/features/territories/ui/compute-draft-totals.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { EntranceKind } from '~/features/territories/model/entrance-kind.type' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { BboxEntrance } from '~/features/territories/server/buildings.server' import { computeDraftTotals } from './compute-draft-totals' @@ -30,37 +30,40 @@ function entrance(overrides: Partial = {}): BboxEntrance { describe('computeDraftTotals', () => { it('sums homes for Classical territories, count is entrance count', () => { - const result = computeDraftTotals(TerritoryKind.Classical, [entrance({ homes: 12 }), entrance({ homes: 8 })]) + const result = computeDraftTotals(TerritoryKindKey.Classical, [entrance({ homes: 12 }), entrance({ homes: 8 })]) expect(result).toEqual({ metric: 'homes', primary: 20, count: 2 }) }) it('for Classical, falls back to phones when homes is zero (mirrors computeTerritoryQuantity)', () => { - const result = computeDraftTotals(TerritoryKind.Classical, [entrance({ homes: 0, phones: 15 })]) + const result = computeDraftTotals(TerritoryKindKey.Classical, [entrance({ homes: 0, phones: 15 })]) expect(result.primary).toBe(15) }) it('sums phones for Phone territories', () => { - const result = computeDraftTotals(TerritoryKind.Phone, [entrance({ phones: 40 }), entrance({ phones: 25 })]) + const result = computeDraftTotals(TerritoryKindKey.Phone, [entrance({ phones: 40 }), entrance({ phones: 25 })]) expect(result).toEqual({ metric: 'phones', primary: 65, count: 2 }) }) it('sums homes-or-phones for Univ (campus) territories', () => { - const result = computeDraftTotals(TerritoryKind.Univ, [entrance({ homes: 5 }), entrance({ homes: 0, phones: 12 })]) + const result = computeDraftTotals(TerritoryKindKey.Univ, [ + entrance({ homes: 5 }), + entrance({ homes: 0, phones: 12 }), + ]) expect(result).toEqual({ metric: 'homes', primary: 17, count: 2 }) }) it('reports only entrance count for Commerces (no per-entrance quantity)', () => { - const result = computeDraftTotals(TerritoryKind.Commerces, [entrance(), entrance()]) + const result = computeDraftTotals(TerritoryKindKey.Commerces, [entrance(), entrance()]) expect(result).toEqual({ metric: 'count', primary: 2, count: 2 }) }) it('reports only entrance count for Hotel', () => { - const result = computeDraftTotals(TerritoryKind.Hotel, [entrance(), entrance(), entrance()]) + const result = computeDraftTotals(TerritoryKindKey.Hotel, [entrance(), entrance(), entrance()]) expect(result).toEqual({ metric: 'count', primary: 3, count: 3 }) }) it('handles the empty draft cleanly', () => { - expect(computeDraftTotals(TerritoryKind.Classical, [])).toEqual({ metric: 'homes', primary: 0, count: 0 }) - expect(computeDraftTotals(TerritoryKind.Commerces, [])).toEqual({ metric: 'count', primary: 0, count: 0 }) + expect(computeDraftTotals(TerritoryKindKey.Classical, [])).toEqual({ metric: 'homes', primary: 0, count: 0 }) + expect(computeDraftTotals(TerritoryKindKey.Commerces, [])).toEqual({ metric: 'count', primary: 0, count: 0 }) }) }) diff --git a/app/features/territories/ui/compute-draft-totals.ts b/app/features/territories/ui/compute-draft-totals.ts index 3e35cf85..6c873acc 100644 --- a/app/features/territories/ui/compute-draft-totals.ts +++ b/app/features/territories/ui/compute-draft-totals.ts @@ -1,4 +1,4 @@ -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { BboxEntrance } from '~/features/territories/server/buildings.server' export type DraftTotals = { @@ -12,13 +12,13 @@ export type DraftTotals = { * so the number the user sees while drafting matches what the created territory * will display afterwards. */ -export function computeDraftTotals(kind: TerritoryKind, entrances: readonly BboxEntrance[]): DraftTotals { +export function computeDraftTotals(kind: TerritoryKindKey, entrances: readonly BboxEntrance[]): DraftTotals { const count = entrances.length - if (kind === TerritoryKind.Phone) { + if (kind === TerritoryKindKey.Phone) { return { metric: 'phones', primary: entrances.reduce((s, e) => s + e.phones, 0), count } } - if (kind === TerritoryKind.Classical || kind === TerritoryKind.Univ) { + if (kind === TerritoryKindKey.Classical || kind === TerritoryKindKey.Univ) { return { metric: 'homes', primary: entrances.reduce((s, e) => s + (e.homes || e.phones), 0), count } } return { metric: 'count', primary: count, count } diff --git a/app/features/territories/ui/stats-filter-chips.test.ts b/app/features/territories/ui/stats-filter-chips.test.ts index 67d5b166..0cca6330 100644 --- a/app/features/territories/ui/stats-filter-chips.test.ts +++ b/app/features/territories/ui/stats-filter-chips.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { PublisherGroup } from '~/database/generated/client' import { AttributionCategory } from '~/features/territories/model/attribution-category' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import { type BuildStatsFilterChipsInput, buildStatsFilterChips } from './stats-filter-chips' const CLASSIQUE_PATTERN = /classique/i @@ -43,9 +43,9 @@ describe('buildStatsFilterChips', () => { }) it('emits one chip per selected territory kind', () => { - const chips = buildStatsFilterChips(baseInput({ kinds: [TerritoryKind.Classical, TerritoryKind.Hotel] })) + const chips = buildStatsFilterChips(baseInput({ kinds: [TerritoryKindKey.Classical, TerritoryKindKey.Hotel] })) const kindChips = chips.filter(c => c.tone === 'kind') - expect(kindChips.map(c => c.key)).toEqual([`kind-${TerritoryKind.Classical}`, `kind-${TerritoryKind.Hotel}`]) + expect(kindChips.map(c => c.key)).toEqual([`kind-${TerritoryKindKey.Classical}`, `kind-${TerritoryKindKey.Hotel}`]) }) it('emits an attribution chip per selected attribution kind', () => { @@ -93,8 +93,8 @@ describe('buildStatsFilterChips', () => { expect(groupChip?.key).toBe('group-99') }) - it('renders a distinct non-empty label for every TerritoryKind', () => { - const allKinds = Object.values(TerritoryKind) as string[] + it('renders a distinct non-empty label for every TerritoryKindKey', () => { + const allKinds = Object.values(TerritoryKindKey) as string[] const labels = allKinds.map(kind => { const chips = buildStatsFilterChips(baseInput({ kinds: [kind] })) return chips.find(c => c.tone === 'kind')?.label diff --git a/app/features/territories/ui/stats-filter-chips.ts b/app/features/territories/ui/stats-filter-chips.ts index 1416a2e8..f386f467 100644 --- a/app/features/territories/ui/stats-filter-chips.ts +++ b/app/features/territories/ui/stats-filter-chips.ts @@ -1,6 +1,6 @@ import type { PublisherGroup } from '~/database/generated/client' import { AttributionCategory } from '~/features/territories/model/attribution-category' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import * as m from '~/i18n/paraglide/messages' import { formatGroupName } from '~/shared/utils/format-group-name' @@ -24,15 +24,15 @@ export interface BuildStatsFilterChipsInput { function territoryKindLabel(kind: string): string { switch (kind) { - case TerritoryKind.Classical: + case TerritoryKindKey.Classical: return m.stats_filter_territory_door() - case TerritoryKind.Phone: + case TerritoryKindKey.Phone: return m.stats_filter_territory_phone() - case TerritoryKind.Commerces: + case TerritoryKindKey.Commerces: return m.stats_filter_territory_commerce() - case TerritoryKind.Hotel: + case TerritoryKindKey.Hotel: return m.stats_filter_territory_hotel() - case TerritoryKind.Univ: + case TerritoryKindKey.Univ: return m.stats_filter_territory_university() default: return kind diff --git a/app/features/territories/ui/use-entrance-pending-state.ts b/app/features/territories/ui/use-entrance-pending-state.ts index 8fffad4e..698e8120 100644 --- a/app/features/territories/ui/use-entrance-pending-state.ts +++ b/app/features/territories/ui/use-entrance-pending-state.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo, useState } from 'react' -import type { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { BboxEntrance } from '~/features/territories/server/buildings.server' import { territoryContentLabel } from '~/features/territories/server/territory-content-label' import type { EntranceAction, EntranceFocusRequest } from '~/features/territories/ui/BuildingEntranceMapEditor' @@ -42,7 +42,10 @@ export function ownEntranceToBbox(entrance: AggregatedEntrance): BboxEntrance | } } -export function useEntrancePendingState(savedTerritoryEntrances: AggregatedEntrance[], territoryType: TerritoryKind) { +export function useEntrancePendingState( + savedTerritoryEntrances: AggregatedEntrance[], + territoryType: TerritoryKindKey, +) { const { blocker, markDirty } = useUnsavedChanges() const [pendingAdditions, setPendingAdditions] = useState>(new Map()) diff --git a/app/features/territories/ui/use-foreign-territory-content.test.ts b/app/features/territories/ui/use-foreign-territory-content.test.ts index 631800b6..795d54a3 100644 --- a/app/features/territories/ui/use-foreign-territory-content.test.ts +++ b/app/features/territories/ui/use-foreign-territory-content.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { TerritoryContent } from '~/features/territories/server/territory-content.queries' let capturedEffect: (() => undefined | (() => void)) | null = null @@ -24,7 +24,7 @@ const { useForeignTerritoryContent } = await import('./use-foreign-territory-con const okContent: TerritoryContent = { id: 7, number: 'T7', - kind: TerritoryKind.Classical, + kind: TerritoryKindKey.Classical, entranceCount: 3, quantity: 5, homes: 5, diff --git a/app/features/territories/ui/use-foreign-territory-content.ts b/app/features/territories/ui/use-foreign-territory-content.ts index cf5b49e4..78ff888c 100644 --- a/app/features/territories/ui/use-foreign-territory-content.ts +++ b/app/features/territories/ui/use-foreign-territory-content.ts @@ -1,17 +1,17 @@ import { useEffect, useState } from 'react' import { z } from 'zod' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { TerritoryContent } from '~/features/territories/server/territory-content.queries' const territoryContentSchema = z.object({ id: z.number(), number: z.string(), kind: z.enum([ - TerritoryKind.Classical, - TerritoryKind.Phone, - TerritoryKind.Commerces, - TerritoryKind.Hotel, - TerritoryKind.Univ, + TerritoryKindKey.Classical, + TerritoryKindKey.Phone, + TerritoryKindKey.Commerces, + TerritoryKindKey.Hotel, + TerritoryKindKey.Univ, ]), entranceCount: z.number().nonnegative(), quantity: z.number().nonnegative(), diff --git a/app/i18n/messages/en.json b/app/i18n/messages/en.json index c59518c3..4bda5975 100644 --- a/app/i18n/messages/en.json +++ b/app/i18n/messages/en.json @@ -863,6 +863,9 @@ "settings_territories_attribution_duration_days_unit": "days", "settings_territories_territory_title": "Territory", "settings_territories_types_title": "Territory types", + "settings_territories_kinds_hint": "For each territory type, pick the roles required to receive an attribution. With no role selected, every publisher is eligible.", + "settings_territories_kind_roles_label": "Allowed roles", + "settings_territories_kind_roles_default": "Every publisher", "settings_territories_virtual_territory_title": "Virtual territory", "settings_territories_phone_type_before": "Enable", "settings_territories_phone_type_highlight": "phone", @@ -1739,6 +1742,7 @@ "attributions_edit_return_submit": "Return territory", "attributions_edit_save_submit": "Save assignment", "attributions_overlap_error": "This publisher already has an active attribution on this territory for that period.", + "attributions_publisher_role_error": "This publisher does not hold the roles required for this territory type.", "attributions_delete_card_title": "Cancel assignment", "attributions_delete_submit": "Cancel assignment of territory {number}", "attributions_delete_flash_success": "The assignment of {name} has been cancelled", diff --git a/app/i18n/messages/fr.json b/app/i18n/messages/fr.json index b9a2b0e7..9e8e5add 100644 --- a/app/i18n/messages/fr.json +++ b/app/i18n/messages/fr.json @@ -865,6 +865,9 @@ "settings_territories_attribution_duration_days_unit": "jours", "settings_territories_territory_title": "Territoire", "settings_territories_types_title": "Types de territoires", + "settings_territories_kinds_hint": "Pour chaque type de territoire, choisissez les rôles requis pour recevoir une attribution. Sans rôle sélectionné, tous les proclamateurs sont éligibles.", + "settings_territories_kind_roles_label": "Rôles autorisés", + "settings_territories_kind_roles_default": "Tous les proclamateurs", "settings_territories_virtual_territory_title": "Territoire virtuel", "settings_territories_phone_type_before": "Activer la gestion des territoires", "settings_territories_phone_type_highlight": "téléphone", @@ -1744,6 +1747,7 @@ "attributions_edit_return_submit": "Rentrer le territoire", "attributions_edit_save_submit": "Enregistrer l'attribution", "attributions_overlap_error": "Ce proclamateur a déjà une attribution active sur ce territoire pour cette période.", + "attributions_publisher_role_error": "Ce proclamateur n'a pas les rôles requis pour ce type de territoire.", "attributions_delete_card_title": "Annuler l'attribution", "attributions_delete_submit": "Annuler l'attribution du territoire {number}", "attributions_delete_flash_success": "L'attribution de {name} a été annulée", diff --git a/app/shared/constants/constants.test.ts b/app/shared/constants/constants.test.ts index 0bb9c439..ea5a9487 100644 --- a/app/shared/constants/constants.test.ts +++ b/app/shared/constants/constants.test.ts @@ -102,9 +102,9 @@ describe('limits — time windows', () => { }) describe('limits — progress / thresholds', () => { - test('IMPORT_TOTAL_STEPS is 41', () => { + test('IMPORT_TOTAL_STEPS is 43', () => { // Must equal the number of progress() calls in runImport, or the bar lies. - expect(IMPORT_TOTAL_STEPS).toBe(41) + expect(IMPORT_TOTAL_STEPS).toBe(43) }) test('IMPORT_PROGRESS_CAP reserves the last 5% for finalization', () => { diff --git a/app/shared/constants/limits.ts b/app/shared/constants/limits.ts index 3a1716d6..b11fa55f 100644 --- a/app/shared/constants/limits.ts +++ b/app/shared/constants/limits.ts @@ -22,7 +22,7 @@ export const GEOCODER_CACHE_TTL_SECONDS = 60 * 60 * 24 * 90 // Progress / thresholds // Must equal the number of progress() calls in runImport; the last 5% is // reserved for finalization + audit writes after the transaction commits. -export const IMPORT_TOTAL_STEPS = 41 +export const IMPORT_TOTAL_STEPS = 43 export const IMPORT_PROGRESS_CAP = 95 // Export streams NDJSON up to 90%; the trailing 10% is packaging + upload. diff --git a/app/shared/domain/audit.server.ts b/app/shared/domain/audit.server.ts index 72616c79..29f1daf6 100644 --- a/app/shared/domain/audit.server.ts +++ b/app/shared/domain/audit.server.ts @@ -62,6 +62,7 @@ export const AuditAction = { TerritoryCreated: 'territory.created', TerritoryUpdated: 'territory.updated', TerritoryDeleted: 'territory.deleted', + TerritoryKindAllowedRolesChanged: 'territory_kind.allowed_roles.changed', EntranceReassigned: 'entrance.reassigned', // Attributions diff --git a/app/shared/domain/setup.server.ts b/app/shared/domain/setup.server.ts index eb771ead..0df26748 100644 --- a/app/shared/domain/setup.server.ts +++ b/app/shared/domain/setup.server.ts @@ -23,11 +23,13 @@ export async function seedPermissions(db: any) { // biome-ignore lint/suspicious/noExplicitAny: accepts both PrismaClient and scoped transaction client type SeedTemplatesFn = (db: any, congregationId: number, locale: Locale) => Promise +// biome-ignore lint/suspicious/noExplicitAny: accepts both PrismaClient and scoped transaction client +type SeedTerritoryKindsFn = (db: any, congregationId: number) => Promise /** - * Seed the default programme templates and roles for a newly created - * congregation. Pass `seedTemplates` to inject the templates seeder from the - * events feature — the caller must supply it to avoid a domain→feature + * Seed the default programme templates, roles and territory kinds for a newly + * created congregation. Pass `seedTemplates` / `seedTerritoryKinds` to inject the + * feature-owned seeders — the caller must supply them to avoid a domain→feature * dependency inversion. */ export async function seedCongregationDefaults( @@ -36,8 +38,10 @@ export async function seedCongregationDefaults( congregationId: number, locale: Locale, seedTemplates: SeedTemplatesFn = async () => {}, + seedTerritoryKinds: SeedTerritoryKindsFn = async () => {}, ) { await seedTemplates(db, congregationId, locale) + await seedTerritoryKinds(db, congregationId) await seedBuiltInRoles(db, congregationId) } diff --git a/app/tests/factories/index.ts b/app/tests/factories/index.ts index 2f29e06b..af6bc234 100644 --- a/app/tests/factories/index.ts +++ b/app/tests/factories/index.ts @@ -1,5 +1,5 @@ import type { PrismaClient } from '~/database/generated/client' -import { TerritoryKind } from '~/features/territories/model/territory-kind.type' +import { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' export function createTestCongregation(db: PrismaClient, overrides: Record = {}) { const suffix = Date.now() @@ -94,6 +94,6 @@ export function createTestMember(db: PrismaClient, congregationId: number, overr export function createTestTerritory(db: PrismaClient, congregationId: number, overrides: Record = {}) { const suffix = Date.now() return db.territory.create({ - data: { number: `T-${suffix}`, type: TerritoryKind.Classical, congregationId, ...overrides }, + data: { number: `T-${suffix}`, type: TerritoryKindKey.Classical, congregationId, ...overrides }, }) } diff --git a/scripts/check-tenant-scoping.ts b/scripts/check-tenant-scoping.ts index a0bf2469..799f5c26 100644 --- a/scripts/check-tenant-scoping.ts +++ b/scripts/check-tenant-scoping.ts @@ -61,6 +61,8 @@ export const TENANT_MODELS = [ 'territory', 'territoryPerimeter', 'territoryCardOverlay', + 'territoryKind', + 'territoryKindAllowedRole', 'attribution', 'campaign', 'campaignTerritory',