2c170f9ef2
Bouton Archives dans la barre de filtres de la frise. Panneau latéral listant les fiches supprimées avec aperçu complet. Restauration recrée la fiche dans la frise. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
665 lines
27 KiB
JavaScript
665 lines
27 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 });
|
|
}
|
|
});
|
|
|
|
// List archived (deleted) frise cards
|
|
app.get('/api/archives', requireAuth, async (req, res) => {
|
|
if (!NOCODB_VERSIONS_TABLE_ID) return res.json({ list: [] });
|
|
try {
|
|
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_VERSIONS_TABLE_ID}?where=(Type,eq,frise)~and(Label,like,%25supprimé%25)&sort=-CreatedAt&limit=100`;
|
|
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 });
|
|
}
|
|
});
|
|
|
|
// ── 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();
|
|
}
|
|
});
|