Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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";
Original file line number Diff line number Diff line change
@@ -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;
48 changes: 46 additions & 2 deletions app/database/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -81,6 +83,8 @@ model Congregation {
members Member[]
memberRoleAssignments MemberRoleAssignment[]
territories Territory[]
territoryKinds TerritoryKind[]
territoryKindAllowedRoles TerritoryKindAllowedRole[]
buildings Building[]
buildingEntrances BuildingEntrance[]
attributions Attribution[]
Expand Down Expand Up @@ -330,6 +334,7 @@ model Role {
allowedForTemplateServiceParts TemplateServicePartAllowedRole[]
allowedForEventServiceParts EventServicePartAllowedRole[]
allowedForBoardSections BoardSectionVisibilityRole[]
allowedForTerritoryKinds TerritoryKindAllowedRole[]

congregation Congregation @relation(fields: [congregationId], references: [id], onDelete: Cascade)
congregationId Int
Expand Down Expand Up @@ -506,10 +511,49 @@ model BoardDynamicDocumentView {
@@index([userId])
}

// A kind of territory, per congregation. The five built-ins are seeded from
// `TerritoryKindKey` at congregation setup; `key` becomes a free-form slug once
// congregations can define their own kinds. `Territory.type` still points at the
// enum — moving it onto a FK here is a later change.
model TerritoryKind {
id Int @id @default(autoincrement())
key String
// null for built-ins — the label comes from i18n, as it does for Role
name String?
isBuiltIn Boolean @default(true)

allowedRoles TerritoryKindAllowedRole[]

congregation Congregation @relation(fields: [congregationId], references: [id], onDelete: Cascade)
congregationId Int

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@unique([key, congregationId])
@@unique([id, congregationId])
@@index([congregationId])
}

// Roles a publisher must hold to be attributed a territory of this kind.
// No rows for a kind means no restriction — any active publisher qualifies.
model TerritoryKindAllowedRole {
kind TerritoryKind @relation(fields: [kindId, congregationId], references: [id, congregationId], onDelete: Cascade)
kindId Int
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
roleId Int
congregation Congregation @relation(fields: [congregationId], references: [id], onDelete: Cascade)
congregationId Int

@@id([kindId, roleId])
@@index([roleId])
@@index([congregationId])
}

model Territory {
id Int @id @default(autoincrement())
number String
type TerritoryKind @default(Classical)
type TerritoryKindKey @default(Classical)
notes String @default("")
attributions Attribution[] @relation("territory")
campaignScopes CampaignTerritory[]
Expand Down
58 changes: 30 additions & 28 deletions app/database/seed-marketing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import { EventTemplateKey } from '../features/events/model/event-template.type'
import { seedDefaultTemplates } from '../features/events/server/seed-templates.server'
import { EntranceKind } from '../features/territories/model/entrance-kind.type'
import { TerritoryAttributionKind } from '../features/territories/model/territory-attribution-kind.type'
import { TerritoryKind } from '../features/territories/model/territory-kind.type'
import { TerritoryKindKey } from '../features/territories/model/territory-kind.type'
import { seedBuiltInTerritoryKinds } from '../features/territories/server/territory-kinds.server'
import { syncBuiltInRoleAssignments } from '../shared/domain/built-in-roles.server'
import { seedBuiltInRoles } from '../shared/domain/setup.server'
import { Permission } from '../shared/types/permission'
Expand Down Expand Up @@ -366,35 +367,35 @@ const PUBLISHERS: {
},
]

const TERRITORIES: { number: string; type: TerritoryKind; notes: string }[] = [
{ number: 'T01', type: TerritoryKind.Classical, notes: 'Centre-ville, secteur piéton' },
{ number: 'T02', type: TerritoryKind.Classical, notes: '' },
{ number: 'T03', type: TerritoryKind.Classical, notes: 'Résidences récentes, beaucoup de jeunes familles' },
{ number: 'T04', type: TerritoryKind.Classical, notes: '' },
{ number: 'T05', type: TerritoryKind.Classical, notes: 'Quartier calme, peu de refus' },
{ number: 'T06', type: TerritoryKind.Classical, notes: '' },
{ number: 'T07', type: TerritoryKind.Classical, notes: 'Immeubles avec digicodes — voir notes entrées' },
{ number: 'T08', type: TerritoryKind.Classical, notes: '' },
{ number: 'T09', type: TerritoryKind.Classical, notes: '' },
{ number: 'T10', type: TerritoryKind.Classical, notes: 'Proche de la gare' },
{ number: 'T11', type: TerritoryKind.Classical, notes: '' },
{ number: 'T12', type: TerritoryKind.Classical, notes: 'Longue distance entre immeubles' },
{ number: 'T13', type: TerritoryKind.Classical, notes: '' },
{ number: 'T14', type: TerritoryKind.Classical, notes: '' },
{ number: 'P01', type: TerritoryKind.Phone, notes: 'Territoire téléphonique — personnes âgées' },
{ number: 'P02', type: TerritoryKind.Phone, notes: 'Territoire téléphonique' },
{ number: 'C01', type: TerritoryKind.Commerces, notes: 'Commerces rue principale' },
{ number: 'C02', type: TerritoryKind.Commerces, notes: 'Commerces zone commerciale' },
{ number: 'H01', type: TerritoryKind.Hotel, notes: 'Hôtels du quartier' },
{ number: 'U01', type: TerritoryKind.Univ, notes: 'Campus universitaire' },
const TERRITORIES: { number: string; type: TerritoryKindKey; notes: string }[] = [
{ number: 'T01', type: TerritoryKindKey.Classical, notes: 'Centre-ville, secteur piéton' },
{ number: 'T02', type: TerritoryKindKey.Classical, notes: '' },
{ number: 'T03', type: TerritoryKindKey.Classical, notes: 'Résidences récentes, beaucoup de jeunes familles' },
{ number: 'T04', type: TerritoryKindKey.Classical, notes: '' },
{ number: 'T05', type: TerritoryKindKey.Classical, notes: 'Quartier calme, peu de refus' },
{ number: 'T06', type: TerritoryKindKey.Classical, notes: '' },
{ number: 'T07', type: TerritoryKindKey.Classical, notes: 'Immeubles avec digicodes — voir notes entrées' },
{ number: 'T08', type: TerritoryKindKey.Classical, notes: '' },
{ number: 'T09', type: TerritoryKindKey.Classical, notes: '' },
{ number: 'T10', type: TerritoryKindKey.Classical, notes: 'Proche de la gare' },
{ number: 'T11', type: TerritoryKindKey.Classical, notes: '' },
{ number: 'T12', type: TerritoryKindKey.Classical, notes: 'Longue distance entre immeubles' },
{ number: 'T13', type: TerritoryKindKey.Classical, notes: '' },
{ number: 'T14', type: TerritoryKindKey.Classical, notes: '' },
{ number: 'P01', type: TerritoryKindKey.Phone, notes: 'Territoire téléphonique — personnes âgées' },
{ number: 'P02', type: TerritoryKindKey.Phone, notes: 'Territoire téléphonique' },
{ number: 'C01', type: TerritoryKindKey.Commerces, notes: 'Commerces rue principale' },
{ number: 'C02', type: TerritoryKindKey.Commerces, notes: 'Commerces zone commerciale' },
{ number: 'H01', type: TerritoryKindKey.Hotel, notes: 'Hôtels du quartier' },
{ number: 'U01', type: TerritoryKindKey.Univ, notes: 'Campus universitaire' },
]

const ENTRANCE_KIND_FOR_TERRITORY: Record<TerritoryKind, EntranceKind> = {
[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, EntranceKind> = {
[TerritoryKindKey.Classical]: EntranceKind.Residential,
[TerritoryKindKey.Phone]: EntranceKind.Residential,
[TerritoryKindKey.Commerces]: EntranceKind.Commerce,
[TerritoryKindKey.Hotel]: EntranceKind.Hotel,
[TerritoryKindKey.Univ]: EntranceKind.Campus,
}

const SHOP_KINDS = [
Expand Down Expand Up @@ -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')
Expand Down
2 changes: 2 additions & 0 deletions app/database/seed.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -44,6 +45,7 @@ async function main() {

await seedDefaultTemplates(prisma, defaultCongregation.id, seedLocale)
await seedBuiltInRoles(prisma, defaultCongregation.id)
await seedBuiltInTerritoryKinds(prisma, defaultCongregation.id)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
})

Expand Down
4 changes: 2 additions & 2 deletions app/features/dashboard/server/dashboard.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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({
Expand Down
4 changes: 2 additions & 2 deletions app/features/dashboard/ui/build-urgent-items.test.ts
Original file line number Diff line number Diff line change
@@ -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`,
Expand Down Expand Up @@ -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,
}
}
Expand Down
12 changes: 6 additions & 6 deletions app/features/publishers/routes/publishers/publisher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -461,13 +461,13 @@ export default function PublisherPage({ loaderData }: Route.ComponentProps) {
)}
</TableCell>
<TableCell className="max-sm:hidden">
{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()}
</TableCell>
<TableCell className="text-center">{attribution.startDate.toLocaleDateString('fr-FR')}</TableCell>
<TableCell className="text-center">
Expand Down
Loading