#!/usr/bin/env node /** * Seed Directus : crée les 2 collections (site_settings singleton + series), * leurs fields, les permissions publiques en lecture, et pre-remplit * le singleton avec les valeurs par défaut du site. * * Idempotent : skip ce qui existe déjà. * * Usage : * node scripts/seed-directus.mjs * * Vars d'env attendues (default = valeurs du docker-compose) : * DIRECTUS_PUBLIC_URL (default http://localhost:8055) * DIRECTUS_ADMIN_EMAIL * DIRECTUS_ADMIN_PASSWORD */ import 'node:process'; const URL_BASE = process.env.DIRECTUS_PUBLIC_URL || 'http://localhost:8055'; const EMAIL = process.env.DIRECTUS_ADMIN_EMAIL || 'admin@laurelvow.fr'; const PASSWORD = process.env.DIRECTUS_ADMIN_PASSWORD || 'changeme-please'; let TOKEN = null; async function api(method, path, body) { const headers = { 'Content-Type': 'application/json' }; if (TOKEN) headers.Authorization = `Bearer ${TOKEN}`; const res = await fetch(`${URL_BASE}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, }); const text = await res.text(); let json; try { json = text ? JSON.parse(text) : null; } catch { json = text; } if (!res.ok) { const isAlreadyExists = res.status === 400 && JSON.stringify(json).includes('already exists'); if (isAlreadyExists) return { skipped: true, json }; throw new Error(`${method} ${path} → ${res.status}\n${JSON.stringify(json, null, 2)}`); } return json; } async function login() { const r = await api('POST', '/auth/login', { email: EMAIL, password: PASSWORD }); TOKEN = r.data.access_token; console.log('✓ logged in as', EMAIL); } async function ensureCollection(collection, meta = {}, schema = {}) { try { await api('GET', `/collections/${collection}`); console.log(`= collection ${collection} already exists`); return false; } catch { await api('POST', '/collections', { collection, meta, schema }); console.log(`✓ collection ${collection} created`); return true; } } async function ensureField(collection, field, type, meta = {}, schema = {}) { try { await api('GET', `/fields/${collection}/${field}`); return false; } catch { const body = { field, type, meta: { interface: 'input', ...meta }, schema }; await api('POST', `/fields/${collection}`, body); console.log(` + ${collection}.${field}`); return true; } } async function ensureRelation(collection, field, related_collection) { // Sans cet appel, l'UI Directus admin affiche le champ file vide même quand // un UUID est stocké en DB (l'API REST POST /fields ne crée pas la foreign key // par défaut). Idempotent : skip si la relation existe déjà. try { const existing = await api('GET', `/relations/${collection}/${field}`); if (existing?.data) { return false; } } catch { // 404 → relation n'existe pas, on continue } await api('POST', '/relations', { collection, field, related_collection }); console.log(` ↪ relation ${collection}.${field} → ${related_collection}`); return true; } async function ensureM2MFiles(collection, field) { // Setup d'un champ "gallery" M2M files (Directus 11 ne le fait pas auto via REST). // Crée la collection junction, ses 2 champs M2O, le champ alias, et les 2 relations. // Idempotent : skip ce qui existe déjà. const junction = `${collection}_files`; const fkOne = `${collection}_id`; const fkMany = 'directus_files_id'; // 1. Collection junction try { await api('GET', `/collections/${junction}`); } catch { await api('POST', '/collections', { collection: junction, meta: { hidden: true, icon: 'import_export' }, schema: {}, fields: [{ field: 'id', type: 'integer', meta: { hidden: true, interface: 'input', readonly: true }, schema: { is_primary_key: true, has_auto_increment: true }, }], }); console.log(` + collection ${junction}`); } // 2. Champs M2O dans la junction await ensureField(junction, fkOne, 'integer', { interface: 'select-dropdown-m2o', hidden: true }); await ensureField(junction, fkMany, 'uuid', { interface: 'file', hidden: true }); // 3. Champ alias dans la collection parente try { await api('GET', `/fields/${collection}/${field}`); } catch { await api('POST', `/fields/${collection}`, { field, type: 'alias', meta: { interface: 'files', special: ['files'], options: { enableCreate: true, enableSelect: true }, }, schema: null, }); console.log(` + ${collection}.${field} (alias files)`); } // 4. Relations M2M (idempotent par try/catch sur déjà existant) try { await api('GET', `/relations/${junction}/${fkMany}`); } catch { await api('POST', '/relations', { collection: junction, field: fkMany, related_collection: 'directus_files', meta: { junction_field: fkOne }, }); console.log(` ↪ relation ${junction}.${fkMany} → directus_files`); } try { await api('GET', `/relations/${junction}/${fkOne}`); } catch { await api('POST', '/relations', { collection: junction, field: fkOne, related_collection: collection, meta: { one_field: field, junction_field: fkMany }, }); console.log(` ↪ relation ${junction}.${fkOne} → ${collection} (M2M lié à .${field})`); } } async function ensurePublicRead(collection) { // Directus 11 : permissions sont attachees a des policies. La policy "Public" // est creee par defaut. On essaie de la trouver et de lui attacher read. try { const policies = await api('GET', `/policies?filter[name][_eq]=Public&limit=1`); const publicPolicy = policies?.data?.[0]; if (!publicPolicy) { console.log(` ! pas de policy Public trouvee, skip ${collection} (a faire via UI)`); return false; } await api('POST', '/permissions', { policy: publicPolicy.id, collection, action: 'read', fields: ['*'], }); console.log(` ✓ public read on ${collection}`); return true; } catch (e) { if (String(e.message).includes('already exists') || String(e.message).includes('RECORD_NOT_UNIQUE')) { return false; } console.log(` ! permission ${collection} : ${e.message.split('\n')[0]} (a faire via UI Settings -> Access Policies -> Public)`); return false; } } async function setSingleton(collection, values) { // Seed seulement les champs vides — on n'écrase pas les modifs déjà saisies. // Le seed sert à pré-remplir un singleton fraichement créé, pas à le reset. let current = {}; try { const r = await api('GET', `/items/${collection}`); current = r?.data || {}; } catch { // singleton vide / inexistant } const toSet = {}; for (const [k, v] of Object.entries(values)) { const cur = current[k]; const isEmpty = cur === null || cur === undefined || cur === '' || (Array.isArray(cur) && cur.length === 0); if (isEmpty) toSet[k] = v; } if (Object.keys(toSet).length === 0) { console.log(`= ${collection} déjà rempli, skip defaults`); return; } await api('PATCH', `/items/${collection}`, toSet); console.log(`✓ defaults set on ${collection} (${Object.keys(toSet).length} champ(s) vides remplis)`); } (async () => { console.log(`Seeding Directus at ${URL_BASE}\n`); await login(); // ============ site_settings (singleton) ============ console.log('\n=== site_settings (singleton) ==='); await ensureCollection('site_settings', { icon: 'tune', singleton: true, note: 'Contenu marketing du site (hero, manifesto, contact, etc.). Une seule ligne.', }); const settingsFields = [ ['studio_name', 'string', { width: 'half' }], ['city', 'string', { width: 'half' }], ['region', 'string', { width: 'half' }], ['country', 'string', { width: 'half' }], ['coords', 'string', { width: 'half', note: '"43.55N 7.02E"' }], ['email', 'string', { width: 'half', interface: 'input', validation: { _and: [{ email: { _regex: '^[^@]+@[^@]+\\.[^@]+$' } }] } }], ['est_year', 'integer', { width: 'half' }], ['current_year', 'integer', { width: 'half' }], // hero ['hero_tag', 'string'], ['hero_display_lines', 'json', { interface: 'tags', note: 'Lignes display du hero, ex: ["STUDIO PHOTO.", "CANNES / PACA."]' }], ['hero_italic_line', 'string'], ['hero_lede', 'text'], ['hero_stat', 'string', { note: 'Signature courte hero, ex: "ON OBSERVE / ON ENREGISTRE / ON DERANGE PEU"' }], ['hero_year_prefix', 'string', { width: 'half' }], ['hero_year_suffix', 'string', { width: 'half' }], // manifesto ['manifesto_tag', 'string'], ['manifesto_italic', 'string'], ['manifesto_end', 'string'], ['manifesto_paragraphs', 'json', { interface: 'list', note: 'Array of paragraph strings' }], // pricing categories (4 cards on home) ['pricing_categories', 'json', { interface: 'list', note: 'Array of {num, name, slug, startsAt, formules}' }], // contact ['contact_title', 'string'], ['contact_body', 'text'], ['contact_addr', 'string'], ]; for (const [name, type, meta = {}, schema = {}] of settingsFields) { await ensureField('site_settings', name, type, meta, schema); } await ensurePublicRead('site_settings'); await setSingleton('site_settings', { studio_name: 'Laurel & Vow', city: 'CANNES', region: 'PACA', country: 'FR', coords: '43.55N 7.02E', email: 'hello@laurelvow.fr', est_year: 2018, current_year: 2026, hero_tag: '[01] STUDIO.', hero_display_lines: ['STUDIO PHOTO.', 'CANNES / PACA.'], hero_italic_line: 'documentaire.', hero_lede: 'MARIAGE / PORTRAIT / REPORTAGE / EVENEMENTIEL. UNE METHODE EDITORIALE ET DOCUMENTAIRE QUI VAUT POUR TOUS LES TERRAINS.', hero_stat: 'ON OBSERVE · ON ENREGISTRE · ON DERANGE PEU', hero_year_prefix: '20', hero_year_suffix: '26', manifesto_tag: '[02] MANIFESTE.', manifesto_italic: 'moins de poses,', manifesto_end: 'PLUS DE REGARDS.', manifesto_paragraphs: [ "STUDIO PHOTO BASE A CANNES, COUVERTURE PACA ET PARTOUT EN FRANCE. APPROCHE DOCUMENTAIRE : ON OBSERVE, ON ENREGISTRE, ON DERANGE PEU.", "QUATRE TERRAINS, UNE METHODE : MARIAGE, PORTRAIT / LIFESTYLE, REPORTAGE / EDITORIAL, EVENEMENTIEL. LE STYLE NE CHANGE PAS AVEC LE SUJET.", ], pricing_categories: [ { num: '01', name: 'MARIAGE', slug: 'mariage', startsAt: '1 200€', formules: 'DEMI-JOURNEE / JOURNEE / SUR-MESURE' }, { num: '02', name: 'PORTRAIT / LIFESTYLE', slug: 'portrait', startsAt: '400€', formules: 'SEANCE 1H / ETENDU / SUR-MESURE' }, { num: '03', name: 'REPORTAGE / EDITORIAL', slug: 'reportage', startsAt: '500€', formules: 'DEMI-JOURNEE / JOURNEE / PROJET' }, { num: '04', name: 'EVENEMENTIEL', slug: 'evenementiel', startsAt: '700€', formules: '2H / DEMI-JOURNEE / JOURNEE' }, ], contact_title: 'ON SE PARLE ?', contact_body: "DECRIS-MOI TON PROJET EN QUELQUES LIGNES : TYPE DE SHOOT, DATE APPROXIMATIVE, LIEU, BUDGET INDICATIF. REPONSE SOUS 48H.", contact_addr: 'STUDIO LAUREL & VOW · CANNES · FR', }); // ============ series (collection) ============ console.log('\n=== series (collection) ==='); await ensureCollection('series', { icon: 'collections', note: 'Series du portfolio. Une entree par shoot.', sort_field: 'sort', }, {}); const seriesFields = [ ['status', 'string', { interface: 'select-dropdown', options: { choices: [{ text: 'Publie', value: 'published' }, { text: 'Brouillon', value: 'draft' }, { text: 'Archive', value: 'archived' }] } }, { default_value: 'published' }], ['sort', 'integer', { interface: 'input', hidden: true }], ['index', 'integer', { width: 'half' }], ['category', 'string', { width: 'half', interface: 'select-dropdown', options: { choices: [ { text: 'Mariage', value: 'mariage' }, { text: 'Portrait / Lifestyle', value: 'portrait-lifestyle' }, { text: 'Reportage / Editorial', value: 'reportage-editorial' }, { text: 'Evenementiel', value: 'evenementiel' }, ] } }], ['title', 'string'], ['lieu', 'string', { width: 'half' }], ['date_shoot', 'string', { width: 'half', note: 'Format libre ex: "Mai 2026" ou "05/2026"' }], ['cover_alt', 'string'], ['excerpt', 'text', { interface: 'input-multiline' }], ['cover', 'uuid', { width: 'half', interface: 'file-image', special: ['file'] }], // mariage-specific (optional) ['couple', 'string', { width: 'half', note: 'Mariage uniquement' }], ['invites', 'integer', { width: 'half', note: 'Mariage uniquement' }], ['formule', 'string', { width: 'half', interface: 'select-dropdown', options: { choices: [ { text: 'Demi-journee', value: 'demi-journee' }, { text: 'Journee complete', value: 'journee-complete' }, { text: 'Sur-mesure', value: 'sur-mesure' }, ] }, note: 'Mariage uniquement' }], // generic (optional) ['client', 'string', { width: 'half', note: 'Reportage / Evenementiel' }], ['duree', 'string', { width: 'half', note: 'Ex: "2h", "Journee"' }], ['published', 'boolean', { width: 'half', interface: 'boolean' }, { default_value: true }], ]; for (const [name, type, meta = {}, schema = {}] of seriesFields) { await ensureField('series', name, type, meta, schema); } await ensureRelation('series', 'cover', 'directus_files'); await ensureM2MFiles('series', 'gallery'); await ensurePublicRead('series'); await ensurePublicRead('directus_files'); console.log('\n=== Done ==='); console.log(`\n→ Open ${URL_BASE} and login with ${EMAIL}`); console.log(`→ site_settings has been pre-filled with defaults`); console.log(`→ series collection is empty — add entries via the UI`); })().catch((e) => { console.error('\nFAILED:', e.message); process.exit(1); });