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,82 @@
/**
* Lock the migration backfill SQL to behave the same as the runtime
* `stripDiacritics()` helper for representative French inputs. The
* `translate()` character map in `migration.sql` is hand-maintained — a typo
* or a missing pair would silently corrupt the backfill on production for
* any tenant relying on diacritic-stripped search.
*/
import { PrismaPg } from '@prisma/adapter-pg'
import { afterAll, describe, expect, it } from 'vitest'
import { PrismaClient } from '~/database/generated/client'
import { stripDiacritics } from '~/shared/utils/strip-diacritics'

const adapter = new PrismaPg({
connectionString: process.env.DB_RUNTIME_URL ?? process.env.DB_URL,
max: 5,
connectionTimeoutMillis: 5000,
})
const testDb = new PrismaClient({ adapter })

const TRANSLATE_FROM = 'àáâãäåçèéêëìíîïñòóôõöùúûüýÿ'
const TRANSLATE_TO = 'aaaaaaceeeeiiiinooooouuuuyy'

async function backfillSql(input: string): Promise<string> {
const rows = await testDb.$queryRawUnsafe<{ result: string }[]>(
`SELECT translate(lower($1), $2, $3) AS result`,
input,
TRANSLATE_FROM,
TRANSLATE_TO,
)
return rows[0]?.result ?? ''
}

afterAll(async () => {
await testDb.$disconnect()
})

describe('migration backfill — SQL/JS parity', () => {
const cases = [
'Pajot',
'Päjot',
'PÄJOT',
'élève',
'Côté',
'François',
'Hélène',
"L'Hôpital-Saint-Louis",
"Boulevard Saint-Germain",
'Champs-Élysées',
'Mañana',
'Müller',
' spaces ',
'mixed CASE Päjot',
]

for (const input of cases) {
it(`SQL backfill matches stripDiacritics() for "${input}"`, async () => {
const sql = await backfillSql(input)
expect(sql).toBe(stripDiacritics(input))
})
}

it('translate map has matching from/to lengths', () => {
// A length mismatch would be a Postgres runtime error inside the
// migration — assert it here so a typo trips a unit failure before
// anyone runs `prisma migrate deploy`.
expect([...TRANSLATE_FROM]).toHaveLength([...TRANSLATE_TO].length)
})

it('uppercase variants get lowercased before translation', async () => {
expect(await backfillSql('ÀÉÎÔÛ')).toBe('aeiou')
})

it('characters outside the map pass through unchanged', async () => {
// æ / œ ligatures + the `ø` slash-o are uncommon in French names and not
// in our map. Document that they survive instead of being silently
// dropped — also keeps the SQL behaviour aligned with the JS helper,
// which preserves them too (NFD doesn't canonically decompose them).
expect(await backfillSql('cœur')).toBe('cœur')
expect(await backfillSql('æther')).toBe('æther')
expect(await backfillSql('strøget')).toBe('strøget')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
-- Add normalized search columns to Member and Building.
--
-- Search aids: lowercased, diacritic-stripped copies of free-text fields used
-- by territory/attribution/building filter forms. Maintained at write-time
-- (see app/shared/utils/strip-diacritics.ts) so runtime queries can stay in
-- pure Prisma `contains` without needing the `unaccent` extension or raw SQL.
--
-- Pre-existing rows are backfilled in step 2 using Postgres `translate()`
-- with an explicit character map covering every Latin-1 / Latin-Extended
-- diacritic likely to appear in French names and addresses. Avoids the
-- `unaccent` extension entirely — managed Postgres hosts often refuse
-- `CREATE EXTENSION` outside the bootstrap role, and we want
-- `prisma migrate deploy` to run cleanly under the standard app role.

-- =========================================================================
-- 1. Add columns with safe defaults
-- =========================================================================

ALTER TABLE "Member"
ADD COLUMN "firstnameNormalized" TEXT NOT NULL DEFAULT '',
ADD COLUMN "lastnameNormalized" TEXT NOT NULL DEFAULT '';

ALTER TABLE "Building"
ADD COLUMN "streetNormalized" TEXT NOT NULL DEFAULT '';

-- =========================================================================
-- 2. Backfill existing rows
--
-- Mirrors `stripDiacritics()` for every char in the map. The `from` and
-- `to` strings must stay the same length; if a new diacritic needs covering
-- later, append matching codepoints to both. Idempotent — the WHERE guard
-- only touches rows with the empty-string default.
-- =========================================================================

UPDATE "Member"
SET
"firstnameNormalized" = translate(lower("firstname"),
'àáâãäåçèéêëìíîïñòóôõöùúûüýÿ',
'aaaaaaceeeeiiiinooooouuuuyy'),
"lastnameNormalized" = translate(lower("lastname"),
'àáâãäåçèéêëìíîïñòóôõöùúûüýÿ',
'aaaaaaceeeeiiiinooooouuuuyy')
WHERE "firstnameNormalized" = '' OR "lastnameNormalized" = '';

UPDATE "Building"
SET "streetNormalized" = translate(lower("street"),
'àáâãäåçèéêëìíîïñòóôõöùúûüýÿ',
'aaaaaaceeeeiiiinooooouuuuyy')
WHERE "streetNormalized" = '';

-- =========================================================================
-- 3. Indexes scoped by congregation for fast filter queries
-- =========================================================================

CREATE INDEX "Member_congregationId_firstnameNormalized_idx"
ON "Member" ("congregationId", "firstnameNormalized");

CREATE INDEX "Member_congregationId_lastnameNormalized_idx"
ON "Member" ("congregationId", "lastnameNormalized");

CREATE INDEX "Building_congregationId_streetNormalized_idx"
ON "Building" ("congregationId", "streetNormalized");
35 changes: 23 additions & 12 deletions app/database/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,17 @@ model Member {
congregationId Int

// Identity
firstname String
lastname String
isMale Boolean?
birthDate DateTime?
phone String @default("")
address String @default("")
firstname String
lastname String
// Search aids: lowercased, diacritic-stripped copies of firstname/lastname.
// Maintained at write-time so search queries can stay in pure Prisma without
// needing the `unaccent` extension at runtime.
firstnameNormalized String @default("")
lastnameNormalized String @default("")
isMale Boolean?
birthDate DateTime?
phone String @default("")
address String @default("")

// Domain status. `isPublisher = false` means ministry-school student.
// `type` is only meaningful when `isPublisher = true`.
Expand Down Expand Up @@ -179,6 +184,8 @@ model Member {

@@unique([id, congregationId])
@@index([congregationId, leftAt])
@@index([congregationId, firstnameNormalized])
@@index([congregationId, lastnameNormalized])
}

// A login. Always has an email + password. Optionally points at a Member.
Expand Down Expand Up @@ -549,12 +556,15 @@ model BuildingResidentialData {
}

model Building {
id Int @id @default(autoincrement())
number String
street String
zip String
latitude Float?
longitude Float?
id Int @id @default(autoincrement())
number String
street String
// Search aid: lowercased, diacritic-stripped copy of street.
// Maintained at write-time so search queries can stay in pure Prisma.
streetNormalized String @default("")
zip String
latitude Float?
longitude Float?
active Boolean @default(true)
inTerritory Boolean @default(true)
inOpenData Boolean @default(false)
Expand All @@ -573,6 +583,7 @@ model Building {

@@unique([number, street, zip, congregationId], name: "address")
@@unique([id, congregationId])
@@index([congregationId, streetNormalized])
}

model Setting {
Expand Down
4 changes: 4 additions & 0 deletions app/database/seed-marketing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { TerritoryAttributionKind } from '../features/territories/model/territor
import { TerritoryKind } from '../features/territories/model/territory-kind.type'
import { Permission } from '../shared/types/permission'
import { PublisherType } from '../shared/types/publisher-type'
import { stripDiacritics } from '../shared/utils/strip-diacritics'
import { PrismaClient } from './generated/client'

const adapter = new PrismaPg({ connectionString: process.env.DB_URL })
Expand Down Expand Up @@ -687,6 +688,8 @@ async function main() {
data: {
firstname: pub.firstname,
lastname: pub.lastname,
firstnameNormalized: stripDiacritics(pub.firstname),
lastnameNormalized: stripDiacritics(pub.lastname),
isPublisher: true,
type: pub.type,
isMale: pub.isMale,
Expand Down Expand Up @@ -881,6 +884,7 @@ async function main() {
data: {
number: buildingNumber,
street: streetInfo.street,
streetNormalized: stripDiacritics(streetInfo.street),
zip: streetInfo.zip,
latitude: coords.latitude,
longitude: coords.longitude,
Expand Down
3 changes: 3 additions & 0 deletions app/features/publishers/server/create-member.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { syncBuiltInRoleAssignments } from '~/shared/domain/built-in-roles.serve
import type { CongregationInfo } from '~/shared/domain/congregation.server'
import { LimitService } from '~/shared/domain/limits.server'
import type { TransactionClient } from '~/shared/infra/db.server'
import { stripDiacritics } from '~/shared/utils/strip-diacritics'
import type { PublisherType } from '~/shared/types/publisher-type'

export interface CreateMemberParams {
Expand Down Expand Up @@ -38,6 +39,8 @@ export async function createMember(db: TransactionClient, congregation: Congrega
data: {
firstname: params.firstname,
lastname: params.lastname,
firstnameNormalized: stripDiacritics(params.firstname),
lastnameNormalized: stripDiacritics(params.lastname),
isMale: params.gender === 'male',
baptismDate: params.baptismDate ? new Date(params.baptismDate) : null,
birthDate: params.birthDate ? new Date(params.birthDate) : null,
Expand Down
Loading