Historique des versions — frise + rédaction

Table NocoDB Versions (Type, RecordId, Snapshot, Label).
Snapshots auto avant chaque modification de fiche frise et de texte rédigé.
Archive avant suppression de fiche. Panneau latéral avec aperçu et restauration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Etienne Delvarre
2026-06-03 14:34:17 +02:00
parent e52878d599
commit 754bc47abd
2 changed files with 291 additions and 7 deletions
+121 -4
View File
@@ -18,6 +18,27 @@ const NOCODB_VISION_TABLE_ID = process.env.NOCODB_VISION_TABLE_ID || '';
const NOCODB_CONTENU_TABLE_ID = process.env.NOCODB_CONTENU_TABLE_ID || '';
const NOCODB_PERSONNAGES_TABLE_ID = process.env.NOCODB_PERSONNAGES_TABLE_ID || '';
const NOCODB_RESSOURCES_TABLE_ID = process.env.NOCODB_RESSOURCES_TABLE_ID || '';
const NOCODB_VERSIONS_TABLE_ID = process.env.NOCODB_VERSIONS_TABLE_ID || '';
// ── Version history helper ──
async function saveVersion(type, recordId, snapshot, label) {
if (!NOCODB_VERSIONS_TABLE_ID) return;
try {
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_VERSIONS_TABLE_ID}`;
await fetch(url, {
method: 'POST',
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({
Type: type,
RecordId: recordId,
Snapshot: JSON.stringify(snapshot),
Label: label || '',
}),
});
} catch (e) {
console.error('saveVersion error:', e.message);
}
}
// Auth
function makeToken(ts) {
@@ -77,10 +98,19 @@ app.post('/api/records', requireAuth, async (req, res) => {
}
});
// Proxy NocoDB — update record
// Proxy NocoDB — update record (with version snapshot)
app.patch('/api/records/:id', requireAuth, async (req, res) => {
try {
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${req.params.id}`;
const recordId = Number(req.params.id);
// Snapshot current state before update
const getUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${recordId}`;
const getR = await fetch(getUrl, { headers: { 'xc-token': NOCODB_TOKEN } });
if (getR.ok) {
const current = await getR.json();
await saveVersion('frise', recordId, current, current.Titre || '');
}
// Proceed with update
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${recordId}`;
const r = await fetch(url, {
method: 'PATCH',
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
@@ -93,10 +123,18 @@ app.patch('/api/records/:id', requireAuth, async (req, res) => {
}
});
// Proxy NocoDB — delete record
// Proxy NocoDB — delete record (archive before deletion)
app.delete('/api/records/:id', requireAuth, async (req, res) => {
try {
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${req.params.id}`;
const recordId = Number(req.params.id);
// Archive before delete
const getUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${recordId}`;
const getR = await fetch(getUrl, { headers: { 'xc-token': NOCODB_TOKEN } });
if (getR.ok) {
const current = await getR.json();
await saveVersion('frise', recordId, current, `[supprimé] ${current.Titre || ''}`);
}
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${recordId}`;
const r = await fetch(url, {
method: 'DELETE',
headers: { 'xc-token': NOCODB_TOKEN },
@@ -243,6 +281,8 @@ app.put('/api/contenu/:sceneId', requireAuth, async (req, res) => {
const payload = { SceneId: sceneId, Texte: req.body.Texte, Mots: req.body.Mots || 0 };
if (existing) {
// Snapshot before update
await saveVersion('redaction', sceneId, { Texte: existing.Texte, Mots: existing.Mots, SceneId: sceneId }, `Scène #${sceneId}`);
// Update
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}/${existing.Id}`;
const r = await fetch(url, {
@@ -268,6 +308,83 @@ app.put('/api/contenu/:sceneId', requireAuth, async (req, res) => {
}
});
// ── Versions API ──
// List versions for a record
app.get('/api/versions/:type/:recordId', requireAuth, async (req, res) => {
if (!NOCODB_VERSIONS_TABLE_ID) return res.json({ list: [] });
try {
const { type, recordId } = req.params;
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_VERSIONS_TABLE_ID}?where=(Type,eq,${type})~and(RecordId,eq,${recordId})&sort=-CreatedAt&limit=50`;
const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } });
const data = await r.json();
res.json(data);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Restore a frise version
app.post('/api/versions/:id/restore', requireAuth, async (req, res) => {
if (!NOCODB_VERSIONS_TABLE_ID) return res.status(400).json({ error: 'Versions non configurées' });
try {
// Get the version
const vUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_VERSIONS_TABLE_ID}/${req.params.id}`;
const vR = await fetch(vUrl, { headers: { 'xc-token': NOCODB_TOKEN } });
if (!vR.ok) return res.status(404).json({ error: 'Version introuvable' });
const version = await vR.json();
const snapshot = JSON.parse(version.Snapshot);
if (version.Type === 'frise') {
// Snapshot current before restoring
const getUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${version.RecordId}`;
const getR = await fetch(getUrl, { headers: { 'xc-token': NOCODB_TOKEN } });
if (getR.ok) {
const current = await getR.json();
await saveVersion('frise', version.RecordId, current, `[avant restauration] ${current.Titre || ''}`);
}
// Restore — only user-editable fields
const { Titre, Resume, Personnage, Tags, Notes, Acte, Ordre } = snapshot;
const patchUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${version.RecordId}`;
const r = await fetch(patchUrl, {
method: 'PATCH',
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ Titre, Resume, Personnage, Tags, Notes, Acte, Ordre }),
});
res.json(await r.json());
} else if (version.Type === 'redaction') {
// Snapshot current text before restoring
const findUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}?where=(SceneId,eq,${version.RecordId})&limit=1`;
const findR = await fetch(findUrl, { headers: { 'xc-token': NOCODB_TOKEN } });
const findData = await findR.json();
const existing = findData.list?.[0];
if (existing) {
await saveVersion('redaction', version.RecordId, { Texte: existing.Texte, Mots: existing.Mots, SceneId: version.RecordId }, `[avant restauration] Scène #${version.RecordId}`);
const patchUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}/${existing.Id}`;
const r = await fetch(patchUrl, {
method: 'PATCH',
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ Texte: snapshot.Texte, Mots: snapshot.Mots || 0 }),
});
res.json(await r.json());
} else {
// Re-create content row
const createUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}`;
const r = await fetch(createUrl, {
method: 'POST',
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ SceneId: version.RecordId, Texte: snapshot.Texte, Mots: snapshot.Mots || 0 }),
});
res.json(await r.json());
}
} else {
res.status(400).json({ error: 'Type inconnu' });
}
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ── Personnages API ──
function personnagesProxy(tableIdVar) {