Decouple Directus from project
This commit is contained in:
@@ -13,6 +13,8 @@ DIRECTUS_SECRET=change-me-change-me-change-me-change-me
|
|||||||
DIRECTUS_ADMIN_EMAIL=admin@example.com
|
DIRECTUS_ADMIN_EMAIL=admin@example.com
|
||||||
DIRECTUS_ADMIN_PASSWORD=change-me
|
DIRECTUS_ADMIN_PASSWORD=change-me
|
||||||
DIRECTUS_ADMIN_TOKEN=change-me-static-admin-token
|
DIRECTUS_ADMIN_TOKEN=change-me-static-admin-token
|
||||||
|
DIRECTUS_TOKEN=
|
||||||
|
DIRECTUS_DEBUG=false
|
||||||
DIRECTUS_PUBLIC_URL=https://cms.example.com
|
DIRECTUS_PUBLIC_URL=https://cms.example.com
|
||||||
DIRECTUS_HOMEPAGE_BANNER_FOLDER=homepage-banners
|
DIRECTUS_HOMEPAGE_BANNER_FOLDER=homepage-banners
|
||||||
DIRECTUS_CORS_ORIGIN=https://swansea-airport.wales
|
DIRECTUS_CORS_ORIGIN=https://swansea-airport.wales
|
||||||
|
|||||||
@@ -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.
|
# 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_BASE_URL=https://assets.swansea-airport.wales/
|
||||||
# DIRECTUS_ASSET_URL_TEMPLATE=https://assets.swansea-airport.wales/{id}
|
# 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
|
DIRECTUS_HOMEPAGE_BANNER_FOLDER=homepage-banners
|
||||||
|
|
||||||
CF_WORKER_NAME=swansea-airfield
|
CF_WORKER_NAME=swansea-airfield
|
||||||
|
|||||||
@@ -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.
|
# 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_BASE_URL=https://assets-test.example.com/
|
||||||
# DIRECTUS_ASSET_URL_TEMPLATE=https://assets-test.example.com/{id}
|
# 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
|
DIRECTUS_HOMEPAGE_BANNER_FOLDER=homepage-banners
|
||||||
|
|
||||||
CF_WORKER_NAME=egfh
|
CF_WORKER_NAME=egfh
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ Production-ready airfield website stack built with Astro, Directus, PostgreSQL,
|
|||||||
|
|
||||||
- All deploy-time variables live in `.env`.
|
- 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.
|
- `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`.
|
- `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.
|
- 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.
|
- Layout and page structure are controlled entirely by Astro.
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
import './scripts/bootstrap-directus.mjs';
|
|
||||||
+2
-72
@@ -1,70 +1,4 @@
|
|||||||
services:
|
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:
|
web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -74,14 +8,11 @@ services:
|
|||||||
PUBLIC_PPR_API_BASE: ${PUBLIC_PPR_API_BASE}
|
PUBLIC_PPR_API_BASE: ${PUBLIC_PPR_API_BASE}
|
||||||
DIRECTUS_URL: ${DIRECTUS_URL}
|
DIRECTUS_URL: ${DIRECTUS_URL}
|
||||||
DIRECTUS_PUBLIC_URL: ${DIRECTUS_PUBLIC_URL}
|
DIRECTUS_PUBLIC_URL: ${DIRECTUS_PUBLIC_URL}
|
||||||
|
DIRECTUS_TOKEN: ${DIRECTUS_TOKEN:-}
|
||||||
|
DIRECTUS_DEBUG: ${DIRECTUS_DEBUG:-}
|
||||||
DIRECTUS_HOMEPAGE_BANNER_FOLDER: ${DIRECTUS_HOMEPAGE_BANNER_FOLDER}
|
DIRECTUS_HOMEPAGE_BANNER_FOLDER: ${DIRECTUS_HOMEPAGE_BANNER_FOLDER}
|
||||||
DIRECTUS_PORT: ${DIRECTUS_PORT}
|
DIRECTUS_PORT: ${DIRECTUS_PORT}
|
||||||
DIRECTUS_ADMIN_TOKEN: ${DIRECTUS_ADMIN_TOKEN}
|
DIRECTUS_ADMIN_TOKEN: ${DIRECTUS_ADMIN_TOKEN}
|
||||||
depends_on:
|
|
||||||
directus:
|
|
||||||
condition: service_healthy
|
|
||||||
directus-bootstrap:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/app
|
- ./:/app
|
||||||
@@ -94,7 +25,6 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
directus_uploads:
|
|
||||||
web_node_modules:
|
web_node_modules:
|
||||||
web_astro:
|
web_astro:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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:
|
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:
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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
|
|
||||||
@@ -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();
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
+44
-8
@@ -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 && !process.env.DIRECTUS_ASSET_URL_TEMPLATE.includes('example.com')
|
||||||
? process.env.DIRECTUS_ASSET_URL_TEMPLATE
|
? process.env.DIRECTUS_ASSET_URL_TEMPLATE
|
||||||
: undefined;
|
: 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 homepageBannerFolder = process.env.DIRECTUS_HOMEPAGE_BANNER_FOLDER ?? 'homepage-banners';
|
||||||
const cafePageFolder = process.env.DIRECTUS_CAFE_PAGE_FOLDER ?? 'cafe-page';
|
const cafePageFolder = process.env.DIRECTUS_CAFE_PAGE_FOLDER ?? 'cafe-page';
|
||||||
|
|
||||||
@@ -81,6 +82,15 @@ function directusHeaders(): Record<string, string> | undefined {
|
|||||||
return { Authorization: `Bearer ${directusToken}` };
|
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<T>(collection: CollectionName): Promise<T[]> {
|
async function readCollection<T>(collection: CollectionName): Promise<T[]> {
|
||||||
const endpoint = new URL(`/items/${collection}`, directusUrl);
|
const endpoint = new URL(`/items/${collection}`, directusUrl);
|
||||||
endpoint.searchParams.set('limit', '100');
|
endpoint.searchParams.set('limit', '100');
|
||||||
@@ -98,27 +108,43 @@ async function readCollection<T>(collection: CollectionName): Promise<T[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = (await response.json()) as { data?: T[] };
|
const payload = (await response.json()) as { data?: T[] };
|
||||||
return payload.data ?? [];
|
const data = payload.data ?? [];
|
||||||
} catch {
|
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[];
|
return fallbackFor(collection) as T[];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readDirectusEndpoint<T>(endpoint: URL): Promise<T[]> {
|
async function readDirectusEndpoint<T>(endpoint: URL): Promise<T[]> {
|
||||||
|
directusLog(`GET ${directusEndpointLabel(endpoint)}`);
|
||||||
let response = await fetch(endpoint, {
|
let response = await fetch(endpoint, {
|
||||||
headers: directusHeaders(),
|
headers: directusHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 403 && directusToken) {
|
if (response.status === 403 && directusToken) {
|
||||||
|
directusLog(`GET ${directusEndpointLabel(endpoint)} returned 403 with token; retrying without token`);
|
||||||
response = await fetch(endpoint);
|
response = await fetch(endpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
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[] };
|
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 {
|
function extensionFromFilename(filename?: string): string {
|
||||||
@@ -177,7 +203,10 @@ async function findFolderByName(name: string): Promise<DirectusFolder | null> {
|
|||||||
async function getImagesFromFolder(folderName: string, fallbackImages: HomepageBannerImage[]): Promise<HomepageBannerImage[]> {
|
async function getImagesFromFolder(folderName: string, fallbackImages: HomepageBannerImage[]): Promise<HomepageBannerImage[]> {
|
||||||
try {
|
try {
|
||||||
const folder = await findFolderByName(folderName);
|
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);
|
const endpoint = new URL('/files', directusUrl);
|
||||||
endpoint.searchParams.set('limit', '20');
|
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',
|
alt: file.description || file.title || file.filename_download || 'Swansea Airport',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return images.length > 0 ? images : fallbackImages;
|
if (images.length === 0) {
|
||||||
} catch {
|
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;
|
return fallbackImages;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user