From 9b7b5401b01e9746f1b057f7e072369873f7b878 Mon Sep 17 00:00:00 2001 From: James Pattinson Date: Sun, 21 Jun 2026 09:33:54 -0400 Subject: [PATCH] Decouple Directus from project --- .env.example | 2 + .env.worker.prod.example | 3 +- .env.worker.test.example | 3 +- README.md | 2 + bootstrap-directus.mjs | 1 - docker-compose.yml | 74 +----- docs/cloudflare-worker.md | 27 ++ scripts/bootstrap-directus-cli.mjs | 129 ---------- scripts/bootstrap-directus-cli.sh | 87 ------- scripts/bootstrap-directus-sql.mjs | 187 -------------- scripts/bootstrap-directus.mjs | 389 ----------------------------- src/lib/directus.ts | 56 ++++- 12 files changed, 83 insertions(+), 877 deletions(-) delete mode 100644 bootstrap-directus.mjs delete mode 100644 scripts/bootstrap-directus-cli.mjs delete mode 100644 scripts/bootstrap-directus-cli.sh delete mode 100644 scripts/bootstrap-directus-sql.mjs delete mode 100644 scripts/bootstrap-directus.mjs diff --git a/.env.example b/.env.example index 5416577..2f7e0b2 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,8 @@ DIRECTUS_SECRET=change-me-change-me-change-me-change-me DIRECTUS_ADMIN_EMAIL=admin@example.com DIRECTUS_ADMIN_PASSWORD=change-me DIRECTUS_ADMIN_TOKEN=change-me-static-admin-token +DIRECTUS_TOKEN= +DIRECTUS_DEBUG=false DIRECTUS_PUBLIC_URL=https://cms.example.com DIRECTUS_HOMEPAGE_BANNER_FOLDER=homepage-banners DIRECTUS_CORS_ORIGIN=https://swansea-airport.wales diff --git a/.env.worker.prod.example b/.env.worker.prod.example index 22a35e2..ee69000 100644 --- a/.env.worker.prod.example +++ b/.env.worker.prod.example @@ -9,7 +9,8 @@ DIRECTUS_PUBLIC_URL=https://cms.swansea-airport.wales # Optional for Worker test/prod builds. Leave unset locally to use Directus /assets URLs. # DIRECTUS_ASSET_BASE_URL=https://assets.swansea-airport.wales/ # DIRECTUS_ASSET_URL_TEMPLATE=https://assets.swansea-airport.wales/{id} -DIRECTUS_ADMIN_TOKEN=replace-with-production-directus-token +DIRECTUS_TOKEN=replace-with-production-directus-read-token +DIRECTUS_DEBUG=false DIRECTUS_HOMEPAGE_BANNER_FOLDER=homepage-banners CF_WORKER_NAME=swansea-airfield diff --git a/.env.worker.test.example b/.env.worker.test.example index 67e80ed..0789f92 100644 --- a/.env.worker.test.example +++ b/.env.worker.test.example @@ -9,7 +9,8 @@ DIRECTUS_PUBLIC_URL=https://egfhcmstest.pattinson.org # Optional for Worker test/prod builds. Leave unset locally to use Directus /assets URLs. # DIRECTUS_ASSET_BASE_URL=https://assets-test.example.com/ # DIRECTUS_ASSET_URL_TEMPLATE=https://assets-test.example.com/{id} -DIRECTUS_ADMIN_TOKEN=replace-with-test-directus-token +DIRECTUS_TOKEN=replace-with-test-directus-read-token +DIRECTUS_DEBUG=false DIRECTUS_HOMEPAGE_BANNER_FOLDER=homepage-banners CF_WORKER_NAME=egfh diff --git a/README.md b/README.md index 23dabd7..1536093 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ Production-ready airfield website stack built with Astro, Directus, PostgreSQL, - All deploy-time variables live in `.env`. - `PUBLIC_PPR_API_BASE` controls the browser-side PPR API base URL; PPR and drone request endpoints are derived from it at build/dev-server startup. +- `DIRECTUS_TOKEN` is the preferred read-only token for Astro content builds; `DIRECTUS_ADMIN_TOKEN` is still used by Directus/bootstrap setup. +- `DIRECTUS_DEBUG=true` makes Astro log Directus fetch/fallback reasons during builds. - `DIRECTUS_HOMEPAGE_BANNER_FOLDER` names the Directus file folder used for rotating homepage banner images. If the folder is missing or empty, the site falls back to `/images/banner.png`. - The frontend service bind-mounts the project into `/app`, keeps `node_modules` and `.astro` in named volumes, and serves the site with `astro dev` on the published frontend port. - Layout and page structure are controlled entirely by Astro. diff --git a/bootstrap-directus.mjs b/bootstrap-directus.mjs deleted file mode 100644 index 158ba04..0000000 --- a/bootstrap-directus.mjs +++ /dev/null @@ -1 +0,0 @@ -import './scripts/bootstrap-directus.mjs'; diff --git a/docker-compose.yml b/docker-compose.yml index ae531c8..b4ddab1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,70 +1,4 @@ services: - db: - image: postgres:16-alpine - restart: unless-stopped - environment: - POSTGRES_DB: ${POSTGRES_DB} - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - volumes: - - db_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 10s - timeout: 5s - retries: 5 - - directus: - image: directus/directus:11 - restart: unless-stopped - environment: - KEY: ${DIRECTUS_KEY} - SECRET: ${DIRECTUS_SECRET} - DB_CLIENT: pg - DB_HOST: ${POSTGRES_HOST} - DB_PORT: ${POSTGRES_PORT} - DB_DATABASE: ${POSTGRES_DB} - DB_USER: ${POSTGRES_USER} - DB_PASSWORD: ${POSTGRES_PASSWORD} - ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL} - ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD} - ADMIN_TOKEN: ${DIRECTUS_ADMIN_TOKEN:-dev-admin-token-change-me} - PUBLIC_URL: ${DIRECTUS_PUBLIC_URL} - CORS_ENABLED: "true" - CORS_ORIGIN: ${DIRECTUS_CORS_ORIGIN} - WEBSOCKETS_ENABLED: "false" - healthcheck: - test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:8055/server/health >/dev/null 2>&1"] - interval: 10s - timeout: 5s - retries: 10 - depends_on: - db: - condition: service_healthy - volumes: - - directus_uploads:/directus/uploads - networks: - - default - - webapps - - - directus-bootstrap: - image: node:22-alpine - restart: "no" - working_dir: /app - volumes: - - ./:/app - environment: - DIRECTUS_URL: ${DIRECTUS_URL} - DIRECTUS_ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL} - DIRECTUS_ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD} - command: ["node", "scripts/bootstrap-directus.mjs"] - depends_on: - db: - condition: service_healthy - directus: - condition: service_healthy - web: build: context: . @@ -74,14 +8,11 @@ services: PUBLIC_PPR_API_BASE: ${PUBLIC_PPR_API_BASE} DIRECTUS_URL: ${DIRECTUS_URL} DIRECTUS_PUBLIC_URL: ${DIRECTUS_PUBLIC_URL} + DIRECTUS_TOKEN: ${DIRECTUS_TOKEN:-} + DIRECTUS_DEBUG: ${DIRECTUS_DEBUG:-} DIRECTUS_HOMEPAGE_BANNER_FOLDER: ${DIRECTUS_HOMEPAGE_BANNER_FOLDER} DIRECTUS_PORT: ${DIRECTUS_PORT} DIRECTUS_ADMIN_TOKEN: ${DIRECTUS_ADMIN_TOKEN} - depends_on: - directus: - condition: service_healthy - directus-bootstrap: - condition: service_completed_successfully restart: unless-stopped volumes: - ./:/app @@ -94,7 +25,6 @@ services: volumes: db_data: - directus_uploads: web_node_modules: web_astro: diff --git a/docs/cloudflare-worker.md b/docs/cloudflare-worker.md index 31c6fde..cae0e45 100644 --- a/docs/cloudflare-worker.md +++ b/docs/cloudflare-worker.md @@ -52,6 +52,33 @@ CF_WORKER_NAME=swansea-airfield-test For Directus, use public HTTPS URLs in Worker env files. Do not use Docker-only hostnames such as `http://directus:8055` outside Docker Compose. +Use `DIRECTUS_TOKEN` for the read-only token Astro uses at build time. `DIRECTUS_ADMIN_TOKEN` is still supported for older local setups, but production/test deploy files should prefer `DIRECTUS_TOKEN`. + +To make Astro print Directus fetch diagnostics during a build, set: + +```env +DIRECTUS_DEBUG=true +``` + +This logs collection reads, folder/file reads, and the reason an image folder fell back to bundled images. + +For image-backed pages, the build token needs read access to: + +```text +news +events +event_dates +event_templates +notices +fuel_prices +documents +contacts +directus_files +directus_folders +``` + +The homepage and cafe image rotators specifically call `/folders` and `/files`, so `directus_folders` read permission is required in addition to `directus_files`. + ## Directus Assets From R2 Local development can keep using Directus asset URLs. For test and production Worker builds, set one of these optional values in `.env.worker.test` and `.env.worker.prod` to make the generated Astro HTML point at R2-hosted files instead: diff --git a/scripts/bootstrap-directus-cli.mjs b/scripts/bootstrap-directus-cli.mjs deleted file mode 100644 index 73eeaf5..0000000 --- a/scripts/bootstrap-directus-cli.mjs +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env node -// Bootstrap Directus schema using the directus CLI (schema apply) -// This approach bypasses the REST API permission issues entirely - -import fs from 'fs'; -import path from 'path'; -import { execSync } from 'child_process'; -import { fileURLToPath } from 'url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const __filename = fileURLToPath(import.meta.url); - -const DIRECTUS_URL = process.env.DIRECTUS_URL || 'http://directus:8055'; -const DIRECTUS_TOKEN = process.env.DIRECTUS_TOKEN || ''; -const DIRECTUS_ADMIN_EMAIL = process.env.DIRECTUS_ADMIN_EMAIL || 'admin@example.com'; -const DIRECTUS_ADMIN_PASSWORD = process.env.DIRECTUS_ADMIN_PASSWORD || 'change-me'; -const SNAPSHOT_PATH = path.join(__dirname, '..', 'directus', 'schema-snapshot.json'); - -console.log('========================================'); -console.log('Directus Bootstrap (CLI-based)'); -console.log('========================================'); -console.log(''); - -// Check if snapshot exists -if (!fs.existsSync(SNAPSHOT_PATH)) { - console.error(`❌ ERROR: Schema snapshot not found at ${SNAPSHOT_PATH}`); - process.exit(1); -} - -console.log(`✓ Found schema snapshot at ${SNAPSHOT_PATH}`); -console.log(''); - -// Determine authentication method -let hasToken = Boolean(DIRECTUS_TOKEN); -if (hasToken) { - console.log('✓ DIRECTUS_TOKEN is set; will use bearer token'); -} else { - console.log('⚠️ DIRECTUS_TOKEN not set; will attempt email/password login'); -} -console.log(''); - -// Wait for Directus to be fully ready -console.log('⏳ Waiting for Directus to be ready...'); -const maxRetries = 30; -let retry = 0; - -async function checkHealth() { - while (retry < maxRetries) { - try { - const response = await fetch(`${DIRECTUS_URL}/server/health`, { - timeout: 5000, - }); - if (response.ok) { - console.log('✓ Directus is ready'); - return true; - } - } catch (e) { - // Continue retrying - } - retry++; - if (retry % 5 === 0) { - process.stdout.write('.'); - } - await new Promise(r => setTimeout(r, 1000)); - } - - if (retry >= maxRetries) { - console.error(`\n❌ ERROR: Directus did not become ready after ${maxRetries} seconds`); - process.exit(1); - } - return false; -} - -async function applySchema() { - await checkHealth(); - console.log(''); - console.log('📋 Applying schema snapshot...'); - console.log(' This will create all collections, fields, and relations.'); - console.log(''); - - // Set environment variables for the directus CLI - process.env.DIRECTUS_URL = DIRECTUS_URL; - process.env.DIRECTUS_TOKEN = DIRECTUS_TOKEN; - process.env.DIRECTUS_ADMIN_EMAIL = DIRECTUS_ADMIN_EMAIL; - process.env.DIRECTUS_ADMIN_PASSWORD = DIRECTUS_ADMIN_PASSWORD; - - try { - // Execute the schema apply command using npx - // The directus CLI is available globally in the directus container - const command = `npx directus schema apply "${SNAPSHOT_PATH}" --yes`; - - console.log(`Running: ${command}`); - console.log(''); - - // Execute synchronously and capture output - const output = execSync(command, { - stdio: 'inherit', - env: process.env, - }); - - console.log(''); - console.log('✅ SUCCESS: Schema snapshot applied successfully!'); - console.log(''); - console.log('Created collections:'); - console.log(' - tags'); - console.log(' - news'); - console.log(' - events'); - console.log(' - notices'); - console.log(' - fuel_prices'); - console.log(' - documents'); - console.log(' - contacts'); - console.log(' - news_tags (M2M junction)'); - console.log(' - events_tags (M2M junction)'); - console.log(''); - console.log('All fields and relations have been initialized.'); - process.exit(0); - } catch (error) { - console.error(''); - console.error('❌ ERROR: Schema apply failed'); - console.error(error.message); - process.exit(1); - } -} - -// Run the bootstrap -applySchema().catch((error) => { - console.error('❌ Unexpected error:', error); - process.exit(1); -}); diff --git a/scripts/bootstrap-directus-cli.sh b/scripts/bootstrap-directus-cli.sh deleted file mode 100644 index 519994b..0000000 --- a/scripts/bootstrap-directus-cli.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/bin/bash -# Bootstrap Directus schema using the directus CLI (schema apply) -# This approach bypasses the REST API permission issues entirely - -set -e - -DIRECTUS_URL="${DIRECTUS_URL:-http://directus:8055}" -DIRECTUS_TOKEN="${DIRECTUS_TOKEN:-}" -DIRECTUS_ADMIN_EMAIL="${DIRECTUS_ADMIN_EMAIL:-admin@example.com}" -DIRECTUS_ADMIN_PASSWORD="${DIRECTUS_ADMIN_PASSWORD:-change-me}" -SNAPSHOT_PATH="/app/directus/schema-snapshot.json" - -echo "========================================" -echo "Directus Bootstrap (CLI-based)" -echo "========================================" - -# Check if snapshot exists -if [ ! -f "$SNAPSHOT_PATH" ]; then - echo "❌ ERROR: Schema snapshot not found at $SNAPSHOT_PATH" - exit 1 -fi - -echo "✓ Found schema snapshot at $SNAPSHOT_PATH" - -# Determine authentication method -if [ -z "$DIRECTUS_TOKEN" ]; then - echo "⚠️ DIRECTUS_TOKEN not set; will attempt email/password login" - LOGIN_METHOD="email" -else - echo "✓ DIRECTUS_TOKEN is set; will use bearer token" - LOGIN_METHOD="token" -fi - -# Wait for Directus to be fully ready -echo "⏳ Waiting for Directus to be ready..." -max_retries=30 -retry=0 -while [ $retry -lt $max_retries ]; do - if curl -s "$DIRECTUS_URL/server/health" > /dev/null 2>&1; then - echo "✓ Directus is ready" - break - fi - retry=$((retry + 1)) - if [ $retry -eq $max_retries ]; then - echo "❌ ERROR: Directus did not become ready after ${max_retries} seconds" - exit 1 - fi - sleep 1 -done - -# Apply schema snapshot using directus CLI -echo "" -echo "📋 Applying schema snapshot..." -echo " This will create all collections, fields, and relations." -echo "" - -# The directus CLI is available inside the directus container -# We'll use it via npx since directus is installed globally there -export DIRECTUS_URL -export DIRECTUS_TOKEN -export DIRECTUS_ADMIN_EMAIL -export DIRECTUS_ADMIN_PASSWORD - -# Execute the schema apply command -# Note: The CLI will prompt for missing credentials if needed -if npx directus schema apply "$SNAPSHOT_PATH" --yes 2>&1; then - echo "" - echo "✅ SUCCESS: Schema snapshot applied successfully!" - echo "" - echo "Created collections:" - echo " - tags" - echo " - news" - echo " - events" - echo " - notices" - echo " - fuel_prices" - echo " - documents" - echo " - contacts" - echo " - news_tags (M2M junction)" - echo " - events_tags (M2M junction)" - echo "" - echo "All fields and relations have been initialized." - exit 0 -else - echo "" - echo "❌ ERROR: Schema apply failed" - exit 1 -fi diff --git a/scripts/bootstrap-directus-sql.mjs b/scripts/bootstrap-directus-sql.mjs deleted file mode 100644 index 67b9d24..0000000 --- a/scripts/bootstrap-directus-sql.mjs +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env node -/** - * Bootstrap Directus schema by creating PostgreSQL tables. - * Directus will auto-discover these tables on startup and create the field metadata automatically. - */ - -import pkg from 'pg'; -const { Pool } = pkg; - -const pgConfig = { - host: process.env.POSTGRES_HOST || 'db', - port: parseInt(process.env.POSTGRES_PORT || '5432'), - user: process.env.POSTGRES_USER || 'directus', - password: process.env.POSTGRES_PASSWORD || 'change-me', - database: process.env.POSTGRES_DB || 'directus', -}; - -const pool = new Pool(pgConfig); - -console.log('========================================'); -console.log('Directus Schema Bootstrap'); -console.log('========================================'); -console.log(''); - -// Define collections and fields -const schema = { - tags: [ - { field: 'id', type: 'integer', nullable: false, default: null }, - { field: 'name', type: 'varchar(255)', nullable: false, default: null }, - { field: 'slug', type: 'varchar(255)', nullable: false, default: null }, - ], - news: [ - { field: 'id', type: 'integer', nullable: false, default: null }, - { field: 'title', type: 'varchar(255)', nullable: false, default: null }, - { field: 'slug', type: 'varchar(255)', nullable: false, default: null }, - { field: 'body', type: 'text', nullable: true, default: null }, - { field: 'summary', type: 'text', nullable: true, default: null }, - { field: 'featured_image', type: 'uuid', nullable: true, default: null }, - { field: 'status', type: 'varchar(255)', nullable: true, default: null }, - { field: 'publish_date', type: 'timestamp with time zone', nullable: true, default: null }, - { field: 'author', type: 'varchar(255)', nullable: true, default: null }, - ], - events: [ - { field: 'id', type: 'integer', nullable: false, default: null }, - { field: 'title', type: 'varchar(255)', nullable: false, default: null }, - { field: 'slug', type: 'varchar(255)', nullable: false, default: null }, - { field: 'description', type: 'text', nullable: true, default: null }, - { field: 'start_datetime', type: 'timestamp with time zone', nullable: false, default: null }, - { field: 'end_datetime', type: 'timestamp with time zone', nullable: true, default: null }, - { field: 'location_text', type: 'varchar(255)', nullable: true, default: null }, - { field: 'image', type: 'uuid', nullable: true, default: null }, - { field: 'registration_link', type: 'varchar(500)', nullable: true, default: null }, - { field: 'status', type: 'varchar(255)', nullable: true, default: null }, - { field: 'is_featured', type: 'boolean', nullable: true, default: 'false' }, - ], - notices: [ - { field: 'id', type: 'integer', nullable: false, default: null }, - { field: 'title', type: 'varchar(255)', nullable: false, default: null }, - { field: 'message', type: 'text', nullable: false, default: null }, - { field: 'severity', type: 'varchar(20)', nullable: true, default: "'info'" }, - { field: 'start_date', type: 'date', nullable: true, default: null }, - { field: 'end_date', type: 'date', nullable: true, default: null }, - { field: 'active', type: 'boolean', nullable: true, default: 'true' }, - { field: 'priority', type: 'integer', nullable: true, default: '0' }, - ], - fuel_prices: [ - { field: 'id', type: 'integer', nullable: false, default: null }, - { field: 'fuel_type', type: 'varchar(255)', nullable: false, default: null }, - { field: 'price_per_litre', type: 'numeric(10,2)', nullable: false, default: null }, - { field: 'currency', type: 'varchar(3)', nullable: true, default: "'GBP'" }, - { field: 'last_updated', type: 'timestamp with time zone', nullable: true, default: null }, - { field: 'notes', type: 'text', nullable: true, default: null }, - ], - documents: [ - { field: 'id', type: 'integer', nullable: false, default: null }, - { field: 'title', type: 'varchar(255)', nullable: false, default: null }, - { field: 'file', type: 'uuid', nullable: true, default: null }, - { field: 'category', type: 'varchar(255)', nullable: true, default: null }, - { field: 'description', type: 'text', nullable: true, default: null }, - { field: 'uploaded_at', type: 'timestamp with time zone', nullable: true, default: null }, - ], - contacts: [ - { field: 'id', type: 'integer', nullable: false, default: null }, - { field: 'name', type: 'varchar(255)', nullable: false, default: null }, - { field: 'role', type: 'varchar(255)', nullable: true, default: null }, - { field: 'email', type: 'varchar(255)', nullable: false, default: null }, - { field: 'phone', type: 'varchar(20)', nullable: true, default: null }, - { field: 'is_public', type: 'boolean', nullable: true, default: 'true' }, - { field: 'order', type: 'integer', nullable: true, default: '0' }, - ], - news_tags: [ - { field: 'id', type: 'integer', nullable: false, default: null }, - { field: 'news_id', type: 'integer', nullable: false, default: null }, - { field: 'tag_id', type: 'integer', nullable: false, default: null }, - ], - events_tags: [ - { field: 'id', type: 'integer', nullable: false, default: null }, - { field: 'event_id', type: 'integer', nullable: false, default: null }, - { field: 'tag_id', type: 'integer', nullable: false, default: null }, - ], -}; - -async function main() { - const client = await pool.connect(); - - try { - console.log('🔐 Connecting to PostgreSQL...'); - await client.query('SELECT 1'); - console.log('✓ Connected successfully'); - console.log(''); - console.log('📋 Creating tables...'); - console.log(''); - - // Create all tables - const created = []; - for (const [collectionName, fields] of Object.entries(schema)) { - const wasCreated = await createTable(client, collectionName, fields); - if (wasCreated) created.push(collectionName); - } - - console.log(''); - if (created.length > 0) { - console.log('✅ Schema bootstrap completed successfully!'); - console.log(''); - console.log(`Created ${created.length} tables:`); - created.forEach(name => console.log(` - ${name}`)); - console.log(''); - console.log('Directus will discover these tables automatically.'); - } else { - console.log('ℹ️ All tables already exist. Schema is up to date.'); - } - } catch (error) { - console.error('❌ Bootstrap failed:', error.message); - console.error(error); - process.exit(1); - } finally { - client.release(); - await pool.end(); - } -} - -async function createTable(client, collectionName, fields) { - // Check if table already exists - const checkResult = await client.query( - `SELECT to_regclass($1)`, - [`public.${collectionName}`] - ); - - if (checkResult.rows[0].to_regclass) { - console.log(` ℹ Table exists: ${collectionName}`); - return false; - } - - // Build CREATE TABLE statement - const columnDefs = fields.map(f => { - let def = `"${f.field}" ${f.type}`; - if (!f.nullable) { - def += ' NOT NULL'; - } - if (f.default) { - def += ` DEFAULT ${f.default}`; - } - return def; - }); - - // Add primary key if not already in fields - if (!fields.some(f => f.field === 'id')) { - columnDefs.unshift('"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY'); - } - - const createTableSQL = `CREATE TABLE "public"."${collectionName}" (${columnDefs.join(', ')})`; - - try { - await client.query(createTableSQL); - console.log(` ✓ Created table: ${collectionName}`); - return true; - } catch (error) { - if (error.code === '42P07') { - // Table already exists - console.log(` ℹ Table exists: ${collectionName}`); - return false; - } - throw error; - } -} - -main(); diff --git a/scripts/bootstrap-directus.mjs b/scripts/bootstrap-directus.mjs deleted file mode 100644 index 7dc274a..0000000 --- a/scripts/bootstrap-directus.mjs +++ /dev/null @@ -1,389 +0,0 @@ -#!/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); -}); diff --git a/src/lib/directus.ts b/src/lib/directus.ts index f1681d7..392d7d5 100644 --- a/src/lib/directus.ts +++ b/src/lib/directus.ts @@ -42,7 +42,8 @@ const directusAssetUrlTemplate = process.env.DIRECTUS_ASSET_URL_TEMPLATE && !process.env.DIRECTUS_ASSET_URL_TEMPLATE.includes('example.com') ? process.env.DIRECTUS_ASSET_URL_TEMPLATE : undefined; -const directusToken = process.env.DIRECTUS_ADMIN_TOKEN; +const directusToken = process.env.DIRECTUS_TOKEN ?? process.env.DIRECTUS_ADMIN_TOKEN; +const directusDebug = ['1', 'true', 'yes', 'on'].includes((process.env.DIRECTUS_DEBUG ?? '').toLowerCase()); const homepageBannerFolder = process.env.DIRECTUS_HOMEPAGE_BANNER_FOLDER ?? 'homepage-banners'; const cafePageFolder = process.env.DIRECTUS_CAFE_PAGE_FOLDER ?? 'cafe-page'; @@ -81,6 +82,15 @@ function directusHeaders(): Record | undefined { return { Authorization: `Bearer ${directusToken}` }; } +function directusLog(message: string): void { + if (!directusDebug) return; + console.warn(`[directus] ${message}`); +} + +function directusEndpointLabel(endpoint: URL): string { + return `${endpoint.pathname}${endpoint.search}`; +} + async function readCollection(collection: CollectionName): Promise { const endpoint = new URL(`/items/${collection}`, directusUrl); endpoint.searchParams.set('limit', '100'); @@ -98,27 +108,43 @@ async function readCollection(collection: CollectionName): Promise { } const payload = (await response.json()) as { data?: T[] }; - return payload.data ?? []; - } catch { + const data = payload.data ?? []; + directusLog(`collection ${collection}: read ${data.length} item(s)`); + return data; + } catch (error) { + directusLog(`collection ${collection}: using fallback after ${error instanceof Error ? error.message : String(error)}`); return fallbackFor(collection) as T[]; } } async function readDirectusEndpoint(endpoint: URL): Promise { + directusLog(`GET ${directusEndpointLabel(endpoint)}`); let response = await fetch(endpoint, { headers: directusHeaders(), }); if (response.status === 403 && directusToken) { + directusLog(`GET ${directusEndpointLabel(endpoint)} returned 403 with token; retrying without token`); response = await fetch(endpoint); } if (!response.ok) { - throw new Error(`Directus responded with ${response.status}`); + let detail = ''; + try { + const payload = (await response.json()) as { errors?: Array<{ message?: string; extensions?: { code?: string; reason?: string } }> }; + const firstError = payload.errors?.[0]; + detail = firstError?.extensions?.reason || firstError?.message || ''; + } catch { + detail = ''; + } + + throw new Error(`Directus responded with ${response.status}${detail ? `: ${detail}` : ''}`); } const payload = (await response.json()) as { data?: T[] }; - return payload.data ?? []; + const data = payload.data ?? []; + directusLog(`GET ${directusEndpointLabel(endpoint)} returned ${data.length} item(s)`); + return data; } function extensionFromFilename(filename?: string): string { @@ -177,7 +203,10 @@ async function findFolderByName(name: string): Promise { async function getImagesFromFolder(folderName: string, fallbackImages: HomepageBannerImage[]): Promise { try { const folder = await findFolderByName(folderName); - if (!folder) return fallbackImages; + if (!folder) { + directusLog(`folder "${folderName}": not found; using ${fallbackImages.length} fallback image(s)`); + return fallbackImages; + } const endpoint = new URL('/files', directusUrl); endpoint.searchParams.set('limit', '20'); @@ -192,8 +221,15 @@ async function getImagesFromFolder(folderName: string, fallbackImages: HomepageB alt: file.description || file.title || file.filename_download || 'Swansea Airport', })); - return images.length > 0 ? images : fallbackImages; - } catch { + if (images.length === 0) { + directusLog(`folder "${folderName}": no images found; using ${fallbackImages.length} fallback image(s)`); + return fallbackImages; + } + + directusLog(`folder "${folderName}": using ${images.length} Directus image(s)`); + return images; + } catch (error) { + directusLog(`folder "${folderName}": using fallback after ${error instanceof Error ? error.message : String(error)}`); return fallbackImages; } } @@ -242,8 +278,8 @@ async function getRecurringEvents(): Promise { const endpoint = new URL('/items/event_dates', directusUrl); endpoint.searchParams.set('limit', '100'); endpoint.searchParams.set('sort', 'date'); - endpoint.searchParams.set( - 'fields', + endpoint.searchParams.set( + 'fields', 'id,date,template.id,template.title,template.slug,template.description,template.image.id,template.image.filename_download,template.image.filename_disk,template.logo.id,template.logo.filename_download,template.logo.filename_disk,template.booking_url', );