fix(visites): duree de conservation d'un an tenue, comptes supprimes anonymises

La purge des visites de plus d'un an ne passait qu'a l'ouverture de l'onglet
Visites par un administrateur : un arbre jamais consulte gardait tout. Elle
passe aussi a l'enregistrement d'une visite, au plus une fois par heure, pour
tous les arbres.

A la suppression d'un compte, ses visites d'autres arbres gardaient son
identifiant dans la cle du visiteur : elles restent comptees, sous une cle
anonyme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-19 00:06:30 +02:00
1 parent 51232d8150
commit 9c84bd0efc
4 files changed
+65 -1

No files matched your search

+17 -1
View File
@@ -6,8 +6,11 @@ import { env } from '../config/env.js'
// Une ouverture d'arbre = une visite. Les rechargements du même visiteur dans cette fenêtre
// (graphe rechargé après une modification, onglet rouvert) ne comptent qu'une fois.
export const VISIT_DEDUP_WINDOW_MS = 30 * 60 * 1000
// Au-delà, les visites sont effacées (lecture de l'onglet Visites)
// Au-delà, les visites sont effacées : à l'enregistrement d'une visite (au plus une fois par heure,
// pour tous les arbres) et à la lecture de l'onglet Visites
export const VISIT_RETENTION_DAYS = 365
const PURGE_INTERVAL_MS = 60 * 60 * 1000
let lastPurgeAt = 0
export type VisitAccessKind = 'share' | 'member' | 'shared_account'
@@ -34,6 +37,18 @@ export function visitorKeyOf(treeId: string, ip: string, userAgent: string): str
return createHash('sha256').update(`${env.JWT_SECRET}|${treeId}|${ip}|${userAgent}`).digest('hex').slice(0, 32)
}
// Compte supprimé : ses visites restent comptées, sans son identifiant
export function deletedAccountVisitorKey(userId: string): string {
return createHash('sha256').update(`${env.JWT_SECRET}|deleted|${userId}`).digest('hex').slice(0, 32)
}
async function purgeExpiredVisits(app: FastifyInstance): Promise<void> {
const now = Date.now()
if (now - lastPurgeAt < PURGE_INTERVAL_MS) return
lastPurgeAt = now
await app.prisma.treeVisit.deleteMany({ where: { createdAt: { lt: new Date(now - VISIT_RETENTION_DAYS * 24 * 60 * 60 * 1000) } } })
}
// N'échoue jamais : un journal qui casse ne doit pas empêcher d'ouvrir l'arbre
export async function recordTreeVisit(
app: FastifyInstance,
@@ -69,6 +84,7 @@ export async function recordTreeVisit(
device: deviceFromUserAgent(userAgent),
},
})
await purgeExpiredVisits(app)
} catch (error) {
request.log.warn({ err: error, treeId }, 'tree_visit_record_failed')
}
+7
View File
@@ -5,6 +5,7 @@ import { z } from 'zod'
import { env } from '../config/env.js'
import { hashPassword, verifyPassword } from '../lib/auth.js'
import { revokeUserToken } from '../lib/token-revocation.js'
import { deletedAccountVisitorKey } from '../lib/tree-visits.js'
import { deleteTreeRecords, removeTreeMediaDirectories } from '../lib/tree-purge.js'
import type { AnyJwtPayload } from '../types/auth.js'
@@ -381,6 +382,12 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
data: { submittedByLabel: 'Contributeur' },
})
// Ses visites des autres arbres restent comptées, sans son identifiant
await tx.treeVisit.updateMany({
where: { visitorKey: `user:${userId}` },
data: { visitorKey: deletedAccountVisitorKey(userId) },
})
await tx.user.delete({ where: { id: userId } })
return treeIds
})
+7
View File
@@ -28,6 +28,9 @@ const { prismaMock } = vi.hoisted(() => ({
contributionSession: {
updateMany: vi.fn(),
},
treeVisit: {
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
},
$transaction: vi.fn(),
$disconnect: vi.fn(),
},
@@ -214,6 +217,10 @@ describe('DELETE /auth/me', () => {
where: { submittedByUserId: 'user-1' },
data: { submittedByLabel: 'Contributeur' },
})
// Ses visites d'autres arbres restent comptées, sans son identifiant
const visitUpdate = prismaMock.treeVisit.updateMany.mock.calls[0][0]
expect(visitUpdate.where).toEqual({ visitorKey: 'user:user-1' })
expect(visitUpdate.data.visitorKey).toMatch(/^[0-9a-f]{32}$/)
expect(prismaMock.user.delete).toHaveBeenCalledWith({ where: { id: 'user-1' } })
// Les arbres doivent partir avant l'utilisateur (relation propriétaire en Restrict).
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest'
import { recordTreeVisit, VISIT_RETENTION_DAYS } from '../src/lib/tree-visits'
function fakeApp() {
return {
prisma: {
treeVisit: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn().mockResolvedValue({ id: 'v' }),
update: vi.fn(),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
},
}
}
const request = { headers: { 'user-agent': 'Mozilla/5.0 (iPhone)' }, ip: '203.0.113.10', log: { warn: vi.fn() } }
describe('journal des visites', () => {
it('efface les visites de plus d’un an à l’enregistrement, au plus une fois par heure', async () => {
const app = fakeApp()
const before = Date.now()
await recordTreeVisit(app as never, request as never, 'tree-1', 'share', null)
await recordTreeVisit(app as never, request as never, 'tree-2', 'share', null)
expect(app.prisma.treeVisit.create).toHaveBeenCalledTimes(2)
expect(app.prisma.treeVisit.deleteMany).toHaveBeenCalledTimes(1)
const cutoff = app.prisma.treeVisit.deleteMany.mock.calls[0][0].where.createdAt.lt.getTime()
// Tous les arbres, pas seulement celui qu'on ouvre : un arbre qu'on ne rouvre plus est purgé aussi
expect(app.prisma.treeVisit.deleteMany.mock.calls[0][0].where).not.toHaveProperty('treeId')
expect(before - cutoff).toBeGreaterThanOrEqual(VISIT_RETENTION_DAYS * 24 * 60 * 60 * 1000 - 1000)
})
})