Merge pull request #140 from IliesAllali/feat/a-verifier

Personnes « à vérifier » : la famille signale ce dont elle n'est pas sûre
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-23 15:30:36 +02:00
commit 2345dd5505
18 files changed
+157 -1

No files matched your search

@@ -0,0 +1,3 @@
-- « À vérifier » sur une personne (feat/a-verifier). Migration additive : booléen à faux par défaut.
-- AlterTable
ALTER TABLE "persons" ADD COLUMN "toVerify" BOOLEAN NOT NULL DEFAULT false;
+2
View File
@@ -237,6 +237,8 @@ model Person {
deathDate DateTime?
// Décédé(e) sans date connue (issue #77) : vrai seulement quand deathDate est vide
isDeceased Boolean @default(false)
// « À vérifier » : la famille n'est pas sûre de cette personne et le signale (retour d'Anaïs, 23/10)
toVerify Boolean @default(false)
birthPlace String?
// Précision d'une date importée (« vers », « avant », « entre »…) et sa borne de fin pour « entre … et … »
birthDatePrecision String?
+5
View File
@@ -78,6 +78,7 @@ const personCreateSchema = z.object({
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
toVerify: z.boolean().optional(),
birthPlace: z.string().max(200).optional().nullable(),
deathPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
@@ -96,6 +97,7 @@ const personUpdateSchema = z
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
toVerify: z.boolean().optional(),
birthPlace: z.string().max(200).optional().nullable(),
deathPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
@@ -410,6 +412,7 @@ async function applyContributionChange(
birthDate: toDateOrNull(parsed.data.birthDate),
deathDate: toDateOrNull(parsed.data.deathDate),
isDeceased: !parsed.data.deathDate && Boolean(parsed.data.isDeceased),
toVerify: Boolean(parsed.data.toVerify),
birthPlace: toTrimmedStringOrNull(parsed.data.birthPlace),
deathPlace: toTrimmedStringOrNull(parsed.data.deathPlace),
profession: toTrimmedStringOrNull(parsed.data.profession),
@@ -479,6 +482,7 @@ async function applyContributionChange(
birthDate: nextDates.birthDate,
deathDate: nextDates.deathDate,
isDeceased: nextDates.deathDate ? false : parsed.data.isDeceased,
toVerify: parsed.data.toVerify,
birthPlace: parsed.data.birthPlace !== undefined ? toTrimmedStringOrNull(parsed.data.birthPlace) : undefined,
deathPlace: parsed.data.deathPlace !== undefined ? toTrimmedStringOrNull(parsed.data.deathPlace) : undefined,
profession: parsed.data.profession !== undefined ? toTrimmedStringOrNull(parsed.data.profession) : undefined,
@@ -878,6 +882,7 @@ async function snapshotEntity(
birthDate: true,
deathDate: true,
isDeceased: true,
toVerify: true,
birthPlace: true,
deathPlace: true,
profession: true,
+9
View File
@@ -93,6 +93,7 @@ const createPersonSchema = z.object({
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
toVerify: z.boolean().optional(),
birthPlace: z.string().max(200).optional().nullable(),
deathPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
@@ -111,6 +112,8 @@ const patchPersonSchema = z
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
isDeceased: z.boolean().optional(),
// « À vérifier » : la famille n'est pas sûre de cette personne (retour d'Anaïs, 23/10)
toVerify: z.boolean().optional(),
birthPlace: z.string().max(200).optional().nullable(),
deathPlace: z.string().max(200).optional().nullable(),
profession: z.string().max(200).optional().nullable(),
@@ -1321,6 +1324,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
deathDate: toDateOrNull(payload.data.deathDate),
// Une date de décès dit déjà que la personne est décédée : le drapeau ne sert que sans date
isDeceased: !payload.data.deathDate && Boolean(payload.data.isDeceased),
toVerify: Boolean(payload.data.toVerify),
birthPlace: toTrimmedStringOrNull(payload.data.birthPlace),
deathPlace: toTrimmedStringOrNull(payload.data.deathPlace),
profession: toTrimmedStringOrNull(payload.data.profession),
@@ -1391,6 +1395,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
birthDate?: Date | null
deathDate?: Date | null
isDeceased?: boolean
toVerify?: boolean
birthPlace?: string | null
deathPlace?: string | null
profession?: string | null
@@ -1433,6 +1438,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
data.isDeceased = false
}
if (payload.data.toVerify !== undefined) {
data.toVerify = payload.data.toVerify
}
if (payload.data.birthPlace !== undefined) {
data.birthPlace = toTrimmedStringOrNull(payload.data.birthPlace)
}
+2
View File
@@ -15,6 +15,7 @@ export type GraphPayload = {
birthDate: string | null
deathDate: string | null
isDeceased: boolean
toVerify: boolean
birthPlace: string | null
deathPlace: string | null
profession: string | null
@@ -105,6 +106,7 @@ export function buildGraphPayload(
birthDate: formatDate(person.birthDate),
deathDate: formatDate(person.deathDate),
isDeceased: Boolean(person.isDeceased) && !person.deathDate,
toVerify: Boolean(person.toVerify),
birthPlace: person.birthPlace,
deathPlace: person.deathPlace,
profession: person.profession,
+22
View File
@@ -1211,6 +1211,28 @@ describe('Surnom (nickname) en écriture directe', () => {
})
})
describe('« À vérifier » en écriture directe (retour d’Anaïs, 23/10)', () => {
it('POST et PATCH posent et retirent le drapeau, absent = inchangé', async () => {
const app = createApp()
const token = await createUserToken(app)
const headers = { authorization: `Bearer ${token}` }
const created = await app.inject({ method: 'POST', url: '/trees/tree-1/persons', headers, payload: { firstName: 'Jeanne', lastName: 'Martin', toVerify: true } })
expect(created.statusCode).toBe(201)
expect(prismaMock.person.create.mock.calls.at(-1)[0].data).toMatchObject({ toVerify: true })
const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers, payload })
expect((await patch({ toVerify: true })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ toVerify: true })
expect((await patch({ toVerify: false })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ toVerify: false })
expect((await patch({ nickname: 'Loulou' })).statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).not.toHaveProperty('toVerify')
expect((await patch({ toVerify: 'oui' })).statusCode).toBe(400)
await app.close()
})
})
describe('Sexe d’une personne en écriture directe (issue #114)', () => {
it('PATCH ramène le sexe à male / female / other, null = non précisé, refuse une valeur inconnue', async () => {
const app = createApp()
@@ -1151,6 +1151,33 @@ describe('API security and authz', () => {
await app.close()
})
it('« à vérifier » proposé par la famille : appliqué à l’approbation, en création comme en modification', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.contributionSession.findFirst.mockResolvedValue({
id: 'session-1',
treeId: 'tree-1',
status: 'pending',
changes: [
{ id: 'change-1', entityType: 'person', action: 'create', entityId: null, beforeJson: null, afterJson: { firstName: 'Alice', lastName: 'Martin', toVerify: true }, conflictState: 'none' },
{ id: 'change-2', entityType: 'person', action: 'update', entityId: 'person-1', beforeJson: { toVerify: false }, afterJson: { toVerify: true }, conflictState: 'none' },
],
})
const response = await app.inject({
method: 'POST',
url: '/trees/tree-1/contributions/sessions/session-1/review',
headers: { authorization: `Bearer ${token}` },
payload: { decision: 'approved' },
})
expect(response.statusCode).toBe(200)
expect(prismaMock.person.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ firstName: 'Alice', toVerify: true }) }))
expect(prismaMock.person.updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: 'person-1' }), data: expect.objectContaining({ toVerify: true }) }))
await app.close()
})
it('allows owner to upload media below image limit', async () => {
const app = createApp()
const token = await createUserToken(app)
+4
View File
@@ -317,6 +317,7 @@ function buildContributionChangesFromDraft(draft = {}) {
if (source.isAlive === true) after.deathDate = null
after.isDeceased = source.isAlive === false
}
if (Object.prototype.hasOwnProperty.call(source, 'toVerify')) after.toVerify = Boolean(source.toVerify)
if (Object.keys(after).length > 0) {
changes.push({
@@ -1349,6 +1350,7 @@ function App() {
if (rawChanges.isAlive === true) payload.deathDate = null
payload.isDeceased = rawChanges.isAlive === false
}
if (Object.prototype.hasOwnProperty.call(rawChanges, 'toVerify')) payload.toVerify = Boolean(rawChanges.toVerify)
if (Object.prototype.hasOwnProperty.call(rawChanges, 'note')) payload.notes = rawChanges.note || null
if (Object.keys(payload).length > 0) {
@@ -1639,6 +1641,7 @@ function App() {
localPerson.isAlive = changes.isAlive === true
localPerson.isDeceased = changes.isAlive === false && !localPerson.deathYear
}
if (Object.prototype.hasOwnProperty.call(changes, 'toVerify')) localPerson.toVerify = Boolean(changes.toVerify)
tree.setSelectedPerson({ ...localPerson })
}
return
@@ -1665,6 +1668,7 @@ function App() {
if (changes.isAlive === true) payload.deathDate = null
payload.isDeceased = changes.isAlive === false
}
if (Object.prototype.hasOwnProperty.call(changes, 'toVerify')) payload.toVerify = Boolean(changes.toVerify)
if (Object.prototype.hasOwnProperty.call(changes, 'note')) payload.notes = changes.note || null
if (Object.keys(payload).length > 0) {
@@ -96,11 +96,13 @@ const PERSON_FIELD_LABELS = {
nationality: t('Nationalité'),
region: t('Région'),
notes: t('Notes'),
toVerify: t('À vérifier'),
}
function displayVal(v, key) {
// Sexe : « Non précisé » plutôt que « vide »
if (key === 'sex') return sexLabel(v)
if (key === 'toVerify') return v ? t('Oui') : t('Non')
if (v === null || v === undefined || v === '') return null
return String(v)
}
+2
View File
@@ -199,6 +199,8 @@
}
.pc-alive-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--pz-faint); }
.pc-alive.is-alive .pc-alive-dot { background: var(--pz-success); }
.pc-verify.is-on { background: var(--pz-a100); color: var(--pz-a700); border-color: transparent; }
.pc-verify.is-on .pc-alive-dot { background: var(--pz-a500); }
/* Liens (relations) */
.pc-rel-row { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 4px 0; }
+16
View File
@@ -752,6 +752,7 @@ const PersonCard = ({
</button>
</span>
)}
{!editMode && person.toVerify && <span className="pz-tag pz-tag--accent pz-tag--dot">{t('À vérifier')}</span>}
{!editMode && person.profession && <span className="pz-tag">{person.profession}</span>}
</div>
</div>
@@ -949,6 +950,21 @@ const PersonCard = ({
</button>
</div>
)}
{editMode && (
// La famille n'est pas sûre de cette personne : marquée sur l'arbre et dans la fiche (retour d'Anaïs, 23/10)
<div className="pc-row">
<span className="pc-label">{t('Vérification')}</span>
<button
type="button"
aria-pressed={Boolean(getEditValue('toVerify', person.toVerify))}
className={`pc-alive pc-verify ${getEditValue('toVerify', person.toVerify) ? 'is-on' : ''}`}
onClick={() => handleFieldChange('toVerify', !getEditValue('toVerify', person.toVerify))}
>
<span className="pc-alive-dot" />
{t('À vérifier')}
</button>
</div>
)}
{infoRow('deathYear', t('Décès'), deathFilled, renderEditableValue({ field: 'deathYear', value: person.deathDate || person.deathYear || '', placeholder: t('Date de décès'), type: 'date', emptyLabel: t('Date inconnue') }), deceasedNow)}
{infoRow('deathPlace', t('Lieu de décès'), isFilled(getEditValue('deathPlace', person.deathPlace)), renderEditableValue({ field: 'deathPlace', value: person.deathPlace || '', placeholder: t('Lieu de décès'), format: formatPlace }))}
{!editMode && (
@@ -603,6 +603,8 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
}
drawFrame(ctx, frameType, person, img)
// « À vérifier » : petit rond terracotta avec un « ? » en haut à droite du cadre, absent au dézoom
if (person.toVerify && withDetail) drawToVerifyMark(ctx)
ctx.restore()
// --- Labels (estompés puis absents en dézoom) ---
@@ -928,6 +930,25 @@ function resolveFrameType(person, img) {
return resolveFrameKind(person.frameType || 'round', ratio, cropShape(person.avatarCrop))
}
function drawToVerifyMark(ctx) {
const r = 9
const x = PERSON_R * 0.74, y = -PERSON_R * 0.74
ctx.shadowColor = 'transparent'
ctx.fillStyle = '#FFFFFF'
ctx.beginPath()
ctx.arc(x, y, r + 1.5, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#D2694A'
ctx.beginPath()
ctx.arc(x, y, r, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#FFFFFF'
ctx.font = '600 12px "DM Sans", system-ui, sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText('?', x, y + 0.5)
}
function drawFrame(ctx, frameType, person, img) {
if (frameType === 'polaroid') {
const pw = PERSON_R + 4
@@ -9,6 +9,7 @@ const REASON_LABELS = {
Lieu: t('Lieu'),
Date: t('Date'),
Contenu: t('Contenu'),
'À vérifier': t('À vérifier'),
}
/**
+2
View File
@@ -113,6 +113,8 @@ function mapRemotePersonToLocal(person, current = null) {
// Décédé(e) sans date connue (issue #77)
isDeceased: Boolean(person?.isDeceased) && !deathYear,
isAlive: !deathYear && !person?.isDeceased,
// « À vérifier » : la famille n'est pas sûre de cette personne
toVerify: person ? Boolean(person.toVerify) : Boolean(current?.toVerify),
frameType: current?.frameType || pickFrameType(person?.id),
note: person?.notes || null,
birthPlace: person?.birthPlace || null,
+11 -1
View File
@@ -129,4 +129,14 @@ describe('personnes identifiées sur un souvenir', () => {
{ personId: 'p-2', x: null, y: null },
])
})
})
})
describe('« À vérifier » au chargement du graphe', () => {
it('garde le drapeau, absent = faux', () => {
loadGraphData({ persons: [
{ id: 'a', firstName: 'Jeanne', lastName: 'Martin', toVerify: true },
{ id: 'b', firstName: 'Paul', lastName: 'Martin' },
] })
expect(getPersonById('a').toVerify).toBe(true)
expect(getPersonById('b').toVerify).toBe(false)
})
})
+11
View File
@@ -29,6 +29,12 @@ const SCORE_WEIGHTS = {
NOTE_CONTAINS: 20,
}
// Taper « à vérifier » (avec ou sans accents, même en partie) liste les personnes marquées à vérifier
const asksToVerify = (q) => {
const plain = q.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\s+/g, ' ')
return plain.length >= 5 && 'a verifier'.startsWith(plain)
}
/**
* Seuil minimum de score pour afficher un résultat
*/
@@ -45,6 +51,7 @@ function calculateScore(query, person) {
const q = query.toLowerCase().trim()
if (!q || q.length < 2) return 0
if (person.toVerify && asksToVerify(q)) return SCORE_WEIGHTS.NAME_EXACT
// Normaliser les données de la personne
const firstName = (person.firstName || '').toLowerCase()
@@ -155,6 +162,10 @@ function getMatchReason(query, person) {
const birthPlace = (person.birthPlace || '').toLowerCase()
const birthYear = person.birthYear?.toString() || ''
if (person.toVerify && asksToVerify(q)) {
return 'À vérifier'
}
if (fullName === q || firstName === q || lastName === q) {
return 'Nom'
}
@@ -18,3 +18,19 @@ describe('recherche par surnom', () => {
expect(getMatchReason('dupont', robert)).toBe('Nom')
})
})
describe('recherche « à vérifier »', () => {
const doute = { firstName: 'Jeanne', lastName: 'Martin', toVerify: true }
const sure = { firstName: 'Paul', lastName: 'Martin', toVerify: false }
it('trouve les personnes marquées, avec ou sans accents, même en partie', () => {
for (const q of ['à vérifier', 'a verifier', 'À VÉRIF', 'a ver']) {
expect(calculateScore(q.toLowerCase(), doute)).toBeGreaterThanOrEqual(MIN_SCORE_THRESHOLD)
}
expect(getMatchReason('a verifier', doute)).toBe('À vérifier')
})
it('ne remonte pas les personnes non marquées', () => {
expect(calculateScore('a verifier', sure)).toBeLessThan(MIN_SCORE_THRESHOLD)
})
})
+1
View File
@@ -49,6 +49,7 @@ export default {
'Surnom': 'Nickname',
'Loulou': 'Lulu',
'Statut': 'Status',
'Vérification': 'Check',
'En vie': 'Living',
'Décédé(e)': 'Deceased',
'Décédé': 'Deceased',