mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
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:
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;
|
||||
@@ -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?
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user