mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-09 15:50:58 +00:00
Contributions fiables : prénom, mot d'accompagnement, liens gardés
- Prénom demandé à l'envoi d'une contribution et d'un souvenir, retenu sur l'appareil pour la fois suivante (submittedByLabel). Le propriétaire lit « par Camille » au lieu de « par Contributeur ». - Mot d'accompagnement enregistré (ContributionSession.comment, migration additive) et affiché à la relecture. - Une personne ajoutée par la famille garde ses liens : identifiant provisoire « tmp:… » remplacé par le vrai id à l'application ; un lien vers une personne refusée est écarté au lieu de faire échouer la relecture. Frère ou sœur : parents calculés aussi pour la famille. - Soumission et relecture en transaction, appliquées dans l'ordre personnes, unions, liens : tout ou rien. - Bouton « Ajouter une personne » pour la famille sur ordinateur ; après l'ajout, le panneau se ferme et un message confirme que la personne est dans les modifications ; il ne réapparaît plus au retour en édition. - Relecture : les liens se lisent « Léon, enfant de Marc ». Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
66752aa1b6
commit
8bfc78b2f5
12 files changed
+491
-141
No files matched your search
@@ -0,0 +1,4 @@
|
||||
-- Mot d'accompagnement d'une contribution de la famille (25/09/2026). Additif, facultatif.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "contribution_sessions" ADD COLUMN "comment" TEXT;
|
||||
@@ -268,6 +268,7 @@ model ContributionSession {
|
||||
treeId String
|
||||
submittedByUserId String?
|
||||
submittedByLabel String
|
||||
comment String?
|
||||
mode ContributionActorMode
|
||||
status ContributionSessionStatus @default(pending)
|
||||
title String
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
// Renumérote les souvenirs acceptés d'une personne et met en avant les trois premiers.
|
||||
// Les souvenirs en attente de relecture ne comptent pas.
|
||||
export async function syncPersonMediaOrder(prisma: PrismaClient, treeId: string, personId: string): Promise<void> {
|
||||
export async function syncPersonMediaOrder(prisma: Prisma.TransactionClient, treeId: string, personId: string): Promise<void> {
|
||||
const media = await prisma.mediaItem.findMany({
|
||||
where: {
|
||||
treeId,
|
||||
|
||||
+185
-122
@@ -28,7 +28,8 @@ const contributionChangeSchema = z.object({
|
||||
|
||||
const createSessionSchema = z.object({
|
||||
title: z.string().min(2).max(120),
|
||||
submittedByLabel: z.string().min(1).max(120).optional(),
|
||||
submittedByLabel: z.string().trim().min(1).max(120).optional(),
|
||||
comment: z.string().trim().max(500).optional().nullable(),
|
||||
changes: z.array(contributionChangeSchema).min(1),
|
||||
})
|
||||
|
||||
@@ -130,7 +131,43 @@ type ContributionStatus = 'pending' | 'approved' | 'rejected'
|
||||
|
||||
type ParsedChange = z.infer<typeof contributionChangeSchema>
|
||||
|
||||
type TxClient = FastifyInstance['prisma']
|
||||
type TxClient = Prisma.TransactionClient
|
||||
|
||||
// Identifiants provisoires : une personne ajoutée dans une contribution porte `ref: "tmp:…"`,
|
||||
// ses liens (union, parent-enfant) y font référence ; remplacés par le vrai id à l'application.
|
||||
type RefMap = Map<string, string>
|
||||
const TEMP_REF_PREFIX = 'tmp:'
|
||||
const REF_FIELDS = ['partner1PersonId', 'partner2PersonId', 'parentPersonId', 'childPersonId', 'viaUnionId'] as const
|
||||
|
||||
function resolveTempRefs(after: Record<string, unknown>, refs: RefMap): Record<string, unknown> {
|
||||
const resolved: Record<string, unknown> = { ...after }
|
||||
for (const field of REF_FIELDS) {
|
||||
const value = resolved[field]
|
||||
if (typeof value === 'string' && value.startsWith(TEMP_REF_PREFIX)) {
|
||||
const realId = refs.get(value)
|
||||
if (!realId) {
|
||||
throw new ContributionRouteError(400, 'unresolved_reference')
|
||||
}
|
||||
resolved[field] = realId
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function recordTempRef(after: unknown, createdId: string, refs: RefMap) {
|
||||
if (isRecord(after) && typeof after.ref === 'string' && after.ref.startsWith(TEMP_REF_PREFIX)) {
|
||||
refs.set(after.ref, createdId)
|
||||
}
|
||||
}
|
||||
|
||||
// Personnes d'abord, puis unions, puis liens : un lien vers une personne ajoutée s'applique après elle
|
||||
const ENTITY_ORDER: Record<string, number> = { person: 0, union: 1, parent_child_link: 2, media: 3, annotation: 4 }
|
||||
function byApplicationOrder<T extends { entityType: string; action: string }>(changes: T[]): T[] {
|
||||
const rank = (change: T) => (ENTITY_ORDER[change.entityType] ?? 9) * 10 + (change.action === 'create' ? 0 : 1)
|
||||
return changes.map((change, index) => ({ change, index }))
|
||||
.sort((a, b) => rank(a.change) - rank(b.change) || a.index - b.index)
|
||||
.map(({ change }) => change)
|
||||
}
|
||||
|
||||
export class ContributionRouteError extends Error {
|
||||
statusCode: number
|
||||
@@ -245,6 +282,7 @@ async function applyContributionChange(
|
||||
treeId: string,
|
||||
change: ParsedChange,
|
||||
actorUserId: string | null,
|
||||
refs: RefMap = new Map(),
|
||||
): Promise<{ entityId: string | null }> {
|
||||
// Les annotations sont appliquées par le client après la relecture (batchAnnotations) :
|
||||
// rien à faire ici. Avant, elles tombaient dans la branche des liens et faisaient échouer la relecture.
|
||||
@@ -310,6 +348,7 @@ async function applyContributionChange(
|
||||
},
|
||||
})
|
||||
|
||||
recordTempRef(change.after, created.id, refs)
|
||||
return { entityId: created.id }
|
||||
}
|
||||
|
||||
@@ -385,7 +424,7 @@ async function applyContributionChange(
|
||||
throw new ContributionRouteError(400, 'invalid_change_after')
|
||||
}
|
||||
|
||||
const parsed = unionCreateSchema.safeParse(change.after)
|
||||
const parsed = unionCreateSchema.safeParse(resolveTempRefs(change.after, refs))
|
||||
if (!parsed.success) {
|
||||
throw new ContributionRouteError(400, 'invalid_change_after')
|
||||
}
|
||||
@@ -420,6 +459,7 @@ async function applyContributionChange(
|
||||
},
|
||||
})
|
||||
|
||||
recordTempRef(change.after, created.id, refs)
|
||||
return { entityId: created.id }
|
||||
}
|
||||
|
||||
@@ -551,7 +591,7 @@ async function applyContributionChange(
|
||||
throw new ContributionRouteError(400, 'invalid_change_after')
|
||||
}
|
||||
|
||||
const parsed = linkCreateSchema.safeParse(change.after)
|
||||
const parsed = linkCreateSchema.safeParse(resolveTempRefs(change.after, refs))
|
||||
if (!parsed.success) {
|
||||
throw new ContributionRouteError(400, 'invalid_change_after')
|
||||
}
|
||||
@@ -966,59 +1006,67 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
|
||||
try {
|
||||
const context = await resolveSubmissionContext(app, request.actor, params.data.id)
|
||||
const now = new Date()
|
||||
const session = await app.prisma.contributionSession.create({
|
||||
data: {
|
||||
treeId: params.data.id,
|
||||
submittedByUserId: context.submittedByUserId,
|
||||
submittedByLabel: payload.data.submittedByLabel?.trim() || context.submittedByLabel,
|
||||
mode: context.mode,
|
||||
status: context.autoStatus,
|
||||
title: payload.data.title.trim(),
|
||||
reviewedAt: context.autoStatus === 'approved' ? now : undefined,
|
||||
reviewedBy: context.autoStatus === 'approved' ? context.submittedByUserId : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const createdChanges = []
|
||||
|
||||
for (const change of payload.data.changes) {
|
||||
let effectiveEntityId = change.entityId ?? null
|
||||
|
||||
if (context.autoStatus === 'approved') {
|
||||
const applied = await applyContributionChange(app.prisma, params.data.id, change, context.submittedByUserId)
|
||||
effectiveEntityId = applied.entityId
|
||||
}
|
||||
|
||||
const createdChange = await app.prisma.contributionChange.create({
|
||||
// Tout ou rien : une erreur au milieu n'applique rien
|
||||
const { session, createdChanges } = await app.prisma.$transaction(async (tx) => {
|
||||
const session = await tx.contributionSession.create({
|
||||
data: {
|
||||
sessionId: session.id,
|
||||
entityType: change.entityType,
|
||||
entityId: effectiveEntityId,
|
||||
action: change.action,
|
||||
beforeJson: toPrismaJson(change.before),
|
||||
afterJson: toPrismaJson(change.after),
|
||||
conflictState: change.conflictState ?? 'none',
|
||||
decision: context.autoStatus === 'approved' ? 'approved' : undefined,
|
||||
treeId: params.data.id,
|
||||
submittedByUserId: context.submittedByUserId,
|
||||
submittedByLabel: payload.data.submittedByLabel?.trim() || context.submittedByLabel,
|
||||
comment: payload.data.comment?.trim() || null,
|
||||
mode: context.mode,
|
||||
status: context.autoStatus,
|
||||
title: payload.data.title.trim(),
|
||||
reviewedAt: context.autoStatus === 'approved' ? now : undefined,
|
||||
reviewedBy: context.autoStatus === 'approved' ? context.submittedByUserId : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
createdChanges.push(createdChange)
|
||||
}
|
||||
const refs: RefMap = new Map()
|
||||
const createdChanges = []
|
||||
|
||||
await app.prisma.auditLog.create({
|
||||
data: {
|
||||
treeId: params.data.id,
|
||||
actorType: context.actorType,
|
||||
actorId: context.actorId,
|
||||
action: 'contribution_session_submitted',
|
||||
entityType: 'contribution_session',
|
||||
entityId: session.id,
|
||||
payloadJson: {
|
||||
status: context.autoStatus,
|
||||
changesCount: createdChanges.length,
|
||||
for (const change of byApplicationOrder(payload.data.changes)) {
|
||||
let effectiveEntityId = change.entityId ?? null
|
||||
|
||||
if (context.autoStatus === 'approved') {
|
||||
const applied = await applyContributionChange(tx, params.data.id, change, context.submittedByUserId, refs)
|
||||
effectiveEntityId = applied.entityId
|
||||
}
|
||||
|
||||
const createdChange = await tx.contributionChange.create({
|
||||
data: {
|
||||
sessionId: session.id,
|
||||
entityType: change.entityType,
|
||||
entityId: effectiveEntityId,
|
||||
action: change.action,
|
||||
beforeJson: toPrismaJson(change.before),
|
||||
afterJson: toPrismaJson(change.after),
|
||||
conflictState: change.conflictState ?? 'none',
|
||||
decision: context.autoStatus === 'approved' ? 'approved' : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
createdChanges.push(createdChange)
|
||||
}
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
treeId: params.data.id,
|
||||
actorType: context.actorType,
|
||||
actorId: context.actorId,
|
||||
action: 'contribution_session_submitted',
|
||||
entityType: 'contribution_session',
|
||||
entityId: session.id,
|
||||
payloadJson: {
|
||||
status: context.autoStatus,
|
||||
changesCount: createdChanges.length,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return { session, createdChanges }
|
||||
}, { timeout: 30_000 })
|
||||
|
||||
return reply.code(201).send({
|
||||
session,
|
||||
@@ -1119,93 +1167,108 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
|
||||
explicitDecisions.set(changeDecision.changeId, changeDecision.decision)
|
||||
}
|
||||
|
||||
let approvedCount = 0
|
||||
let rejectedCount = 0
|
||||
let conflictCount = 0
|
||||
const { updatedSession, approvedCount, rejectedCount, conflictCount, status } = await app.prisma.$transaction(async (tx) => {
|
||||
let approvedCount = 0
|
||||
let rejectedCount = 0
|
||||
let conflictCount = 0
|
||||
const refs: RefMap = new Map()
|
||||
|
||||
for (const change of session.changes) {
|
||||
let decision = explicitDecisions.get(change.id) ?? payload.data.decision
|
||||
for (const change of byApplicationOrder(session.changes)) {
|
||||
let decision = explicitDecisions.get(change.id) ?? payload.data.decision
|
||||
|
||||
const hasConflict = await detectConflict(app.prisma, params.data.id, {
|
||||
entityType: change.entityType,
|
||||
action: change.action,
|
||||
entityId: change.entityId,
|
||||
beforeJson: change.beforeJson,
|
||||
})
|
||||
|
||||
if (hasConflict && change.conflictState !== 'needs_review') {
|
||||
await app.prisma.contributionChange.update({
|
||||
where: { id: change.id },
|
||||
data: { conflictState: 'needs_review' },
|
||||
const hasConflict = await detectConflict(tx, params.data.id, {
|
||||
entityType: change.entityType,
|
||||
action: change.action,
|
||||
entityId: change.entityId,
|
||||
beforeJson: change.beforeJson,
|
||||
})
|
||||
conflictCount += 1
|
||||
}
|
||||
|
||||
if (decision === 'approved') {
|
||||
await applyContributionChange(
|
||||
app.prisma,
|
||||
params.data.id,
|
||||
{
|
||||
entityType: change.entityType,
|
||||
action: change.action,
|
||||
entityId: change.entityId,
|
||||
before: change.beforeJson,
|
||||
after: change.afterJson,
|
||||
conflictState: change.conflictState,
|
||||
},
|
||||
membership.userId,
|
||||
)
|
||||
|
||||
approvedCount += 1
|
||||
} else {
|
||||
if (change.entityType === 'media' && change.entityId) {
|
||||
await app.prisma.mediaItem.updateMany({
|
||||
where: { id: change.entityId, treeId: params.data.id },
|
||||
data: { status: 'rejected', deletedAt: new Date() },
|
||||
if (hasConflict && change.conflictState !== 'needs_review') {
|
||||
await tx.contributionChange.update({
|
||||
where: { id: change.id },
|
||||
data: { conflictState: 'needs_review' },
|
||||
})
|
||||
conflictCount += 1
|
||||
}
|
||||
rejectedCount += 1
|
||||
|
||||
if (decision === 'approved') {
|
||||
try {
|
||||
await applyContributionChange(
|
||||
tx,
|
||||
params.data.id,
|
||||
{
|
||||
entityType: change.entityType,
|
||||
action: change.action,
|
||||
entityId: change.entityId,
|
||||
before: change.beforeJson,
|
||||
after: change.afterJson,
|
||||
conflictState: change.conflictState,
|
||||
},
|
||||
membership.userId,
|
||||
refs,
|
||||
)
|
||||
approvedCount += 1
|
||||
} catch (error) {
|
||||
// Lien vers une personne ajoutée mais refusée : le lien est écarté, pas la relecture
|
||||
if (error instanceof ContributionRouteError && error.code === 'unresolved_reference') {
|
||||
decision = 'rejected'
|
||||
rejectedCount += 1
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (change.entityType === 'media' && change.entityId) {
|
||||
await tx.mediaItem.updateMany({
|
||||
where: { id: change.entityId, treeId: params.data.id },
|
||||
data: { status: 'rejected', deletedAt: new Date() },
|
||||
})
|
||||
}
|
||||
rejectedCount += 1
|
||||
}
|
||||
|
||||
await tx.contributionChange.update({
|
||||
where: {
|
||||
id: change.id,
|
||||
},
|
||||
data: {
|
||||
decision,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await app.prisma.contributionChange.update({
|
||||
const status: ContributionStatus = approvedCount > 0 ? 'approved' : 'rejected'
|
||||
|
||||
const updatedSession = await tx.contributionSession.update({
|
||||
where: {
|
||||
id: change.id,
|
||||
id: session.id,
|
||||
},
|
||||
data: {
|
||||
decision,
|
||||
status,
|
||||
reviewedAt: new Date(),
|
||||
reviewedBy: membership.userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const status: ContributionStatus = approvedCount > 0 ? 'approved' : 'rejected'
|
||||
|
||||
const updatedSession = await app.prisma.contributionSession.update({
|
||||
where: {
|
||||
id: session.id,
|
||||
},
|
||||
data: {
|
||||
status,
|
||||
reviewedAt: new Date(),
|
||||
reviewedBy: membership.userId,
|
||||
},
|
||||
})
|
||||
|
||||
await app.prisma.auditLog.create({
|
||||
data: {
|
||||
treeId: params.data.id,
|
||||
actorType: 'user',
|
||||
actorId: membership.userId,
|
||||
action: 'contribution_session_reviewed',
|
||||
entityType: 'contribution_session',
|
||||
entityId: session.id,
|
||||
payloadJson: {
|
||||
approvedCount,
|
||||
rejectedCount,
|
||||
conflictCount,
|
||||
finalStatus: status,
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
treeId: params.data.id,
|
||||
actorType: 'user',
|
||||
actorId: membership.userId,
|
||||
action: 'contribution_session_reviewed',
|
||||
entityType: 'contribution_session',
|
||||
entityId: session.id,
|
||||
payloadJson: {
|
||||
approvedCount,
|
||||
rejectedCount,
|
||||
conflictCount,
|
||||
finalStatus: status,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return { updatedSession, approvedCount, rejectedCount, conflictCount, status }
|
||||
}, { timeout: 30_000 })
|
||||
|
||||
return reply.send({
|
||||
session: updatedSession,
|
||||
|
||||
@@ -673,6 +673,117 @@ describe('API security and authz', () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
// Contributions fiables (25/09/2026) : mot d'accompagnement, liens d'une personne ajoutée
|
||||
const signShareToken = (app: any) => app.jwt.sign({
|
||||
kind: 'tree_access',
|
||||
sub: 'tree:tree-1:contributor',
|
||||
treeId: 'tree-1',
|
||||
role: 'contributor',
|
||||
accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(),
|
||||
})
|
||||
|
||||
it('stores the contributor first name and comment', async () => {
|
||||
const app = createApp()
|
||||
await app.ready()
|
||||
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null, settings: { contributorPolicy: 'pending' } })
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/trees/tree-1/contributions/sessions',
|
||||
headers: { authorization: `Bearer ${signShareToken(app)}` },
|
||||
payload: {
|
||||
title: 'Contribution de Camille',
|
||||
submittedByLabel: 'Camille',
|
||||
comment: "J'ai ajouté mon cousin",
|
||||
changes: [{ entityType: 'person', action: 'create', after: { firstName: 'Léon', lastName: 'Martin' } }],
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(201)
|
||||
expect(prismaMock.contributionSession.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ submittedByLabel: 'Camille', comment: "J'ai ajouté mon cousin" }) }),
|
||||
)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('links a person added in the same contribution, even when the link comes first', async () => {
|
||||
const app = createApp()
|
||||
await app.ready()
|
||||
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null, settings: { contributorPolicy: 'direct' } })
|
||||
prismaMock.person.create.mockResolvedValue({ id: 'person-new' })
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/trees/tree-1/contributions/sessions',
|
||||
headers: { authorization: `Bearer ${signShareToken(app)}` },
|
||||
payload: {
|
||||
title: 'Un enfant pour Alice',
|
||||
changes: [
|
||||
{ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: 'person-1', childPersonId: 'tmp:léon', parentageType: 'biologique' } },
|
||||
{ entityType: 'person', action: 'create', after: { ref: 'tmp:léon', firstName: 'Léon', lastName: 'Martin' } },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(201)
|
||||
expect(prismaMock.parentChildLink.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ parentPersonId: 'person-1', childPersonId: 'person-new' }) }),
|
||||
)
|
||||
expect(prismaMock.$transaction).toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('drops the link of a rejected new person instead of failing the review', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
prismaMock.contributionSession.findFirst.mockResolvedValue({
|
||||
id: 'session-1',
|
||||
treeId: 'tree-1',
|
||||
status: 'pending',
|
||||
changes: [
|
||||
{ id: 'change-link', entityType: 'parent_child_link', action: 'create', entityId: null, beforeJson: null, afterJson: { parentPersonId: 'person-1', childPersonId: 'tmp:léon' }, conflictState: 'none' },
|
||||
{ id: 'change-person', entityType: 'person', action: 'create', entityId: null, beforeJson: null, afterJson: { ref: 'tmp:léon', firstName: 'Léon', lastName: 'Martin' }, 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',
|
||||
changeDecisions: [{ changeId: 'change-person', decision: 'rejected' }],
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(prismaMock.parentChildLink.create).not.toHaveBeenCalled()
|
||||
expect(prismaMock.contributionChange.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'change-link' }, data: { decision: 'rejected' } }),
|
||||
)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuses a link to an unknown provisional person at submission', async () => {
|
||||
const app = createApp()
|
||||
await app.ready()
|
||||
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null, settings: { contributorPolicy: 'direct' } })
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/trees/tree-1/contributions/sessions',
|
||||
headers: { authorization: `Bearer ${signShareToken(app)}` },
|
||||
payload: {
|
||||
title: 'Lien orphelin',
|
||||
changes: [{ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: 'person-1', childPersonId: 'tmp:personne' } }],
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(400)
|
||||
expect(response.json().error).toBe('unresolved_reference')
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('allows admin to list pending contribution sessions', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
|
||||
+67
-9
@@ -61,6 +61,7 @@ import {
|
||||
import './App.css'
|
||||
import './styles/pz.css'
|
||||
import { setBooting } from './bootSignal.js'
|
||||
import { saveContributorName } from './utils/contributorName'
|
||||
|
||||
function yearToIsoDate(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
@@ -202,10 +203,14 @@ function buildContributionChangesFromDraft(draft = {}) {
|
||||
if (!person || typeof person !== 'object') continue
|
||||
if (!person.firstName || !person.lastName) continue
|
||||
|
||||
// Identifiant provisoire : le serveur le remplace par le vrai id pour créer les liens
|
||||
const ref = typeof person.ref === 'string' && person.ref.startsWith('tmp:') ? person.ref : null
|
||||
|
||||
changes.push({
|
||||
entityType: 'person',
|
||||
action: 'create',
|
||||
after: {
|
||||
...(ref ? { ref } : {}),
|
||||
firstName: String(person.firstName).trim(),
|
||||
lastName: String(person.lastName).trim(),
|
||||
birthName: person.birthName ? String(person.birthName).trim() : null,
|
||||
@@ -213,6 +218,32 @@ function buildContributionChangesFromDraft(draft = {}) {
|
||||
deathDate: person.deathDate || null,
|
||||
},
|
||||
})
|
||||
|
||||
if (!ref) continue
|
||||
|
||||
const linkedParentIds = new Set()
|
||||
for (const rel of Array.isArray(person.relations) ? person.relations : []) {
|
||||
if (!rel?.personId) continue
|
||||
const relatedId = String(rel.personId)
|
||||
if (rel.type === 'child') {
|
||||
linkedParentIds.add(relatedId)
|
||||
changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: relatedId, childPersonId: ref, parentageType: 'biologique' } })
|
||||
} else if (rel.type === 'parent') {
|
||||
changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: ref, childPersonId: relatedId, parentageType: 'biologique' } })
|
||||
} else if (rel.type === 'spouse') {
|
||||
changes.push({ entityType: 'union', action: 'create', after: { partner1PersonId: relatedId, partner2PersonId: ref, unionType: 'mariage' } })
|
||||
}
|
||||
}
|
||||
|
||||
for (const link of Array.isArray(person.siblingParentLinks) ? person.siblingParentLinks : []) {
|
||||
if (!link?.parentId || linkedParentIds.has(String(link.parentId))) continue
|
||||
linkedParentIds.add(String(link.parentId))
|
||||
changes.push({
|
||||
entityType: 'parent_child_link',
|
||||
action: 'create',
|
||||
after: { parentPersonId: String(link.parentId), childPersonId: ref, viaUnionId: link.unionId || null, parentageType: 'biologique' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const modifiedPersons = draft.modifiedPersons && typeof draft.modifiedPersons === 'object'
|
||||
@@ -349,10 +380,11 @@ function buildContributionChangesFromDraft(draft = {}) {
|
||||
|
||||
function buildContribChangesList(draft = {}) {
|
||||
const items = []
|
||||
const addedPersonIds = (Array.isArray(draft.addedPersons) ? draft.addedPersons : []).map(String)
|
||||
const addedPersonEntries = Array.isArray(draft.addedPersons) ? draft.addedPersons : []
|
||||
const addedPersonIds = addedPersonEntries.filter((entry) => typeof entry !== 'object').map(String)
|
||||
|
||||
for (const id of addedPersonIds) {
|
||||
const d = draft.modifiedPersons?.[id] || {}
|
||||
for (const entry of addedPersonEntries) {
|
||||
const d = entry && typeof entry === 'object' ? entry : (draft.modifiedPersons?.[String(entry)] || {})
|
||||
const name = `${d.firstName || ''} ${d.lastName || ''}`.trim() || 'Nouvelle personne'
|
||||
items.push({ entityType: 'person', action: 'create', label: name })
|
||||
}
|
||||
@@ -812,6 +844,10 @@ function App() {
|
||||
|
||||
// Panneau ajout personne
|
||||
const [addPersonPanelVisible, setAddPersonPanelVisible] = useState(false)
|
||||
// Quitter le mode édition ferme le panneau d'ajout (sinon il réapparaît au retour, vide)
|
||||
useEffect(() => {
|
||||
if (!editMode.isActive) setAddPersonPanelVisible(false)
|
||||
}, [editMode.isActive])
|
||||
const [addPersonLoading, setAddPersonLoading] = useState(false)
|
||||
const [addPersonError, setAddPersonError] = useState('')
|
||||
|
||||
@@ -835,7 +871,7 @@ function App() {
|
||||
// Un frère ou une sœur reçoit les mêmes parents que la personne choisie :
|
||||
// sans parent connu, on s'arrête avant de créer quoi que ce soit.
|
||||
const siblingParentLinks = []
|
||||
if (tree.canEditCurrentTree || isDemoMode) {
|
||||
if (Array.isArray(formData.relations)) {
|
||||
for (const rel of formData.relations) {
|
||||
if (rel.type !== 'sibling') continue
|
||||
const links = getSiblingParentLinks(rel.personId, filiations, unions)
|
||||
@@ -934,14 +970,20 @@ function App() {
|
||||
const addedPersons = [
|
||||
...(draft.addedPersons || []),
|
||||
{
|
||||
ref: `tmp:${makeLocalId('personne')}`,
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName,
|
||||
birthName: formData.birthName || null,
|
||||
birthDate: formData.birthDate || null,
|
||||
deathDate: formData.deathDate || null,
|
||||
relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId })),
|
||||
siblingParentLinks,
|
||||
},
|
||||
]
|
||||
editMode.updateDraft({ addedPersons })
|
||||
// La personne n'apparaît dans l'arbre qu'après relecture : le dire, et fermer le panneau
|
||||
setAddPersonPanelVisible(false)
|
||||
setDraftNotice(`${formData.firstName} est dans vos modifications. Envoyez-les quand vous avez fini.`)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1019,6 +1061,12 @@ function App() {
|
||||
const [contribSubmitLoading, setContribSubmitLoading] = useState(false)
|
||||
const [contribSubmitError, setContribSubmitError] = useState('')
|
||||
const [contribSuccess, setContribSuccess] = useState(false)
|
||||
const [draftNotice, setDraftNotice] = useState('')
|
||||
useEffect(() => {
|
||||
if (!draftNotice) return undefined
|
||||
const timer = setTimeout(() => setDraftNotice(''), 4500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [draftNotice])
|
||||
|
||||
// Soumission des modifications
|
||||
const handleEditSubmit = useCallback(async () => {
|
||||
@@ -1114,7 +1162,7 @@ function App() {
|
||||
}
|
||||
}, [userRole.isAdmin, auth.userAuth.token, tree.treeContext.treeId, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleContribSessionSubmit = useCallback(async ({ comment }) => {
|
||||
const handleContribSessionSubmit = useCallback(async ({ comment, name }) => {
|
||||
setContribSubmitLoading(true)
|
||||
setContribSubmitError('')
|
||||
|
||||
@@ -1129,10 +1177,16 @@ function App() {
|
||||
|
||||
const now = new Date()
|
||||
const dateStr = now.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
const userName = auth.userAuth.user?.name || 'Anonyme'
|
||||
const title = `Contribution de ${userName} · ${dateStr}`
|
||||
const contributorName = (name || auth.userAuth.user?.name || '').trim()
|
||||
if (name) saveContributorName(name)
|
||||
const title = `Contribution de ${contributorName || 'la famille'} · ${dateStr}`.slice(0, 120)
|
||||
|
||||
const result = await contrib.handleSubmitContributionSession({ title, comment, changes })
|
||||
const result = await contrib.handleSubmitContributionSession({
|
||||
title,
|
||||
comment: comment || null,
|
||||
...(contributorName ? { submittedByLabel: contributorName } : {}),
|
||||
changes,
|
||||
})
|
||||
|
||||
if (!result?.ok) {
|
||||
setContribSubmitError(result?.error || "Erreur lors de l'envoi des contributions")
|
||||
@@ -2431,13 +2485,17 @@ function App() {
|
||||
})()}
|
||||
loading={contribSubmitLoading}
|
||||
error={contribSubmitError}
|
||||
askName={!auth.userAuth.user?.name}
|
||||
onSubmit={handleContribSessionSubmit}
|
||||
onCancel={() => setShowContribModal(false)}
|
||||
/>
|
||||
{/* Toast succès contribution */}
|
||||
{draftNotice && !contribSuccess && (
|
||||
<div className="contrib-success-toast" role="status">{draftNotice}</div>
|
||||
)}
|
||||
{contribSuccess && (
|
||||
<div className="contrib-success-toast" role="status">
|
||||
Contribution envoyée — merci !
|
||||
Contribution envoyée, merci !
|
||||
</div>
|
||||
)}
|
||||
{/* Navbar contextuelle */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { API_BASE_URL } from '../api/client'
|
||||
import { persons as treePersons } from '../data/mockData'
|
||||
import { Check, ChevronDown, Copy, RefreshCw, X } from 'lucide-react'
|
||||
import './SettingsPanels.css'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
@@ -71,7 +72,39 @@ function displayVal(v) {
|
||||
return String(v)
|
||||
}
|
||||
|
||||
function getPersonDisplayName(change) {
|
||||
// Nom d'une personne citée par un lien : dans l'arbre, ou ajoutée dans la même contribution (id provisoire « tmp: »)
|
||||
function nameOfPersonRef(id, sessionChanges = []) {
|
||||
if (!id) return null
|
||||
const key = String(id)
|
||||
if (key.startsWith('tmp:')) {
|
||||
const added = sessionChanges.find((c) => c.entityType === 'person' && c.afterJson?.ref === key)
|
||||
const name = `${added?.afterJson?.firstName || ''} ${added?.afterJson?.lastName || ''}`.trim()
|
||||
return name || 'la nouvelle personne'
|
||||
}
|
||||
const person = treePersons.find((candidate) => String(candidate.id) === key)
|
||||
const name = `${person?.firstName || ''} ${person?.lastName || ''}`.trim()
|
||||
return name || null
|
||||
}
|
||||
|
||||
function getRelationLabel(change, sessionChanges) {
|
||||
const data = change.afterJson && typeof change.afterJson === 'object' ? change.afterJson : null
|
||||
if (!data) return null
|
||||
if (change.entityType === 'parent_child_link') {
|
||||
const child = nameOfPersonRef(data.childPersonId, sessionChanges)
|
||||
const parent = nameOfPersonRef(data.parentPersonId, sessionChanges)
|
||||
return child && parent ? `${child}, enfant de ${parent}` : null
|
||||
}
|
||||
if (change.entityType === 'union') {
|
||||
const a = nameOfPersonRef(data.partner1PersonId, sessionChanges)
|
||||
const b = nameOfPersonRef(data.partner2PersonId, sessionChanges)
|
||||
return a && b ? `${a} et ${b}` : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getPersonDisplayName(change, sessionChanges = []) {
|
||||
const relationLabel = getRelationLabel(change, sessionChanges)
|
||||
if (relationLabel) return relationLabel
|
||||
if (change.entityType === 'annotation') {
|
||||
const data = change.afterJson || change.after || {}
|
||||
return formatAnnotationType(data.type)
|
||||
@@ -413,7 +446,7 @@ const ContributionPanel = ({
|
||||
const decision = getChangeDecision(session.id, change.id)
|
||||
const isApproved = decision === 'approved'
|
||||
const isRejected = decision === 'rejected'
|
||||
const personName = getPersonDisplayName(change)
|
||||
const personName = getPersonDisplayName(change, session.changes || [])
|
||||
const fields = getFieldRows(change)
|
||||
const annPreview = change.entityType === 'annotation' ? getAnnotationPreview(change) : null
|
||||
const mediaPreview = change.entityType === 'media' ? getMediaPreview(change, treeId, mediaToken) : null
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { Send, X } from 'lucide-react'
|
||||
import './ContributionSessionModal.css'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
import { readContributorName } from '../utils/contributorName'
|
||||
|
||||
function formatAction(action) {
|
||||
if (action === 'create') return 'Ajout'
|
||||
@@ -23,13 +24,15 @@ function formatAction(action) {
|
||||
* @param {Function} props.onSubmit - Handler soumission ({ comment })
|
||||
* @param {Function} props.onCancel - Handler annulation
|
||||
*/
|
||||
function ContributionSessionModal({ visible, changes = [], recap = {}, loading = false, error = '', onSubmit, onCancel }) {
|
||||
function ContributionSessionModal({ visible, changes = [], recap = {}, loading = false, error = '', askName = true, onSubmit, onCancel }) {
|
||||
const [comment, setComment] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
|
||||
// Reset à l'ouverture
|
||||
// Reset à l'ouverture ; le prénom déjà donné sur cet appareil est repris
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setComment('')
|
||||
setName(readContributorName())
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
@@ -47,7 +50,8 @@ function ContributionSessionModal({ visible, changes = [], recap = {}, loading =
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
onSubmit({ comment: comment.trim() })
|
||||
if (askName && !name.trim()) return
|
||||
onSubmit({ comment: comment.trim(), name: name.trim() })
|
||||
}
|
||||
|
||||
const totalChanges = (recap.added || 0) + (recap.modified || 0) + (recap.deleted || 0)
|
||||
@@ -94,6 +98,20 @@ function ContributionSessionModal({ visible, changes = [], recap = {}, loading =
|
||||
)}
|
||||
|
||||
<form className="cs-form" onSubmit={handleSubmit}>
|
||||
{askName && (
|
||||
<div className="pz-field">
|
||||
<label htmlFor="cs-name">Votre prénom</label>
|
||||
<input
|
||||
id="cs-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value.slice(0, 120))}
|
||||
placeholder="Pour que la famille sache qui a ajouté quoi"
|
||||
autoComplete="given-name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="pz-field">
|
||||
<label htmlFor="cs-comment">
|
||||
Un mot pour accompagner <span className="cs-optional">facultatif</span>
|
||||
@@ -115,7 +133,7 @@ function ContributionSessionModal({ visible, changes = [], recap = {}, loading =
|
||||
<button type="button" className="pz-btn pz-btn--ghost" onClick={onCancel} disabled={loading}>
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" className="pz-btn pz-btn--primary" disabled={loading}>
|
||||
<button type="submit" className="pz-btn pz-btn--primary" disabled={loading || (askName && !name.trim())}>
|
||||
<Send size={16} strokeWidth={2} />
|
||||
<PzBusy busy={loading} busyLabel="Envoi en cours">Envoyer mes contributions</PzBusy>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { readContributorName } from '../utils/contributorName'
|
||||
import { ArrowDown, ArrowUp, FileText, Image, MapPin, Mic, Plus, Quote, Route, Trash2, Upload, Video, X } from 'lucide-react'
|
||||
import { getAcceptForMediaType } from '../utils/mediaUpload'
|
||||
import GeoMediaThumbnail from './GeoMediaThumbnail'
|
||||
@@ -40,6 +41,11 @@ const MediaManagerPanel = ({
|
||||
const [citationText, setCitationText] = useState('')
|
||||
const [videoMode, setVideoMode] = useState('file') // 'file' | 'youtube'
|
||||
const [youtubeUrl, setYoutubeUrl] = useState('')
|
||||
const [contributorName, setContributorName] = useState(() => readContributorName())
|
||||
// Le panneau est monté dès le départ : relire le prénom à chaque ouverture (il a pu être donné entre-temps)
|
||||
useEffect(() => {
|
||||
if (visible) setContributorName((current) => current || readContributorName())
|
||||
}, [visible])
|
||||
|
||||
const isCitation = mediaType === 'citation'
|
||||
const isYoutubeVideo = mediaType === 'video' && videoMode === 'youtube'
|
||||
@@ -48,6 +54,10 @@ const MediaManagerPanel = ({
|
||||
return false
|
||||
}
|
||||
|
||||
if (!canManage && !contributorName.trim()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isCitation) {
|
||||
return citationText.trim().length > 0
|
||||
}
|
||||
@@ -57,7 +67,7 @@ const MediaManagerPanel = ({
|
||||
}
|
||||
|
||||
return Boolean(file)
|
||||
}, [citationText, file, isCitation, isYoutubeVideo, loading, youtubeUrl])
|
||||
}, [canManage, citationText, contributorName, file, isCitation, isYoutubeVideo, loading, youtubeUrl])
|
||||
const fileAccept = useMemo(() => getAcceptForMediaType(mediaType), [mediaType])
|
||||
|
||||
if (!visible || !person) {
|
||||
@@ -77,6 +87,7 @@ const MediaManagerPanel = ({
|
||||
mediaType,
|
||||
citationText: citationText.trim(),
|
||||
youtubeUrl: isYoutubeVideo ? youtubeUrl.trim() : null,
|
||||
submittedByLabel: canManage ? null : contributorName.trim(),
|
||||
})
|
||||
setFile(null)
|
||||
setCaption('')
|
||||
@@ -228,6 +239,20 @@ const MediaManagerPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!canManage && (
|
||||
<div className="pz-field">
|
||||
<label htmlFor="mm-contributor-name">Votre prénom</label>
|
||||
<input
|
||||
id="mm-contributor-name"
|
||||
type="text"
|
||||
value={contributorName}
|
||||
onChange={(e) => setContributorName(e.target.value.slice(0, 120))}
|
||||
placeholder="Pour que la famille sache qui l'a ajouté"
|
||||
autoComplete="given-name"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && <div className="pz-error" role="alert">{errorMessage}</div>}
|
||||
{notice && !errorMessage && <div className="pz-success" role="status">{notice}</div>}
|
||||
|
||||
|
||||
@@ -175,7 +175,15 @@ function ContextualNavbar({
|
||||
icon={<Edit3 size={20} strokeWidth={2} />}
|
||||
label="Contribuer à l'arbre"
|
||||
onClick={onEditClick}
|
||||
active={editModeActive}
|
||||
/>
|
||||
{editModeActive && (
|
||||
<NavButton
|
||||
icon={<UserPlus size={20} strokeWidth={2} />}
|
||||
label="Ajouter une personne"
|
||||
onClick={onAddPersonClick}
|
||||
/>
|
||||
)}
|
||||
<NavButton
|
||||
icon={<User size={20} strokeWidth={2} />}
|
||||
label="Créer un compte"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { uploadPersonMedia, reorderPersonMedia, deletePersonMedia } from '../api
|
||||
import { getPersonMedias } from '../data/mockData'
|
||||
import { fileToBase64, getAccountErrorMessage } from '../utils/errorMessages'
|
||||
import { getUploadMimeType, resolveUploadMediaType, validateMediaFile } from '../utils/mediaUpload'
|
||||
import { saveContributorName } from '../utils/contributorName'
|
||||
|
||||
// Souvenir envoyé par la famille : en attente de la relecture du propriétaire
|
||||
function noticeFor(created) {
|
||||
@@ -57,11 +58,16 @@ export function useMediaManager({
|
||||
mediaType,
|
||||
citationText = '',
|
||||
youtubeUrl = null,
|
||||
submittedByLabel = null,
|
||||
}) => {
|
||||
if (!canUploadMedia || !selectedPerson || !treeContext.treeId || !treeContext.accessToken) {
|
||||
return
|
||||
}
|
||||
|
||||
// Prénom de la personne qui propose (famille), retenu pour la fois suivante
|
||||
const byLabel = submittedByLabel ? { submittedByLabel } : {}
|
||||
if (submittedByLabel) saveContributorName(submittedByLabel)
|
||||
|
||||
const personId = selectedPerson.id
|
||||
|
||||
// YouTube video — no file needed
|
||||
@@ -77,6 +83,7 @@ export function useMediaManager({
|
||||
youtubeUrl,
|
||||
caption: caption || null,
|
||||
source,
|
||||
...byLabel,
|
||||
},
|
||||
treeContext.accessToken,
|
||||
)
|
||||
@@ -117,6 +124,7 @@ export function useMediaManager({
|
||||
type: 'citation',
|
||||
caption: resolvedCitationText,
|
||||
source,
|
||||
...byLabel,
|
||||
},
|
||||
treeContext.accessToken,
|
||||
)
|
||||
@@ -134,6 +142,7 @@ export function useMediaManager({
|
||||
dataBase64,
|
||||
caption,
|
||||
source,
|
||||
...byLabel,
|
||||
},
|
||||
treeContext.accessToken,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Prénom de la personne qui contribue, retenu sur cet appareil pour ne pas le redemander.
|
||||
const STORAGE_KEY = 'parenthese_contributor_name'
|
||||
|
||||
export function readContributorName() {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function saveContributorName(name) {
|
||||
const value = String(name || '').trim().slice(0, 120)
|
||||
if (!value) return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, value)
|
||||
} catch {
|
||||
// stockage indisponible : le prénom sera redemandé
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user