754bc47abd
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>
652 lines
26 KiB
JavaScript
652 lines
26 KiB
JavaScript
const express = require('express');
|
|
const cookieParser = require('cookie-parser');
|
|
const crypto = require('crypto');
|
|
const path = require('path');
|
|
|
|
const app = express();
|
|
app.use(express.json({ limit: '5mb' }));
|
|
app.use(cookieParser());
|
|
|
|
const PORT = process.env.PORT || 3000;
|
|
const PASSWORD = process.env.TDB_PASSWORD || 'rdb2026';
|
|
const SESSION_SECRET = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex');
|
|
const NOCODB_URL = process.env.NOCODB_URL || 'https://nocodb.hub.delvarre.net';
|
|
const NOCODB_TOKEN = process.env.NOCODB_TOKEN || '';
|
|
const NOCODB_BASE_ID = process.env.NOCODB_BASE_ID || '';
|
|
const NOCODB_TABLE_ID = process.env.NOCODB_TABLE_ID || '';
|
|
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) {
|
|
return crypto.createHmac('sha256', SESSION_SECRET).update(`tdb-${ts}`).digest('hex');
|
|
}
|
|
|
|
function isAuth(req) {
|
|
const ts = req.cookies?.tdb_ts;
|
|
const tok = req.cookies?.tdb_tok;
|
|
if (!ts || !tok) return false;
|
|
if (Date.now() - Number(ts) > 7 * 24 * 3600 * 1000) return false;
|
|
return tok === makeToken(ts);
|
|
}
|
|
|
|
// Login
|
|
app.post('/api/login', (req, res) => {
|
|
if (req.body?.password !== PASSWORD) return res.status(401).json({ error: 'Mot de passe incorrect' });
|
|
const ts = String(Date.now());
|
|
const tok = makeToken(ts);
|
|
const opts = { httpOnly: true, sameSite: 'lax', maxAge: 7 * 24 * 3600 * 1000, secure: false };
|
|
res.cookie('tdb_ts', ts, opts);
|
|
res.cookie('tdb_tok', tok, opts);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// Auth middleware for API
|
|
function requireAuth(req, res, next) {
|
|
if (!isAuth(req)) return res.status(401).json({ error: 'Non authentifié' });
|
|
next();
|
|
}
|
|
|
|
// Proxy NocoDB — list records
|
|
app.get('/api/records', requireAuth, async (req, res) => {
|
|
try {
|
|
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}?limit=200&sort=-Ordre`;
|
|
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 });
|
|
}
|
|
});
|
|
|
|
// Proxy NocoDB — create record
|
|
app.post('/api/records', requireAuth, async (req, res) => {
|
|
try {
|
|
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}`;
|
|
const r = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(req.body),
|
|
});
|
|
const data = await r.json();
|
|
res.status(r.status).json(data);
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// Proxy NocoDB — update record (with version snapshot)
|
|
app.patch('/api/records/:id', requireAuth, async (req, res) => {
|
|
try {
|
|
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' },
|
|
body: JSON.stringify(req.body),
|
|
});
|
|
const data = await r.json();
|
|
res.status(r.status).json(data);
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// Proxy NocoDB — delete record (archive before deletion)
|
|
app.delete('/api/records/:id', requireAuth, async (req, res) => {
|
|
try {
|
|
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 },
|
|
});
|
|
res.status(r.status).json({ ok: true });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// Proxy NocoDB — get column options (for dynamic selects)
|
|
app.get('/api/columns', requireAuth, async (req, res) => {
|
|
try {
|
|
const url = `${NOCODB_URL}/api/v2/meta/tables/${NOCODB_TABLE_ID}`;
|
|
const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } });
|
|
const table = await r.json();
|
|
const selectCols = {};
|
|
for (const col of table.columns || []) {
|
|
if (col.uidt === 'SingleSelect' || col.uidt === 'MultiSelect') {
|
|
selectCols[col.title] = {
|
|
type: col.uidt,
|
|
options: (col.colOptions?.options || []).map(o => o.title),
|
|
};
|
|
}
|
|
}
|
|
res.json(selectCols);
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// Add a new Personnage option
|
|
app.post('/api/columns/personnage', requireAuth, async (req, res) => {
|
|
try {
|
|
const { name } = req.body;
|
|
if (!name) return res.status(400).json({ error: 'Nom requis' });
|
|
// Get current column meta
|
|
const metaUrl = `${NOCODB_URL}/api/v2/meta/tables/${NOCODB_TABLE_ID}`;
|
|
const metaRes = await fetch(metaUrl, { headers: { 'xc-token': NOCODB_TOKEN } });
|
|
const table = await metaRes.json();
|
|
const col = table.columns.find(c => c.title === 'Personnage');
|
|
if (!col) return res.status(404).json({ error: 'Colonne Personnage introuvable' });
|
|
const options = col.colOptions?.options || [];
|
|
if (options.some(o => o.title === name)) return res.status(409).json({ error: 'Existe déjà' });
|
|
options.push({ title: name });
|
|
const patchUrl = `${NOCODB_URL}/api/v2/meta/columns/${col.id}`;
|
|
const patchRes = await fetch(patchUrl, {
|
|
method: 'PATCH',
|
|
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ colOptions: { options } }),
|
|
});
|
|
if (!patchRes.ok) return res.status(500).json({ error: 'Erreur NocoDB' });
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// Bulk update order (for drag-and-drop)
|
|
app.post('/api/reorder', requireAuth, async (req, res) => {
|
|
try {
|
|
const updates = req.body; // [{id, Acte, Ordre}, ...]
|
|
for (const u of updates) {
|
|
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${u.id}`;
|
|
await fetch(url, {
|
|
method: 'PATCH',
|
|
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ Acte: u.Acte, Ordre: u.Ordre }),
|
|
});
|
|
}
|
|
res.json({ ok: true });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// ── Vision API ──
|
|
|
|
// List all vision sections
|
|
app.get('/api/vision', requireAuth, async (req, res) => {
|
|
try {
|
|
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_VISION_TABLE_ID}?limit=50&sort=Ordre`;
|
|
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 });
|
|
}
|
|
});
|
|
|
|
// Update a vision section
|
|
app.patch('/api/vision/:id', requireAuth, async (req, res) => {
|
|
try {
|
|
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_VISION_TABLE_ID}/${req.params.id}`;
|
|
const r = await fetch(url, {
|
|
method: 'PATCH',
|
|
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(req.body),
|
|
});
|
|
const data = await r.json();
|
|
res.status(r.status).json(data);
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// ── Contenu API (scene text for Rédaction view) ──
|
|
|
|
// List all word counts (for binder display)
|
|
app.get('/api/contenu', requireAuth, async (req, res) => {
|
|
try {
|
|
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}?limit=200&fields=SceneId,Mots`;
|
|
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 });
|
|
}
|
|
});
|
|
|
|
// Get content for a scene
|
|
app.get('/api/contenu/:sceneId', requireAuth, async (req, res) => {
|
|
try {
|
|
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}?where=(SceneId,eq,${req.params.sceneId})&limit=1`;
|
|
const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } });
|
|
const data = await r.json();
|
|
const row = data.list?.[0] || null;
|
|
res.json(row);
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// Save content for a scene (create or update)
|
|
app.put('/api/contenu/:sceneId', requireAuth, async (req, res) => {
|
|
try {
|
|
const sceneId = Number(req.params.sceneId);
|
|
// Check if row exists
|
|
const findUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}?where=(SceneId,eq,${sceneId})&limit=1`;
|
|
const findR = await fetch(findUrl, { headers: { 'xc-token': NOCODB_TOKEN } });
|
|
const findData = await findR.json();
|
|
const existing = findData.list?.[0];
|
|
|
|
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, {
|
|
method: 'PATCH',
|
|
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await r.json();
|
|
res.json(data);
|
|
} else {
|
|
// Create
|
|
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}`;
|
|
const r = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await r.json();
|
|
res.status(r.status).json(data);
|
|
}
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// ── 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) {
|
|
return NOCODB_PERSONNAGES_TABLE_ID;
|
|
}
|
|
|
|
app.get('/api/personnages', requireAuth, async (req, res) => {
|
|
if (!NOCODB_PERSONNAGES_TABLE_ID) return res.json({ list: [] });
|
|
try {
|
|
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_PERSONNAGES_TABLE_ID}?limit=100&sort=Nom`;
|
|
const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } });
|
|
res.json(await r.json());
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
app.post('/api/personnages', requireAuth, async (req, res) => {
|
|
if (!NOCODB_PERSONNAGES_TABLE_ID) return res.status(400).json({ error: 'Table non configurée' });
|
|
try {
|
|
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_PERSONNAGES_TABLE_ID}`;
|
|
const r = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(req.body),
|
|
});
|
|
res.status(r.status).json(await r.json());
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
app.patch('/api/personnages/:id', requireAuth, async (req, res) => {
|
|
if (!NOCODB_PERSONNAGES_TABLE_ID) return res.status(400).json({ error: 'Table non configurée' });
|
|
try {
|
|
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_PERSONNAGES_TABLE_ID}/${req.params.id}`;
|
|
const r = await fetch(url, {
|
|
method: 'PATCH',
|
|
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(req.body),
|
|
});
|
|
res.status(r.status).json(await r.json());
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
app.delete('/api/personnages/:id', requireAuth, async (req, res) => {
|
|
if (!NOCODB_PERSONNAGES_TABLE_ID) return res.status(400).json({ error: 'Table non configurée' });
|
|
try {
|
|
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_PERSONNAGES_TABLE_ID}/${req.params.id}`;
|
|
const r = await fetch(url, { method: 'DELETE', headers: { 'xc-token': NOCODB_TOKEN } });
|
|
res.status(r.status).json({ ok: true });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// ── Ressources API ──
|
|
|
|
app.get('/api/ressources', requireAuth, async (req, res) => {
|
|
if (!NOCODB_RESSOURCES_TABLE_ID) return res.json({ list: [] });
|
|
try {
|
|
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_RESSOURCES_TABLE_ID}?limit=200&sort=-CreatedAt`;
|
|
const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } });
|
|
res.json(await r.json());
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
app.post('/api/ressources', requireAuth, async (req, res) => {
|
|
if (!NOCODB_RESSOURCES_TABLE_ID) return res.status(400).json({ error: 'Table non configurée' });
|
|
try {
|
|
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_RESSOURCES_TABLE_ID}`;
|
|
const r = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(req.body),
|
|
});
|
|
res.status(r.status).json(await r.json());
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
app.patch('/api/ressources/:id', requireAuth, async (req, res) => {
|
|
if (!NOCODB_RESSOURCES_TABLE_ID) return res.status(400).json({ error: 'Table non configurée' });
|
|
try {
|
|
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_RESSOURCES_TABLE_ID}/${req.params.id}`;
|
|
const r = await fetch(url, {
|
|
method: 'PATCH',
|
|
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(req.body),
|
|
});
|
|
res.status(r.status).json(await r.json());
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
app.delete('/api/ressources/:id', requireAuth, async (req, res) => {
|
|
if (!NOCODB_RESSOURCES_TABLE_ID) return res.status(400).json({ error: 'Table non configurée' });
|
|
try {
|
|
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_RESSOURCES_TABLE_ID}/${req.params.id}`;
|
|
const r = await fetch(url, { method: 'DELETE', headers: { 'xc-token': NOCODB_TOKEN } });
|
|
res.status(r.status).json({ ok: true });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// Ressource image upload
|
|
app.post('/api/ressource-upload', requireAuth, (req, res) => {
|
|
try {
|
|
const { dataUrl } = req.body;
|
|
if (!dataUrl || !dataUrl.startsWith('data:image/')) return res.status(400).json({ error: 'Invalid image' });
|
|
const matches = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/);
|
|
if (!matches) return res.status(400).json({ error: 'Invalid format' });
|
|
const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1];
|
|
const buffer = Buffer.from(matches[2], 'base64');
|
|
const uploadDir = path.join(__dirname, 'public', 'uploads');
|
|
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
|
const filename = `res-${Date.now()}.${ext}`;
|
|
fs.writeFileSync(path.join(uploadDir, filename), buffer);
|
|
res.json({ path: `/uploads/${filename}?t=${Date.now()}` });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// ── Hero image upload (file too large for NocoDB LongText) ──
|
|
const fs = require('fs');
|
|
|
|
app.post('/api/hero-upload', requireAuth, (req, res) => {
|
|
try {
|
|
const { dataUrl } = req.body;
|
|
if (!dataUrl || !dataUrl.startsWith('data:image/')) return res.status(400).json({ error: 'Invalid image' });
|
|
const matches = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/);
|
|
if (!matches) return res.status(400).json({ error: 'Invalid format' });
|
|
const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1];
|
|
const buffer = Buffer.from(matches[2], 'base64');
|
|
const uploadDir = path.join(__dirname, 'public', 'uploads');
|
|
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
|
const filename = `hero-${Date.now()}.${ext}`;
|
|
fs.writeFileSync(path.join(uploadDir, filename), buffer);
|
|
const publicPath = `/uploads/${filename}?t=${Date.now()}`;
|
|
res.json({ path: publicPath });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// Moodboard image upload
|
|
app.post('/api/mood-upload', requireAuth, (req, res) => {
|
|
try {
|
|
const { dataUrl, index } = req.body;
|
|
if (!dataUrl || !dataUrl.startsWith('data:image/')) return res.status(400).json({ error: 'Invalid image' });
|
|
const matches = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/);
|
|
if (!matches) return res.status(400).json({ error: 'Invalid format' });
|
|
const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1];
|
|
const buffer = Buffer.from(matches[2], 'base64');
|
|
const uploadDir = path.join(__dirname, 'public', 'uploads');
|
|
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
|
const filename = `mood-${index}-${Date.now()}.${ext}`;
|
|
fs.writeFileSync(path.join(uploadDir, filename), buffer);
|
|
res.json({ path: `/uploads/${filename}?t=${Date.now()}` });
|
|
} catch (e) {
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// Auth gate — redirect to login page if not authenticated
|
|
app.get('/', (req, res) => {
|
|
if (isAuth(req)) {
|
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
|
} else {
|
|
res.sendFile(path.join(__dirname, 'public', 'login.html'));
|
|
}
|
|
});
|
|
|
|
// Static files (login.html is public, index.html is gated above)
|
|
app.use(express.static(path.join(__dirname, 'public'), { index: false }));
|
|
|
|
// ── Startup migration: ensure Tags column exists ──
|
|
async function ensureTagsColumn() {
|
|
try {
|
|
const url = `${NOCODB_URL}/api/v2/meta/tables/${NOCODB_TABLE_ID}`;
|
|
const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } });
|
|
const table = await r.json();
|
|
const hasTagsCol = (table.columns || []).some(c => c.title === 'Tags');
|
|
if (!hasTagsCol) {
|
|
const createUrl = `${NOCODB_URL}/api/v2/meta/tables/${NOCODB_TABLE_ID}/columns`;
|
|
const cr = await fetch(createUrl, {
|
|
method: 'POST',
|
|
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ column_name: 'Tags', title: 'Tags', uidt: 'LongText' }),
|
|
});
|
|
if (cr.ok) {
|
|
console.log('Tags column created');
|
|
// Migrate existing Type values to Tags
|
|
const recUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}?limit=200&fields=Id,Type`;
|
|
const recR = await fetch(recUrl, { headers: { 'xc-token': NOCODB_TOKEN } });
|
|
const recData = await recR.json();
|
|
for (const rec of (recData.list || [])) {
|
|
if (rec.Type) {
|
|
const patchUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${rec.Id}`;
|
|
await fetch(patchUrl, {
|
|
method: 'PATCH',
|
|
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ Tags: rec.Type }),
|
|
});
|
|
}
|
|
}
|
|
console.log('Type values migrated to Tags');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Tags migration error:', e.message);
|
|
}
|
|
}
|
|
|
|
// ── Startup migration: Statut → Tags (one-time) ──
|
|
async function migrateStatutToTags() {
|
|
try {
|
|
const recUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}?limit=200&fields=Id,Statut,Tags`;
|
|
const recR = await fetch(recUrl, { headers: { 'xc-token': NOCODB_TOKEN } });
|
|
const recData = await recR.json();
|
|
let migrated = 0;
|
|
for (const rec of (recData.list || [])) {
|
|
if (rec.Statut) {
|
|
const existingTags = (rec.Tags || '').split(',').map(t => t.trim()).filter(Boolean);
|
|
if (!existingTags.includes(rec.Statut)) {
|
|
existingTags.push(rec.Statut);
|
|
}
|
|
const patchUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${rec.Id}`;
|
|
await fetch(patchUrl, {
|
|
method: 'PATCH',
|
|
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ Tags: existingTags.join(', '), Statut: null }),
|
|
});
|
|
migrated++;
|
|
}
|
|
}
|
|
if (migrated > 0) console.log(`Statut migrated to Tags for ${migrated} records`);
|
|
} catch (e) {
|
|
console.error('Statut migration error:', e.message);
|
|
}
|
|
}
|
|
|
|
// ── Startup migration: ensure Personnages columns (Origines, Backstory) ──
|
|
async function ensurePersonnagesColumns() {
|
|
if (!NOCODB_PERSONNAGES_TABLE_ID) return;
|
|
try {
|
|
const url = `${NOCODB_URL}/api/v2/meta/tables/${NOCODB_PERSONNAGES_TABLE_ID}`;
|
|
const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } });
|
|
const table = await r.json();
|
|
const existing = (table.columns || []).map(c => c.title);
|
|
for (const col of ['Origines', 'Backstory']) {
|
|
if (!existing.includes(col)) {
|
|
const createUrl = `${NOCODB_URL}/api/v2/meta/tables/${NOCODB_PERSONNAGES_TABLE_ID}/columns`;
|
|
await fetch(createUrl, {
|
|
method: 'POST',
|
|
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ column_name: col, title: col, uidt: 'LongText' }),
|
|
});
|
|
console.log(`Personnages column '${col}' created`);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Personnages columns migration error:', e.message);
|
|
}
|
|
}
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`TdB RdB — port ${PORT}`);
|
|
if (NOCODB_TOKEN) {
|
|
ensureTagsColumn();
|
|
migrateStatutToTags();
|
|
ensurePersonnagesColumns();
|
|
}
|
|
});
|