From ca6eccf72127b43b7e67d1b4a038d8c0fe6728e4 Mon Sep 17 00:00:00 2001 From: mindsers Date: Tue, 25 Aug 2026 02:14:59 +0200 Subject: [PATCH 1/3] chore(territories): rename TerritoryKind enum to TerritoryKindKey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frees the TerritoryKind name for the entity table that will own per-kind configuration and, later, user-created kinds. Enum values and their mapped strings are unchanged, so Territory.type keeps its data — the migration is a Postgres type rename, not a data migration. --- .../migration.sql | 4 ++ app/database/schema.prisma | 6 +- app/database/seed-marketing.ts | 56 ++++++++-------- .../server/dashboard.integration.test.ts | 4 +- .../dashboard/ui/build-urgent-items.test.ts | 4 +- .../routes/publishers/publisher.tsx | 12 ++-- .../server/data-transfer.integration.test.ts | 4 +- .../server/import-territories.server.ts | 6 +- app/features/territories/index.ts | 2 +- .../model/stats-filter-defaults.ts | 4 +- .../territories/model/territory-kind.type.ts | 7 +- .../api/entrances-in-bbox-params.test.ts | 16 ++--- .../routes/api/entrances-in-bbox-params.ts | 8 +-- .../routes/attributions/territories.tsx | 12 ++-- .../routes/my-territories/list.tsx | 16 ++--- .../territories/routes/split-tool/_layout.tsx | 12 ++-- .../routes/split-tool/commerces.tsx | 8 +-- .../territories/routes/split-tool/hotels.tsx | 8 +-- .../territories/routes/split-tool/list.tsx | 8 +-- .../territories/routes/split-tool/phones.tsx | 8 +-- .../routes/split-tool/university.tsx | 8 +-- .../territories/routes/territory/list.tsx | 12 ++-- .../territories/routes/territory/new.tsx | 14 ++-- .../territories/routes/territory/view.tsx | 14 ++-- .../territories/schemas/building.schema.ts | 4 +- .../territories/schemas/territory.schema.ts | 4 +- ...aggregate-attribution-stats.server.test.ts | 4 +- .../attribution.aggregate.integration.test.ts | 4 +- .../server/attribution.aggregate.test.ts | 4 +- .../server/attribution.aggregate.ts | 6 +- .../territories/server/attributions.server.ts | 4 +- .../server/building-filters.server.test.ts | 14 ++-- .../server/building-filters.server.ts | 14 ++-- .../territories/server/buildings.server.ts | 34 +++++----- ...pute-attributions-per-month.server.test.ts | 4 +- .../compute-availability-gap.server.test.ts | 4 +- ...-coverage-by-territory-type.server.test.ts | 28 ++++---- ...mpute-coverage-by-territory-type.server.ts | 14 ++-- .../compute-duration-stats.server.test.ts | 4 +- ...-monthly-coverage-evolution.server.test.ts | 6 +- ...mpute-next-territory-number.server.test.ts | 20 +++--- .../compute-next-territory-number.server.ts | 16 ++--- .../compute-overdue-rate.server.test.ts | 4 +- .../compute-ranked-territories.server.test.ts | 4 +- ...ute-rest-period-utilization.server.test.ts | 4 +- .../server/compute-territory-quantity.test.ts | 20 +++--- .../server/compute-territory-quantity.ts | 6 +- .../server/create-attribution.server.test.ts | 6 +- ...create-territory-from-split.server.test.ts | 26 ++++---- .../create-territory-from-split.server.ts | 4 +- .../server/create-territory.server.test.ts | 10 +-- .../server/create-territory.server.ts | 4 +- .../server/entrance-content-label.test.ts | 42 +++++++----- .../server/entrance-content-label.ts | 6 +- ...etch-attributions-for-stats.server.test.ts | 8 +-- .../fetch-territory-counts.server.test.ts | 26 ++++---- .../server/fetch-territory-counts.server.ts | 6 +- ...d-adjacent-territories.integration.test.ts | 22 +++---- .../get-entrances-in-bbox.integration.test.ts | 46 ++++++------- .../server/map-visibility.integration.test.ts | 28 ++++---- .../territories/server/map-visibility.test.ts | 66 +++++++++---------- .../territories/server/map-visibility.ts | 34 +++++----- .../parse-stats-filter-params.server.test.ts | 10 +-- .../parse-stats-filter-params.server.ts | 4 +- .../proximity-loader.integration.test.ts | 8 +-- .../server/split-tool-create.workflow.test.ts | 6 +- .../server/split-tool-create.workflow.ts | 4 +- .../stats-aggregate.integration.test.ts | 32 ++++----- .../server/stats-attribution.type.ts | 4 +- .../server/stats-filter-params.type.ts | 4 +- .../territories-never-worked.server.test.ts | 4 +- .../server/territory-content-label.test.ts | 30 ++++----- .../server/territory-content-label.ts | 12 ++-- ...ritory-content.queries.integration.test.ts | 12 ++-- .../server/territory-content.queries.ts | 4 +- .../server/territory-count-by-type.type.ts | 4 +- .../territory-coverage-total.server.test.ts | 6 +- .../server/territory-coverage-total.server.ts | 4 +- .../server/territory-coverage.server.test.ts | 6 +- .../server/territory-coverage.server.ts | 4 +- .../server/territory-filters.server.test.ts | 6 +- .../server/territory-filters.server.ts | 4 +- ...pdate-territory.server.integration.test.ts | 10 +-- .../ui/BuildingEntranceMapCreator.tsx | 4 +- .../ui/BuildingEntranceMapEditor.tsx | 4 +- .../territories/ui/DraftTerritoryRail.tsx | 6 +- .../territories/ui/EntranceImpactBlock.tsx | 10 +-- app/features/territories/ui/EntrancePopup.tsx | 6 +- .../ui/EntrancesWithoutCoordinatesList.tsx | 4 +- .../territories/ui/PendingChangesRail.tsx | 4 +- .../territories/ui/PendingEntranceList.tsx | 4 +- .../territories/ui/StatsFiltersDialog.tsx | 16 ++--- .../ui/TerritoryAttributionDocument.tsx | 4 +- .../territories/ui/TerritoryCardLink.tsx | 12 ++-- .../territories/ui/TerritoryDocument.tsx | 26 ++++---- .../territories/ui/TerritoryEntranceCard.tsx | 6 +- .../territories/ui/TerritoryFilters.tsx | 12 ++-- .../territories/ui/TerritoryInfoCard.tsx | 16 ++--- .../territories/ui/build-filter-chips.test.ts | 4 +- .../territories/ui/build-filter-chips.ts | 12 ++-- .../ui/compute-draft-totals.test.ts | 21 +++--- .../territories/ui/compute-draft-totals.ts | 8 +-- .../territories/ui/stats-filter-chips.test.ts | 10 +-- .../territories/ui/stats-filter-chips.ts | 12 ++-- .../ui/use-entrance-pending-state.ts | 7 +- .../ui/use-foreign-territory-content.test.ts | 4 +- .../ui/use-foreign-territory-content.ts | 12 ++-- app/tests/factories/index.ts | 4 +- 108 files changed, 628 insertions(+), 591 deletions(-) create mode 100644 app/database/migrations/20260825000000_rename_territory_kind_enum/migration.sql 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/schema.prisma b/app/database/schema.prisma index a31a5da0..e0c228ea 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") @@ -509,7 +511,7 @@ model BoardDynamicDocumentView { 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..283a1c15 100644 --- a/app/database/seed-marketing.ts +++ b/app/database/seed-marketing.ts @@ -20,7 +20,7 @@ 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 { syncBuiltInRoleAssignments } from '../shared/domain/built-in-roles.server' import { seedBuiltInRoles } from '../shared/domain/setup.server' import { Permission } from '../shared/types/permission' @@ -366,35 +366,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 = [ 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/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/import-territories.server.ts b/app/features/settings/server/import-territories.server.ts index 199c57d6..b06efd97 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,12 +16,12 @@ 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) } 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/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/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..c5afb808 100644 --- a/app/features/territories/server/create-attribution.server.test.ts +++ b/app/features/territories/server/create-attribution.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/domain/settings.server', () => ({ getSetting: vi.fn(), @@ -44,7 +44,7 @@ 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) }) describe('createAttribution', () => { @@ -92,7 +92,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 }) 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/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/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 }, }) } From 8a9fe1ec08500f709bc0ea246feed9e583ad1c7a Mon Sep 17 00:00:00 2001 From: mindsers Date: Tue, 25 Aug 2026 02:36:38 +0200 Subject: [PATCH 2/3] feat(territories): role-gated attribution per territory kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each territory kind now declares which roles a publisher must hold to be attributed a territory of that kind. Closes the last open piece of the role-based permission epic besides the direct-grant cutover. Introduces the TerritoryKind entity rather than keying the join table on the enum, so the configuration survives kinds becoming user-created: that table grows a name/slug and drops the enum column, and the role links are untouched. Territory.type deliberately stays on TerritoryKindKey for now. The gate lives in the createAttribution / updateAttribution delegators, not in the aggregate. Those two are the human-initiated path, so posting straight at the route cannot slip past it, while campaign-lifecycle.workflow — which calls the aggregate directly — stays exempt: it re-attributes a pairing that already existed and swallows ConflictError, so gating it would silently strip a publisher's territory when a kind's roles change. An unchanged publisher on an edit also passes, so tightening a kind never locks an existing attribution. Refs #147 --- .../migration.sql | 79 +++++ app/database/schema.prisma | 42 +++ app/database/seed-marketing.ts | 2 + app/database/seed.ts | 2 + .../register-congregation.server.test.ts | 2 + .../server/register-congregation.server.ts | 3 +- .../server/setup-first-account.server.test.ts | 2 + .../server/setup-first-account.server.ts | 3 +- .../settings/routes/territories/settings.tsx | 106 +++---- .../schemas/territory-settings.schema.ts | 22 ++ .../server/data-transfer.type.test.ts | 6 + .../settings/server/data-transfer.type.ts | 2 + .../server/export-congregation.server.ts | 14 + .../server/import-congregation.server.ts | 10 + .../server/import-territories.server.ts | 49 +++ app/features/settings/ui/DurationInput.tsx | 53 ++++ .../settings/ui/TerritoryKindSettingsList.tsx | 90 ++++++ app/features/territories/index.server.ts | 3 + .../territories/routes/attributions/edit.tsx | 13 +- .../territories/routes/attributions/new.tsx | 22 +- .../attributable-publishers.queries.test.ts | 86 ++++++ .../server/attributable-publishers.queries.ts | 42 +++ .../attribution-eligibility.policy.test.ts | 96 ++++++ .../server/attribution-eligibility.policy.ts | 56 ++++ .../server/create-attribution.server.test.ts | 23 +- .../server/create-attribution.server.ts | 15 +- .../territory-kinds.integration.test.ts | 289 ++++++++++++++++++ .../server/territory-kinds.queries.test.ts | 60 ++++ .../server/territory-kinds.queries.ts | 58 ++++ .../server/territory-kinds.server.test.ts | 106 +++++++ .../server/territory-kinds.server.ts | 98 ++++++ .../server/update-attribution.server.test.ts | 29 ++ .../server/update-attribution.server.ts | 8 +- app/i18n/messages/en.json | 4 + app/i18n/messages/fr.json | 4 + app/shared/constants/constants.test.ts | 4 +- app/shared/constants/limits.ts | 2 +- app/shared/domain/audit.server.ts | 1 + app/shared/domain/setup.server.ts | 10 +- scripts/check-tenant-scoping.ts | 2 + 40 files changed, 1423 insertions(+), 95 deletions(-) create mode 100644 app/database/migrations/20260825100000_add_territory_kind_roles/migration.sql create mode 100644 app/features/settings/ui/DurationInput.tsx create mode 100644 app/features/settings/ui/TerritoryKindSettingsList.tsx create mode 100644 app/features/territories/server/attributable-publishers.queries.test.ts create mode 100644 app/features/territories/server/attributable-publishers.queries.ts create mode 100644 app/features/territories/server/attribution-eligibility.policy.test.ts create mode 100644 app/features/territories/server/attribution-eligibility.policy.ts create mode 100644 app/features/territories/server/territory-kinds.integration.test.ts create mode 100644 app/features/territories/server/territory-kinds.queries.test.ts create mode 100644 app/features/territories/server/territory-kinds.queries.ts create mode 100644 app/features/territories/server/territory-kinds.server.test.ts create mode 100644 app/features/territories/server/territory-kinds.server.ts 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 e0c228ea..3d14a080 100644 --- a/app/database/schema.prisma +++ b/app/database/schema.prisma @@ -83,6 +83,8 @@ model Congregation { members Member[] memberRoleAssignments MemberRoleAssignment[] territories Territory[] + territoryKinds TerritoryKind[] + territoryKindAllowedRoles TerritoryKindAllowedRole[] buildings Building[] buildingEntrances BuildingEntrance[] attributions Attribution[] @@ -332,6 +334,7 @@ model Role { allowedForTemplateServiceParts TemplateServicePartAllowedRole[] allowedForEventServiceParts EventServicePartAllowedRole[] allowedForBoardSections BoardSectionVisibilityRole[] + allowedForTerritoryKinds TerritoryKindAllowedRole[] congregation Congregation @relation(fields: [congregationId], references: [id], onDelete: Cascade) congregationId Int @@ -508,6 +511,45 @@ 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 diff --git a/app/database/seed-marketing.ts b/app/database/seed-marketing.ts index 283a1c15..736c0a3b 100644 --- a/app/database/seed-marketing.ts +++ b/app/database/seed-marketing.ts @@ -21,6 +21,7 @@ import { seedDefaultTemplates } from '../features/events/server/seed-templates.s import { EntranceKind } from '../features/territories/model/entrance-kind.type' import { TerritoryAttributionKind } from '../features/territories/model/territory-attribution-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' @@ -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/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.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.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 b06efd97..73e3b53d 100644 --- a/app/features/settings/server/import-territories.server.ts +++ b/app/features/settings/server/import-territories.server.ts @@ -28,6 +28,55 @@ export async function importTerritories( } } +/** + * 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/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/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..54cf4b1d --- /dev/null +++ b/app/features/territories/server/attributable-publishers.queries.ts @@ -0,0 +1,42 @@ +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: string, + 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..3532e1f2 --- /dev/null +++ b/app/features/territories/server/attribution-eligibility.policy.ts @@ -0,0 +1,56 @@ +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: string, + 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/create-attribution.server.test.ts b/app/features/territories/server/create-attribution.server.test.ts index c5afb808..62735ad8 100644 --- a/app/features/territories/server/create-attribution.server.test.ts +++ b/app/features/territories/server/create-attribution.server.test.ts @@ -7,17 +7,20 @@ vi.mock('~/shared/domain/settings.server', () => ({ })) 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 baseParams = { publisherId: 1, @@ -45,6 +48,7 @@ beforeEach(() => { mockDb.attribution.findMany.mockResolvedValue([]) mockDb.attribution.findFirst.mockResolvedValue(null as never) mockDb.territory.findUniqueOrThrow.mockResolvedValue({ type: TerritoryKindKey.Classical } as never) + mockDb.territory.findFirst.mockResolvedValue({ type: TerritoryKindKey.Classical } as never) }) describe('createAttribution', () => { @@ -108,4 +112,21 @@ describe('createAttribution', () => { expect(result).toEqual(fake) }) + + it("gates the publisher against the territory kind's allowed roles", async () => { + mockDb.territory.findFirst.mockResolvedValue({ type: TerritoryKindKey.Phone } as never) + + await createAttribution(mockDb as never, { ...baseParams, type: TerritoryAttributionKind.Default }) + + expect(assertPublisherAllowedForKind).toHaveBeenCalledWith(mockDb, TerritoryKindKey.Phone, 1, 10) + }) + + it('creates nothing when the publisher fails the role gate', async () => { + vi.mocked(assertPublisherAllowedForKind).mockRejectedValue(new Error('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() + }) }) 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/territory-kinds.integration.test.ts b/app/features/territories/server/territory-kinds.integration.test.ts new file mode 100644 index 00000000..c64f8590 --- /dev/null +++ b/app/features/territories/server/territory-kinds.integration.test.ts @@ -0,0 +1,289 @@ +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' + +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('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..18fc5a61 --- /dev/null +++ b/app/features/territories/server/territory-kinds.queries.ts @@ -0,0 +1,58 @@ +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. + */ +export async function getKindAllowedRoleIds( + db: TransactionClient, + kindKey: string, + 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..b1e421e3 --- /dev/null +++ b/app/features/territories/server/territory-kinds.server.ts @@ -0,0 +1,98 @@ +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`. + * They are the mapped values of `TerritoryKindKey`, which still types + * `Territory.type` — 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: string, + 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..1945c2a0 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,31 @@ describe('updateAttribution', () => { const call = vi.mocked(db.attribution.update).mock.calls[0][0] as { data: Record } expect(call.data.endDate).toBe(endDate) }) + + it("gates the incoming publisher against the attribution's territory kind", async () => { + vi.mocked(db.attribution.update).mockResolvedValue({} as never) + + await updateAttribution(db as never, 5, 2, 99, { + publisherId: 10, + notes: '', + type: TerritoryAttributionKind.Default, + startDate: new Date('2025-01-01'), + }) + + expect(assertPublisherAllowedForAttribution).toHaveBeenCalledWith(db, 5, 10, 2) + }) + + 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/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/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', From 5aea7d1c2833d00bfb0c4b0b1eabb9acb423a250 Mon Sep 17 00:00:00 2001 From: mindsers Date: Tue, 25 Aug 2026 02:49:26 +0200 Subject: [PATCH 3/3] fix(territories): narrow kindKey to the enum and close review gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #365 surfaced a fail-open path: kindKey was typed `string`, so a mistyped key found no kind, getKindAllowedRoleIds returned [] — which means "unrestricted" — and the gate silently passed. Every call site already holds a TerritoryKindKey (Territory.type, or Object.values in the settings action), so narrowing costs nothing now and the compiler rejects the typo instead. Widen it back to string when congregations can define their own kinds, not before. Also: - Fix an inaccurate comment: BUILT_IN_TERRITORY_KIND_KEYS holds the enum member names, not the @map strings, and it contradicted the migration that says so. - Guard the hand-spelled kind-roles-* settings fields against drifting from the enum; without it, adding a kind silently drops its roles on every save. - Cover the unchanged-publisher edit path end-to-end, not just at unit level. - Drop two tests that asserted on delegation instead of observable outcomes. --- .../schemas/territory-settings.schema.test.ts | 37 +++++++---- .../server/attributable-publishers.queries.ts | 3 +- .../server/attribution-eligibility.policy.ts | 3 +- .../server/create-attribution.server.test.ts | 19 +++--- .../territory-kinds.integration.test.ts | 66 +++++++++++++++++++ .../server/territory-kinds.queries.ts | 8 ++- .../server/territory-kinds.server.ts | 11 +++- .../server/update-attribution.server.test.ts | 13 ---- 8 files changed, 119 insertions(+), 41 deletions(-) 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/territories/server/attributable-publishers.queries.ts b/app/features/territories/server/attributable-publishers.queries.ts index 54cf4b1d..63dfd12c 100644 --- a/app/features/territories/server/attributable-publishers.queries.ts +++ b/app/features/territories/server/attributable-publishers.queries.ts @@ -1,3 +1,4 @@ +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' @@ -23,7 +24,7 @@ interface Options { */ export async function findAttributablePublishers( db: TransactionClient, - kindKey: string, + kindKey: TerritoryKindKey, congregationId: number, options?: Options, ) { diff --git a/app/features/territories/server/attribution-eligibility.policy.ts b/app/features/territories/server/attribution-eligibility.policy.ts index 3532e1f2..51cc311c 100644 --- a/app/features/territories/server/attribution-eligibility.policy.ts +++ b/app/features/territories/server/attribution-eligibility.policy.ts @@ -1,3 +1,4 @@ +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' @@ -16,7 +17,7 @@ import { getKindAllowedRoleIds } from './territory-kinds.queries' */ export async function assertPublisherAllowedForKind( db: TransactionClient, - kindKey: string, + kindKey: TerritoryKindKey, publisherId: number, congregationId: number, ): Promise { diff --git a/app/features/territories/server/create-attribution.server.test.ts b/app/features/territories/server/create-attribution.server.test.ts index 62735ad8..b690844e 100644 --- a/app/features/territories/server/create-attribution.server.test.ts +++ b/app/features/territories/server/create-attribution.server.test.ts @@ -21,6 +21,7 @@ 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, @@ -113,20 +114,20 @@ describe('createAttribution', () => { expect(result).toEqual(fake) }) - it("gates the publisher against the territory kind's allowed roles", async () => { - mockDb.territory.findFirst.mockResolvedValue({ type: TerritoryKindKey.Phone } as never) - - await createAttribution(mockDb as never, { ...baseParams, type: TerritoryAttributionKind.Default }) - - expect(assertPublisherAllowedForKind).toHaveBeenCalledWith(mockDb, TerritoryKindKey.Phone, 1, 10) - }) - it('creates nothing when the publisher fails the role gate', async () => { - vi.mocked(assertPublisherAllowedForKind).mockRejectedValue(new Error('publisher_role_not_allowed')) + 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/territory-kinds.integration.test.ts b/app/features/territories/server/territory-kinds.integration.test.ts index c64f8590..9217cf29 100644 --- a/app/features/territories/server/territory-kinds.integration.test.ts +++ b/app/features/territories/server/territory-kinds.integration.test.ts @@ -10,6 +10,7 @@ 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, @@ -265,6 +266,71 @@ describe('role gating on the attribution paths', () => { }) }) +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 => { diff --git a/app/features/territories/server/territory-kinds.queries.ts b/app/features/territories/server/territory-kinds.queries.ts index 18fc5a61..a9214fd2 100644 --- a/app/features/territories/server/territory-kinds.queries.ts +++ b/app/features/territories/server/territory-kinds.queries.ts @@ -1,3 +1,4 @@ +import type { TerritoryKindKey } from '~/features/territories/model/territory-kind.type' import type { TransactionClient } from '~/shared/infra/db.server' export interface TerritoryKindWithRoles { @@ -42,10 +43,15 @@ export async function listTerritoryKindsWithRoles( * 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: string, + kindKey: TerritoryKindKey, congregationId: number, ): Promise { const kind = await db.territoryKind.findFirst({ diff --git a/app/features/territories/server/territory-kinds.server.ts b/app/features/territories/server/territory-kinds.server.ts index b1e421e3..d771acef 100644 --- a/app/features/territories/server/territory-kinds.server.ts +++ b/app/features/territories/server/territory-kinds.server.ts @@ -5,8 +5,13 @@ import type { TransactionClient } from '~/shared/infra/db.server' /** * The keys of the built-in territory kinds, as stored in `TerritoryKind.key`. - * They are the mapped values of `TerritoryKindKey`, which still types - * `Territory.type` — the two must stay in step until territories move onto the FK. + * + * `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) @@ -52,7 +57,7 @@ function diffRoleIds(previous: number[], desired: number[]): DiffResult { */ export async function setKindAllowedRoles( db: TransactionClient, - kindKey: string, + kindKey: TerritoryKindKey, desiredRoleIds: number[], congregationId: number, actorId: number, diff --git a/app/features/territories/server/update-attribution.server.test.ts b/app/features/territories/server/update-attribution.server.test.ts index 1945c2a0..db23a144 100644 --- a/app/features/territories/server/update-attribution.server.test.ts +++ b/app/features/territories/server/update-attribution.server.test.ts @@ -96,19 +96,6 @@ describe('updateAttribution', () => { expect(call.data.endDate).toBe(endDate) }) - it("gates the incoming publisher against the attribution's territory kind", async () => { - vi.mocked(db.attribution.update).mockResolvedValue({} as never) - - await updateAttribution(db as never, 5, 2, 99, { - publisherId: 10, - notes: '', - type: TerritoryAttributionKind.Default, - startDate: new Date('2025-01-01'), - }) - - expect(assertPublisherAllowedForAttribution).toHaveBeenCalledWith(db, 5, 10, 2) - }) - it('updates nothing when the publisher fails the role gate', async () => { vi.mocked(assertPublisherAllowedForAttribution).mockRejectedValue(new Error('publisher_role_not_allowed'))