site-mariage/scripts/seed-directus.mjs
Corentin Joguet 9559ba1216 refactor: rebrand complet Laurel & Vow -> Mostuki Photo
- fallbacks composants + directus.ts passes a Mostuki Photo / Cannes
- corrige Contact.astro (PARIS -> CANNES)
- seed/token scripts : defauts Mostuki + email corentin.jog@gmail.com
- docker-compose dev : conteneurs mostuki-*, email admin, secrets dev
- backup.sh : conteneurs par defaut mostuki-*
- package.json : name mostuki-photo + description vitrine
- .env.example : email admin
- DEPLOY.md : retrait des references obsoletes a l ancien branding

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:15:03 +02:00

353 lines
14 KiB
JavaScript

#!/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 || 'corentin.jog@gmail.com';
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: 'Mostuki Photo',
city: 'CANNES',
region: 'PACA',
country: 'FR',
coords: '43.55N 7.02E',
email: 'corentin.jog@gmail.com',
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 MOSTUKI · 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);
});