Files
egfh-website/scripts/bootstrap-directus.mjs
T
jamesp 290ff0bc1e Initial commit
Co-authored-by: Copilot <copilot@github.com>
2026-05-11 15:55:14 -04:00

390 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Idempotent Directus schema bootstrap for the Swansea airfield project.
*
* Usage (containerized):
* docker run --rm --network webdev_default \
* -v "$PWD:/app" -w /app \
* -e DIRECTUS_BASE_URL=http://directus:8055 \
* -e DIRECTUS_EMAIL=admin@example.com \
* -e DIRECTUS_PASSWORD=change-me \
* node:22-alpine node scripts/bootstrap-directus.mjs
*/
const baseUrl = (process.env.DIRECTUS_URL || process.env.DIRECTUS_BASE_URL || 'http://localhost:8055').replace(/\/$/, '');
const email = process.env.DIRECTUS_ADMIN_EMAIL || process.env.DIRECTUS_EMAIL || 'admin@example.com';
const password = process.env.DIRECTUS_ADMIN_PASSWORD || process.env.DIRECTUS_PASSWORD || 'change-me';
// Note: We intentionally ignore pre-set DIRECTUS_TOKEN because it often lacks proper
// permissions. Email/password login to get a fresh admin token is more reliable.
const collections = [
{ collection: 'tags', icon: 'sell' },
{ collection: 'news', icon: 'article' },
{ collection: 'events', icon: 'event' },
{ collection: 'notices', icon: 'campaign' },
{ collection: 'fuel_prices', icon: 'local_gas_station' },
{ collection: 'documents', icon: 'description' },
{ collection: 'contacts', icon: 'contacts' },
{ collection: 'news_tags', icon: 'link', hidden: true },
{ collection: 'events_tags', icon: 'link', hidden: true },
];
const fieldsByCollection = {
tags: [
{ field: 'name', type: 'string', required: true },
{ field: 'slug', type: 'string', required: true },
],
news: [
{ field: 'title', type: 'string', required: true },
{ field: 'slug', type: 'string', required: true },
{ field: 'body', type: 'text' },
{ field: 'summary', type: 'text' },
{ field: 'featured_image', type: 'uuid' },
{ field: 'status', type: 'string' },
{ field: 'publish_date', type: 'dateTime' },
{ field: 'author', type: 'string' },
],
events: [
{ field: 'title', type: 'string', required: true },
{ field: 'slug', type: 'string', required: true },
{ field: 'description', type: 'text' },
{ field: 'start_datetime', type: 'dateTime', required: true },
{ field: 'end_datetime', type: 'dateTime' },
{ field: 'location_text', type: 'string' },
{ field: 'image', type: 'uuid' },
{ field: 'registration_link', type: 'string' },
{ field: 'status', type: 'string' },
{ field: 'is_featured', type: 'boolean', default: false },
],
notices: [
{ field: 'title', type: 'string', required: true },
{ field: 'message', type: 'text', required: true },
{ field: 'severity', type: 'string', default: 'info' },
{ field: 'start_date', type: 'date' },
{ field: 'end_date', type: 'date' },
{ field: 'active', type: 'boolean', default: true },
{ field: 'priority', type: 'integer', default: 0 },
],
fuel_prices: [
{ field: 'fuel_type', type: 'string', required: true },
{ field: 'price_per_litre', type: 'decimal', required: true },
{ field: 'currency', type: 'string', default: 'GBP' },
{ field: 'last_updated', type: 'dateTime' },
{ field: 'notes', type: 'text' },
],
documents: [
{ field: 'title', type: 'string', required: true },
{ field: 'file', type: 'uuid' },
{ field: 'category', type: 'string' },
{ field: 'description', type: 'text' },
{ field: 'uploaded_at', type: 'dateTime' },
],
contacts: [
{ field: 'name', type: 'string', required: true },
{ field: 'role', type: 'string' },
{ field: 'email', type: 'string', required: true },
{ field: 'phone', type: 'string' },
{ field: 'is_public', type: 'boolean', default: true },
{ field: 'order', type: 'integer', default: 0 },
],
news_tags: [
{ field: 'news_id', type: 'integer', required: true },
{ field: 'tag_id', type: 'integer', required: true },
],
events_tags: [
{ field: 'event_id', type: 'integer', required: true },
{ field: 'tag_id', type: 'integer', required: true },
],
};
const typeMap = {
string: { data_type: 'varchar', max_length: 255 },
text: { data_type: 'text' },
integer: { data_type: 'integer' },
decimal: { data_type: 'decimal', numeric_precision: 10, numeric_scale: 2 },
boolean: { data_type: 'boolean' },
date: { data_type: 'date' },
dateTime: { data_type: 'timestamp with time zone' },
uuid: { data_type: 'uuid' },
};
async function main() {
try {
// Wait for Directus to be ready
console.log('========================================');
console.log('Directus Schema Bootstrap');
console.log('========================================');
console.log('');
console.log('⏳ Waiting for Directus to be ready...');
let ready = false;
let retries = 0;
const maxRetries = 30;
while (!ready && retries < maxRetries) {
try {
const health = await fetch(`${baseUrl}/server/health`);
if (health.ok) {
ready = true;
console.log('✓ Directus is ready');
}
} catch (e) {
// Continue retrying
}
if (!ready) {
retries++;
if (retries % 5 === 0) {
process.stdout.write('.');
}
await new Promise(r => setTimeout(r, 1000));
}
}
if (!ready) {
throw new Error(`Directus did not become ready after ${maxRetries} seconds`);
}
console.log('');
console.log('🔐 Authenticating with email/password login...');
const token = await login(email, password);
console.log('✓ Authentication successful');
console.log('');
console.log('📋 Creating schema...');
console.log('');
// Create all collections with their fields in one call
// This ensures both metadata and tables are created
const created = [];
const existing = [];
for (const collection of collections) {
const collectionName = collection.collection;
const fields = fieldsByCollection[collectionName] || [];
const result = await ensureCollection(token, collection, fields);
if (result.created) {
created.push(collectionName);
} else {
existing.push(collectionName);
}
}
console.log('');
if (created.length > 0) {
console.log('✅ Schema bootstrap completed successfully!');
console.log('');
console.log(`Created ${created.length} collections:`);
created.forEach(name => console.log(` - ${name}`));
if (existing.length > 0) {
console.log('');
console.log(`${existing.length} collections already exist.`);
}
} else {
console.log('️ All collections already exist. Schema is up to date.');
}
console.log('');
} catch (error) {
console.error('');
console.error('❌ Bootstrap failed:', error.message);
console.error('');
if (error.details) {
console.error('Details:', error.details);
}
process.exit(1);
}
}
async function login(userEmail, userPassword) {
const response = await fetch(`${baseUrl}/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: userEmail, password: userPassword }),
});
if (!response.ok) {
throw new Error(`Login failed with status ${response.status}`);
}
const payload = await response.json();
const token = payload?.data?.access_token;
if (!token) {
throw new Error('No access token returned from Directus login.');
}
return token;
}
async function ensureCollection(token, collection, fields = []) {
const collectionName = collection.collection;
// Build field payloads - convert to Directus field format
const fieldPayloads = fields.map(field => {
const typeConfig = typeMap[field.type];
if (!typeConfig) {
throw new Error(`Unsupported field type: ${field.type}`);
}
return {
field: field.field,
type: field.type,
meta: {
required: Boolean(field.required),
interface: defaultInterfaceForType(field.type),
},
schema: {
...typeConfig,
is_nullable: !field.required,
default_value: field.default || null,
},
};
});
try {
const result = await fetchJson(`${baseUrl}/collections`, token, 'POST', {
collection: collectionName,
meta: {
icon: collection.icon,
hidden: Boolean(collection.hidden),
singleton: false,
note: `Managed by scripts/bootstrap-directus.mjs`,
},
// Include schema to tell Directus to create the table
schema: {
name: collectionName,
},
// Include fields to create them in one call
fields: fieldPayloads,
});
console.log(`Created collection: ${collectionName}`);
return { created: true };
} catch (error) {
// Check if it's a "collection already exists" error
if (error.message && error.message.includes('already exists')) {
console.log(`Collection exists: ${collectionName}`);
return { created: false };
}
// Check if response indicates duplicate
if (error.statusCode === 409 || error.statusCode === 400) {
console.log(`Collection exists: ${collectionName}`);
return { created: false };
}
throw error;
}
}
function defaultInterfaceForType(type) {
switch (type) {
case 'text':
return 'input-multiline';
case 'boolean':
return 'boolean';
case 'date':
return 'datetime';
case 'dateTime':
return 'datetime';
case 'uuid':
return 'select-dropdown-m2o';
case 'decimal':
case 'integer':
return 'input';
default:
return 'input';
}
}
async function ensureRelation(token, relation) {
const result = await fetchJson(`${baseUrl}/relations`, token, 'POST', {
collection: relation.collection,
field: relation.field,
related_collection: relation.related_collection,
schema: {
on_delete: 'CASCADE',
},
meta: {
many_collection: relation.collection,
many_field: relation.field,
one_collection: relation.one_collection,
one_field: relation.one_field,
one_deselect_action: 'nullify',
},
}, { allowAlreadyExists: true });
if (result?.alreadyExists) {
console.log(`Relation exists: ${relation.collection}.${relation.field}`);
return;
}
console.log(`Created relation: ${relation.collection}.${relation.field} -> ${relation.related_collection}`);
}
async function fetchJson(url, token, method, body, options = {}) {
const { allowNotFound = false, allowAlreadyExists = false } = options;
const response = await fetch(url, {
method,
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
if (allowNotFound && response.status === 404) {
return null;
}
if (!response.ok) {
const text = await response.text();
if (allowAlreadyExists && isAlreadyExistsError(response.status, text)) {
return { alreadyExists: true };
}
if (response.status === 403) {
throw new Error(
`${method} ${url} failed (403): ${text}\n` +
'Bootstrap authentication is valid but lacks admin privileges. ' +
'If this database was initialized earlier, your current DIRECTUS_ADMIN_EMAIL/PASSWORD may no longer match the original admin user. ' +
'Use a stable DIRECTUS_ADMIN_TOKEN, or reset dev volumes with "docker compose down -v".'
);
}
throw new Error(`${method} ${url} failed (${response.status}): ${text}`);
}
if (response.status === 204) {
return null;
}
return response.json();
}
function isAlreadyExistsError(status, text) {
if (status !== 400 && status !== 409) {
return false;
}
const normalized = text.toLowerCase();
return (
normalized.includes('already exists') ||
normalized.includes('duplicate') ||
normalized.includes('not unique') ||
normalized.includes('record_not_unique')
);
}
main().catch((error) => {
console.error(error.message || error);
process.exit(1);
});