Build Phase 1 application foundation

This commit is contained in:
Media
2026-08-16 19:16:45 -05:00
parent 9aab48643b
commit eb19675a5c
30 changed files with 3375 additions and 2207 deletions
+17
View File
@@ -0,0 +1,17 @@
.git
.gitignore
.agents
.codex
.env
.env.*
!.env.example
**/node_modules
**/dist
**/*.log
**/*.sqlite
**/*.sqlite-shm
**/*.sqlite-wal
**/*.sqlite-journal
data
backups
client/pnpm-lock.yaml
+34
View File
@@ -0,0 +1,34 @@
# A distinct project name and host port let v1 and v2 run side by side.
COMPOSE_PROJECT_NAME=announcements-v2
WEB_PORT=5175
# Docker Compose pins these internal values. They are shown here to document the
# standalone API configuration contract; do not uncomment them for normal Compose use.
# NODE_ENV=production
# HOST=0.0.0.0
# PORT=7532
# DATABASE_PATH=/data/db/announcements.sqlite
# S3_ENDPOINT=http://seaweedfs:8333
# S3_FORCE_PATH_STYLE=true
# Optional local Vite development proxy target (not used by Compose).
# VITE_API_PROXY_TARGET=http://127.0.0.1:7532
# Application configuration exposed by Compose
APP_TIMEZONE=America/Chicago
SHUTDOWN_TIMEOUT_MS=10000
DATABASE_BUSY_TIMEOUT_MS=5000
TRUST_PROXY=true
# Private SeaweedFS S3 configuration
S3_REGION=us-east-1
S3_BUCKET=announcements-v2
S3_ACCESS_KEY_ID=replace-with-a-random-access-key
S3_SECRET_ACCESS_KEY=replace-with-a-long-random-secret
# Optional integrations. Leave blank to disable them during Phase 1.
MQTT_URL=
MQTT_USERNAME=
MQTT_PASSWORD=
HA_WEBHOOK_URL=
HA_WEBHOOK_SECRET=
+15 -1
View File
@@ -1 +1,15 @@
node_modules
node_modules/
# Local configuration and secrets
.env
.env.*
!.env.example
# Local build and runtime data
dist/
*.sqlite
*.sqlite-shm
*.sqlite-wal
*.sqlite-journal
data/
backups/
+18 -8
View File
@@ -1,14 +1,24 @@
FROM node:22-alpine AS build
FROM node:22.23.2-bookworm-slim AS build
ARG PNPM_VERSION=10.29.3
WORKDIR /app
RUN corepack enable
RUN corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY server/package.json ./server/package.json
COPY client/package.json ./client/package.json
COPY . .
RUN pnpm build
RUN pnpm install --frozen-lockfile --filter @announcements/client...
COPY client ./client
RUN pnpm --filter @announcements/client build
FROM nginx:1.30.4-alpine
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/client/dist /usr/share/nginx/html
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+42
View File
@@ -0,0 +1,42 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
resolver 127.0.0.11 valid=10s ipv6=off;
set $api_origin http://api:7532;
location ~ ^/(api|health|media)(/|$) {
proxy_pass $api_origin;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /socket.io/ {
proxy_pass $api_origin;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
proxy_read_timeout 75s;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+4 -1
View File
@@ -1,8 +1,11 @@
{
"name": "client",
"name": "@announcements/client",
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
-1572
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { io } from "socket.io-client";
import "./App.css";
const socket = io("http://10.40.0.2:7532");
const socket = io();
export default function App() {
const [slides, setSlides] = useState([]);
+19 -6
View File
@@ -1,7 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
export default defineConfig(({ mode }) => {
const environment = loadEnv(mode, ".", "VITE_");
const target = environment.VITE_API_PROXY_TARGET || "http://127.0.0.1:7532";
const proxy = { target, changeOrigin: true };
return {
plugins: [react()],
server: {
proxy: {
"/api": proxy,
"/health": proxy,
"/media": proxy,
"/socket.io": { ...proxy, ws: true },
},
},
};
});
+97 -14
View File
@@ -1,20 +1,103 @@
name: "${COMPOSE_PROJECT_NAME:-announcements-v2}"
services:
announcements-server:
web:
build:
context: .
dockerfile: client/Dockerfile
restart: unless-stopped
init: true
ports:
- "${WEB_PORT:-5175}:80"
depends_on:
api:
condition: service_healthy
networks:
- app
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1/"]
interval: 15s
timeout: 5s
retries: 3
start_period: 10s
api:
build:
context: .
dockerfile: server/Dockerfile
container_name: announcements-server
restart: unless-stopped
ports:
- "7532:7532"
announcements-client:
build:
context: ./client
dockerfile: Dockerfile
container_name: announcements-client
restart: unless-stopped
ports:
- "5174:80"
init: true
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: "7532"
DATABASE_PATH: /data/db/announcements.sqlite
APP_TIMEZONE: "${APP_TIMEZONE:-America/Chicago}"
SHUTDOWN_TIMEOUT_MS: "${SHUTDOWN_TIMEOUT_MS:-10000}"
DATABASE_BUSY_TIMEOUT_MS: "${DATABASE_BUSY_TIMEOUT_MS:-5000}"
TRUST_PROXY: "${TRUST_PROXY:-true}"
S3_ENDPOINT: http://seaweedfs:8333
S3_REGION: "${S3_REGION:-us-east-1}"
S3_BUCKET: "${S3_BUCKET:-announcements-v2}"
S3_ACCESS_KEY_ID: "${S3_ACCESS_KEY_ID:?Set S3_ACCESS_KEY_ID in .env}"
S3_SECRET_ACCESS_KEY: "${S3_SECRET_ACCESS_KEY:?Set S3_SECRET_ACCESS_KEY in .env}"
S3_FORCE_PATH_STYLE: "true"
MQTT_URL: "${MQTT_URL:-}"
MQTT_USERNAME: "${MQTT_USERNAME:-}"
MQTT_PASSWORD: "${MQTT_PASSWORD:-}"
HA_WEBHOOK_URL: "${HA_WEBHOOK_URL:-}"
HA_WEBHOOK_SECRET: "${HA_WEBHOOK_SECRET:-}"
volumes:
- app-data:/data
depends_on:
- announcements-server
seaweedfs:
condition: service_started
networks:
- app
- storage
healthcheck:
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:7532/health/ready').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"
interval: 15s
timeout: 5s
retries: 5
start_period: 15s
stop_grace_period: 15s
seaweedfs:
image: chrislusf/seaweedfs:4.39
restart: unless-stopped
command: ["mini", "-dir=/data", "-ip=seaweedfs"]
environment:
AWS_ACCESS_KEY_ID: "${S3_ACCESS_KEY_ID:?Set S3_ACCESS_KEY_ID in .env}"
AWS_SECRET_ACCESS_KEY: "${S3_SECRET_ACCESS_KEY:?Set S3_SECRET_ACCESS_KEY in .env}"
S3_BUCKET: "${S3_BUCKET:-announcements-v2}"
volumes:
- seaweedfs-data:/data
expose:
- "8333"
networks:
- storage
healthcheck:
test:
- CMD-SHELL
- >-
wget -q -O /dev/null http://127.0.0.1:9333/readyz &&
wget -q -O /dev/null http://127.0.0.1:8333/readyz
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
stop_grace_period: 30s
networks:
app:
storage:
internal: true
volumes:
app-data:
seaweedfs-data:
+14 -19
View File
@@ -1,24 +1,19 @@
{
"name": "Annoucements",
"version": "1.0.0",
"description": "",
"main": "index.js",
"name": "announcements",
"version": "2.0.0",
"private": true,
"description": "Church announcement management and signage",
"scripts": {
"dev": "nodemon server/index.js",
"start": "node server/index.js"
"dev:server": "pnpm --filter @announcements/server dev",
"dev:client": "pnpm --filter @announcements/client dev",
"start": "pnpm --filter @announcements/server start",
"build": "pnpm --filter @announcements/client build",
"lint": "pnpm --filter @announcements/client lint",
"test": "pnpm --filter @announcements/server test",
"check": "pnpm lint && pnpm test && pnpm build"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "[email protected]",
"dependencies": {
"cors": "^2.8.6",
"express": "^5.2.1",
"fast-xml-parser": "^5.8.0",
"mqtt": "^5.15.1",
"socket.io": "^4.8.3"
"engines": {
"node": ">=22.13.0"
},
"devDependencies": {
"nodemon": "^3.1.14"
}
"packageManager": "[email protected]"
}
+1366 -447
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
packages:
- client
- server
onlyBuiltDependencies:
- better-sqlite3
+24 -5
View File
@@ -1,13 +1,32 @@
FROM node:22-alpine
FROM node:22.23.2-bookworm-slim AS dependencies
ARG PNPM_VERSION=10.29.3
WORKDIR /app
RUN corepack enable
RUN apt-get update \
&& apt-get install --yes --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
RUN corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY server/package.json ./server/package.json
COPY client/package.json ./client/package.json
RUN pnpm install --frozen-lockfile --prod --filter @announcements/server...
COPY server ./server
FROM node:22.23.2-bookworm-slim
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY --from=dependencies /app/server ./server
ENV NODE_ENV=production
EXPOSE 7532
CMD ["node", "server/index.js"]
CMD ["node", "server/src/index.js"]
-131
View File
@@ -1,131 +0,0 @@
const express = require("express");
const http = require("http");
const { Server } = require("socket.io");
const cors = require("cors");
const app = express();
app.use(cors());
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: "*",
},
});
const PORT = 7532;
let currentSlide = 0;
const slideSeconds = 7;
const { XMLParser } = require("fast-xml-parser");
let slides = [];
async function loadSlides() {
try {
const response = await fetch(
"http://10.40.0.2:9000/announcements?list-type=2"
);
const xml = await response.text();
const parser = new XMLParser();
const data = parser.parse(xml);
const contents = data.ListBucketResult?.Contents || [];
const files = Array.isArray(contents)
? contents
: [contents];
slides = files
.map((file) => file.Key)
.filter((name) =>
/\.(png|jpg|jpeg|webp)$/i.test(name)
)
.map((name) => {
const encodedName = name
.split("/")
.map((part) => encodeURIComponent(part))
.join("/");
return `http://10.40.0.2:9000/announcements/${encodedName}`;
});
console.log("Slides loaded:", slides.length);
if (currentSlide >= slides.length) {
currentSlide = 0;
}
broadcastState();
} catch (err) {
console.error("Failed to load slides:", err);
}
}
const mqtt = require("mqtt");
let mode = "on";
const mqttClient = mqtt.connect("mqtt://10.40.0.2");
mqttClient.on("connect", () => {
console.log("MQTT connected");
mqttClient.subscribe("announcements/mode");
});
mqttClient.on("message", (topic, message) => {
if (topic === "announcements/mode") {
const value = message.toString().trim().toLowerCase();
if (value === "on" || value === "off") {
mode = value;
broadcastState();
console.log("Announcement mode:", mode);
}
}
});
function broadcastState() {
io.emit("slideshow:state", {
currentSlide,
slides,
slideSeconds,
mode,
});
}
io.on("connection", (socket) => {
console.log("Client connected:", socket.id);
socket.emit("slideshow:state", {
currentSlide,
slides,
slideSeconds,
mode,
});
});
setInterval(() => {
if (slides.length === 0) return;
currentSlide = (currentSlide + 1) % slides.length;
broadcastState();
}, slideSeconds * 1000);
app.get("/", (req, res) => {
res.send("Announcement server is running");
});
loadSlides();
setInterval(() => {
loadSlides();
}, 30000);
server.listen(PORT, () => {
console.log(`Announcement server running on http://10.40.0.2:${PORT}`);
});
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@announcements/server",
"version": "2.0.0",
"private": true,
"description": "Announcements v2 API and realtime server",
"main": "src/index.js",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --test"
},
"engines": {
"node": ">=22.13.0"
},
"dependencies": {
"better-sqlite3": "13.0.2",
"dotenv": "^16.5.0",
"express": "^5.2.1",
"socket.io": "^4.8.3"
}
}
+81
View File
@@ -0,0 +1,81 @@
const express = require("express");
const {
createDetailedHealthRouter,
createHealthRouter,
} = require("./health/routes");
const { createSettingsRouter } = require("./settings/routes");
const { createSettingsService } = require("./settings/service");
function createApp({ config, database, lifecycle, migrations }) {
const app = express();
const settingsService = createSettingsService(database);
app.disable("x-powered-by");
app.set("trust proxy", config.trustProxy);
app.use(express.json({ limit: "1mb" }));
app.get("/api", (request, response) => {
response.json({ name: "Announcements API", version: 2 });
});
app.use(
"/health",
createHealthRouter({ database, lifecycle, migrations }),
);
const detailedHealthRouterOptions = {
config,
database,
lifecycle,
migrations,
};
app.use(
"/api/health",
createDetailedHealthRouter(detailedHealthRouterOptions),
);
app.use(
"/api/status",
createDetailedHealthRouter(detailedHealthRouterOptions),
);
app.use("/api/settings", createSettingsRouter(settingsService));
app.use((request, response) => {
response.status(404).json({
error: {
code: "NOT_FOUND",
message: "Route not found",
},
});
});
app.use((error, request, response, next) => {
if (response.headersSent) {
next(error);
return;
}
if (error instanceof SyntaxError && error.status === 400 && "body" in error) {
response.status(400).json({
error: {
code: "INVALID_JSON",
message: "Request body must contain valid JSON",
},
});
return;
}
const statusCode = error.statusCode || 500;
response.status(statusCode).json({
error: {
code: error.code || "INTERNAL_ERROR",
message:
statusCode >= 500 ? "An internal server error occurred" : error.message,
...(error.details ? { details: error.details } : {}),
},
});
});
return app;
}
module.exports = {
createApp,
};
+210
View File
@@ -0,0 +1,210 @@
const path = require("node:path");
class ConfigError extends Error {
constructor(message, field) {
super(message);
this.name = "ConfigError";
this.field = field;
}
}
function requiredString(env, name) {
const value = env[name]?.trim();
if (!value) {
throw new ConfigError(`${name} is required`, name);
}
return value;
}
function optionalString(env, name) {
const value = env[name]?.trim();
return value || null;
}
function integer(env, name, defaultValue, { min, max } = {}) {
const raw = env[name];
if (raw === undefined || raw === "") {
return defaultValue;
}
if (!/^-?\d+$/.test(raw)) {
throw new ConfigError(`${name} must be an integer`, name);
}
const value = Number(raw);
if (!Number.isSafeInteger(value)) {
throw new ConfigError(`${name} must be a safe integer`, name);
}
if (min !== undefined && value < min) {
throw new ConfigError(`${name} must be at least ${min}`, name);
}
if (max !== undefined && value > max) {
throw new ConfigError(`${name} must be at most ${max}`, name);
}
return value;
}
function boolean(env, name, defaultValue) {
const raw = env[name];
if (raw === undefined || raw === "") {
return defaultValue;
}
const normalized = raw.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(normalized)) {
return true;
}
if (["false", "0", "no", "off"].includes(normalized)) {
return false;
}
throw new ConfigError(`${name} must be a boolean`, name);
}
function url(env, name, { required = false, protocols } = {}) {
const raw = required ? requiredString(env, name) : optionalString(env, name);
if (raw === null) {
return null;
}
let parsed;
try {
parsed = new URL(raw);
} catch {
throw new ConfigError(`${name} must be a valid URL`, name);
}
if (protocols && !protocols.includes(parsed.protocol)) {
throw new ConfigError(
`${name} must use one of these protocols: ${protocols.join(", ")}`,
name,
);
}
return parsed.toString().replace(/\/$/, "");
}
function bucketName(env) {
const value = requiredString(env, "S3_BUCKET");
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(value)) {
throw new ConfigError(
"S3_BUCKET must be a valid lowercase S3 bucket name",
"S3_BUCKET",
);
}
return value;
}
function nodeEnvironment(env) {
const value = env.NODE_ENV?.trim() || "development";
if (!["development", "test", "production"].includes(value)) {
throw new ConfigError(
"NODE_ENV must be development, test, or production",
"NODE_ENV",
);
}
return value;
}
function timeZone(env) {
const value = requiredString(env, "APP_TIMEZONE");
try {
new Intl.DateTimeFormat("en-US", { timeZone: value }).format();
} catch {
throw new ConfigError("APP_TIMEZONE must be a valid IANA time zone", "APP_TIMEZONE");
}
return value;
}
function storageCredential(env, name, environment) {
const value = optionalString(env, name);
if (environment === "production" && value === null) {
throw new ConfigError(`${name} is required in production`, name);
}
return value;
}
function loadConfig(env = process.env) {
const environment = nodeEnvironment(env);
const databasePath = requiredString(env, "DATABASE_PATH");
if (databasePath !== ":memory:" && !path.isAbsolute(databasePath)) {
throw new ConfigError("DATABASE_PATH must be an absolute path", "DATABASE_PATH");
}
return Object.freeze({
environment,
host: env.HOST?.trim() || "0.0.0.0",
port: integer(env, "PORT", 7532, { min: 0, max: 65535 }),
timeZone: timeZone(env),
shutdownTimeoutMs: integer(env, "SHUTDOWN_TIMEOUT_MS", 10_000, {
min: 1_000,
max: 60_000,
}),
trustProxy: boolean(env, "TRUST_PROXY", true),
database: Object.freeze({
path: databasePath,
busyTimeoutMs: integer(env, "DATABASE_BUSY_TIMEOUT_MS", 5_000, {
min: 0,
max: 60_000,
}),
}),
storage: Object.freeze({
endpoint: url(env, "S3_ENDPOINT", {
required: true,
protocols: ["http:", "https:"],
}),
region: env.S3_REGION?.trim() || "us-east-1",
bucket: bucketName(env),
accessKeyId: storageCredential(env, "S3_ACCESS_KEY_ID", environment),
secretAccessKey: storageCredential(env, "S3_SECRET_ACCESS_KEY", environment),
forcePathStyle: boolean(env, "S3_FORCE_PATH_STYLE", true),
}),
mqtt: Object.freeze({
url: url(env, "MQTT_URL", {
protocols: ["mqtt:", "mqtts:", "ws:", "wss:"],
}),
username: optionalString(env, "MQTT_USERNAME"),
password: optionalString(env, "MQTT_PASSWORD"),
}),
notifications: Object.freeze({
homeAssistantWebhookUrl: url(env, "HA_WEBHOOK_URL", {
protocols: ["http:", "https:"],
}),
homeAssistantWebhookSecret: optionalString(env, "HA_WEBHOOK_SECRET"),
}),
});
}
function redactedConfig(config) {
return {
environment: config.environment,
host: config.host,
port: config.port,
timeZone: config.timeZone,
trustProxy: config.trustProxy,
database: {
configured: Boolean(config.database.path),
},
storage: {
configured: Boolean(
config.storage.endpoint &&
config.storage.bucket &&
config.storage.accessKeyId &&
config.storage.secretAccessKey,
),
region: config.storage.region,
bucket: config.storage.bucket,
forcePathStyle: config.storage.forcePathStyle,
},
mqtt: {
configured: Boolean(config.mqtt.url),
},
notifications: {
configured: Boolean(config.notifications.homeAssistantWebhookUrl),
},
};
}
module.exports = {
ConfigError,
loadConfig,
redactedConfig,
};
+32
View File
@@ -0,0 +1,32 @@
const fs = require("node:fs");
const path = require("node:path");
const Database = require("better-sqlite3");
function openDatabase({ path: filename, busyTimeoutMs = 5_000 }) {
if (filename !== ":memory:") {
fs.mkdirSync(path.dirname(filename), { recursive: true });
}
const database = new Database(filename, {
timeout: busyTimeoutMs,
});
database.pragma("foreign_keys = ON");
database.pragma(`busy_timeout = ${busyTimeoutMs}`);
if (filename !== ":memory:") {
database.pragma("journal_mode = WAL");
database.pragma("synchronous = NORMAL");
}
return database;
}
function checkDatabase(database) {
database.prepare("SELECT 1 AS ok").get();
return true;
}
module.exports = {
checkDatabase,
openDatabase,
};
+122
View File
@@ -0,0 +1,122 @@
const fs = require("node:fs");
const path = require("node:path");
const DEFAULT_MIGRATIONS_DIRECTORY = path.join(__dirname, "migrations");
const MIGRATION_FILENAME = /^(\d+)_([a-z0-9_-]+)\.sql$/i;
function discoverMigrations(directory = DEFAULT_MIGRATIONS_DIRECTORY) {
const migrations = fs
.readdirSync(directory, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith(".sql"))
.map((entry) => {
const match = MIGRATION_FILENAME.exec(entry.name);
if (!match) {
throw new Error(`Invalid migration filename: ${entry.name}`);
}
return {
version: Number(match[1]),
name: match[2],
filename: entry.name,
sql: fs.readFileSync(path.join(directory, entry.name), "utf8"),
};
})
.sort((left, right) => left.version - right.version);
const versions = new Set();
for (const migration of migrations) {
if (!Number.isSafeInteger(migration.version) || migration.version < 1) {
throw new Error(`Invalid migration version: ${migration.filename}`);
}
if (versions.has(migration.version)) {
throw new Error(`Duplicate migration version: ${migration.version}`);
}
versions.add(migration.version);
}
return migrations;
}
function ensureMigrationsTable(database) {
database.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at INTEGER NOT NULL
)
`);
}
function readAppliedMigrations(database) {
return database
.prepare(
`SELECT version, name, applied_at AS appliedAt
FROM schema_migrations
ORDER BY version`,
)
.all();
}
function migrationStatus(database, directory = DEFAULT_MIGRATIONS_DIRECTORY) {
ensureMigrationsTable(database);
const available = discoverMigrations(directory);
const applied = readAppliedMigrations(database);
const appliedVersions = new Set(applied.map((migration) => migration.version));
return {
currentVersion: applied.at(-1)?.version ?? 0,
latestAvailableVersion: available.at(-1)?.version ?? 0,
pendingVersions: available
.filter((migration) => !appliedVersions.has(migration.version))
.map((migration) => migration.version),
applied,
};
}
function runMigrations(database, directory = DEFAULT_MIGRATIONS_DIRECTORY) {
ensureMigrationsTable(database);
const migrations = discoverMigrations(directory);
const applied = new Map(
readAppliedMigrations(database).map((migration) => [
migration.version,
migration.name,
]),
);
const availableVersions = new Set(migrations.map((migration) => migration.version));
for (const [version] of applied) {
if (!availableVersions.has(version)) {
throw new Error(`Database contains unknown migration version ${version}`);
}
}
const insertMigration = database.prepare(
`INSERT INTO schema_migrations (version, name, applied_at)
VALUES (?, ?, ?)`,
);
const applyMigration = database.transaction((migration) => {
database.exec(migration.sql);
insertMigration.run(migration.version, migration.name, Date.now());
});
for (const migration of migrations) {
const appliedName = applied.get(migration.version);
if (appliedName !== undefined) {
if (appliedName !== migration.name) {
throw new Error(
`Migration ${migration.version} was applied as ${appliedName}, not ${migration.name}`,
);
}
continue;
}
applyMigration(migration);
}
return migrationStatus(database, directory);
}
module.exports = {
DEFAULT_MIGRATIONS_DIRECTORY,
discoverMigrations,
migrationStatus,
runMigrations,
};
@@ -0,0 +1,214 @@
CREATE TABLE assets (
id TEXT PRIMARY KEY,
source_sha256 TEXT NOT NULL UNIQUE
CHECK (length(source_sha256) = 64),
display_name TEXT NOT NULL COLLATE NOCASE UNIQUE
CHECK (length(trim(display_name)) > 0),
original_filename TEXT NOT NULL
CHECK (length(trim(original_filename)) > 0),
status TEXT NOT NULL
CHECK (status IN ('processing', 'ready', 'failed', 'deleting')),
processing_error TEXT,
expiration_at INTEGER,
original_object_key TEXT,
original_content_type TEXT,
original_byte_size INTEGER CHECK (original_byte_size IS NULL OR original_byte_size >= 0),
original_width INTEGER CHECK (original_width IS NULL OR original_width > 0),
original_height INTEGER CHECK (original_height IS NULL OR original_height > 0),
display_object_key TEXT,
display_sha256 TEXT CHECK (display_sha256 IS NULL OR length(display_sha256) = 64),
display_content_type TEXT,
display_byte_size INTEGER CHECK (display_byte_size IS NULL OR display_byte_size >= 0),
display_width INTEGER CHECK (display_width IS NULL OR display_width > 0),
display_height INTEGER CHECK (display_height IS NULL OR display_height > 0),
thumbnail_object_key TEXT,
thumbnail_sha256 TEXT CHECK (thumbnail_sha256 IS NULL OR length(thumbnail_sha256) = 64),
thumbnail_content_type TEXT,
thumbnail_byte_size INTEGER CHECK (thumbnail_byte_size IS NULL OR thumbnail_byte_size >= 0),
thumbnail_width INTEGER CHECK (thumbnail_width IS NULL OR thumbnail_width > 0),
thumbnail_height INTEGER CHECK (thumbnail_height IS NULL OR thumbnail_height > 0),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX assets_lifecycle_idx ON assets (status, expiration_at);
CREATE TABLE playlists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL COLLATE NOCASE UNIQUE
CHECK (length(trim(name)) > 0),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE playlist_entries (
id TEXT PRIMARY KEY,
playlist_id TEXT NOT NULL,
asset_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
duration_override_ms INTEGER
CHECK (duration_override_ms IS NULL OR duration_override_ms > 0),
background_mode_override TEXT
CHECK (
background_mode_override IS NULL OR
background_mode_override IN ('blurred', 'solid', 'black')
),
background_color_override TEXT,
background_darkened_override INTEGER
CHECK (
background_darkened_override IS NULL OR
background_darkened_override IN (0, 1)
),
schedule_start_at INTEGER,
schedule_end_at INTEGER,
position INTEGER NOT NULL CHECK (position >= 0),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT,
UNIQUE (playlist_id, position),
UNIQUE (playlist_id, asset_id),
CHECK (
schedule_start_at IS NULL OR
schedule_end_at IS NULL OR
schedule_start_at < schedule_end_at
)
);
CREATE INDEX playlist_entries_asset_idx ON playlist_entries (asset_id);
CREATE INDEX playlist_entries_schedule_idx
ON playlist_entries (playlist_id, enabled, schedule_start_at, schedule_end_at);
CREATE TABLE app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
default_duration_ms INTEGER NOT NULL DEFAULT 7000
CHECK (default_duration_ms > 0),
default_background_mode TEXT NOT NULL DEFAULT 'blurred'
CHECK (default_background_mode IN ('blurred', 'solid', 'black')),
default_solid_background_color TEXT NOT NULL DEFAULT '#000000'
CHECK (length(trim(default_solid_background_color)) > 0),
default_background_darkened INTEGER NOT NULL DEFAULT 0
CHECK (default_background_darkened IN (0, 1)),
default_blur_px INTEGER NOT NULL DEFAULT 20
CHECK (default_blur_px >= 0),
crossfade_duration_ms INTEGER NOT NULL DEFAULT 1000
CHECK (crossfade_duration_ms >= 0),
updated_at INTEGER NOT NULL
);
INSERT INTO app_settings (id, updated_at)
VALUES (1, CAST(strftime('%s', 'now') AS INTEGER) * 1000);
CREATE TABLE playback_state (
output_id TEXT PRIMARY KEY,
mode TEXT NOT NULL DEFAULT 'on' CHECK (mode IN ('on', 'off')),
active_playlist_id TEXT,
pending_playlist_id TEXT,
pending_playlist_requested_at INTEGER,
current_entry_id TEXT,
rotation_paused INTEGER NOT NULL DEFAULT 0 CHECK (rotation_paused IN (0, 1)),
paused_remaining_ms INTEGER
CHECK (paused_remaining_ms IS NULL OR paused_remaining_ms >= 0),
static_asset_id TEXT,
slide_started_at INTEGER,
next_slide_at INTEGER,
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
updated_at INTEGER NOT NULL,
FOREIGN KEY (active_playlist_id) REFERENCES playlists(id) ON DELETE SET NULL,
FOREIGN KEY (pending_playlist_id) REFERENCES playlists(id) ON DELETE RESTRICT,
FOREIGN KEY (current_entry_id) REFERENCES playlist_entries(id) ON DELETE SET NULL,
FOREIGN KEY (static_asset_id) REFERENCES assets(id) ON DELETE SET NULL,
CHECK (
(pending_playlist_id IS NULL AND pending_playlist_requested_at IS NULL) OR
(pending_playlist_id IS NOT NULL AND pending_playlist_requested_at IS NOT NULL)
)
);
INSERT INTO playback_state (output_id, updated_at)
VALUES ('global', CAST(strftime('%s', 'now') AS INTEGER) * 1000);
CREATE TABLE asset_import_sources (
id TEXT PRIMARY KEY,
source_system TEXT NOT NULL,
source_bucket TEXT NOT NULL,
source_key TEXT NOT NULL,
source_etag TEXT,
source_byte_size INTEGER CHECK (source_byte_size IS NULL OR source_byte_size >= 0),
source_sha256 TEXT CHECK (source_sha256 IS NULL OR length(source_sha256) = 64),
asset_id TEXT,
initial_playlist_entry_id TEXT,
status TEXT NOT NULL
CHECK (status IN ('pending', 'processing', 'imported', 'failed')),
error TEXT,
imported_at INTEGER,
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE SET NULL,
FOREIGN KEY (initial_playlist_entry_id) REFERENCES playlist_entries(id) ON DELETE SET NULL,
UNIQUE (source_system, source_bucket, source_key)
);
CREATE INDEX asset_import_sources_status_idx ON asset_import_sources (status);
CREATE TABLE notification_incidents (
id TEXT PRIMARY KEY,
condition_key TEXT NOT NULL,
episode INTEGER NOT NULL CHECK (episode > 0),
event TEXT NOT NULL,
severity TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('open', 'recovered')),
message TEXT NOT NULL,
context_json TEXT NOT NULL DEFAULT '{}',
opened_at INTEGER NOT NULL,
last_observed_at INTEGER NOT NULL,
recovered_at INTEGER,
UNIQUE (condition_key, episode),
CHECK (
(state = 'open' AND recovered_at IS NULL) OR
(state = 'recovered' AND recovered_at IS NOT NULL)
)
);
CREATE UNIQUE INDEX notification_incidents_one_open_idx
ON notification_incidents (condition_key)
WHERE state = 'open';
CREATE TABLE notification_outbox (
id TEXT PRIMARY KEY,
incident_id TEXT NOT NULL,
transition TEXT NOT NULL CHECK (transition IN ('failure', 'recovery')),
payload_json TEXT NOT NULL,
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
next_attempt_at INTEGER NOT NULL,
delivered_at INTEGER,
last_error TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (incident_id) REFERENCES notification_incidents(id) ON DELETE CASCADE,
UNIQUE (incident_id, transition)
);
CREATE INDEX notification_outbox_due_idx
ON notification_outbox (next_attempt_at)
WHERE delivered_at IS NULL;
CREATE TABLE display_clients (
client_id TEXT PRIMARY KEY,
output_id TEXT NOT NULL DEFAULT 'global',
first_seen_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
active_playlist_id TEXT,
playlist_revision INTEGER,
playback_revision INTEGER,
current_entry_id TEXT,
current_asset_id TEXT,
cached_asset_count INTEGER CHECK (cached_asset_count IS NULL OR cached_asset_count >= 0),
playable_asset_count INTEGER CHECK (playable_asset_count IS NULL OR playable_asset_count >= 0),
last_error TEXT,
updated_at INTEGER NOT NULL
);
CREATE INDEX display_clients_last_seen_idx ON display_clients (last_seen_at);
+89
View File
@@ -0,0 +1,89 @@
const express = require("express");
const { checkDatabase } = require("../database");
const { redactedConfig } = require("../config");
function databaseHealth(database) {
try {
checkDatabase(database);
return { status: "ok" };
} catch (error) {
return {
status: "error",
message: "SQLite is unavailable",
};
}
}
function createHealthRouter({ database, lifecycle, migrations }) {
const router = express.Router();
router.get("/live", (request, response) => {
response.json({ status: "ok" });
});
router.get("/ready", (request, response) => {
const databaseCheck = databaseHealth(database);
const migrationsCurrent = migrations.pendingVersions.length === 0;
const ready =
lifecycle.ready && databaseCheck.status === "ok" && migrationsCurrent;
response.status(ready ? 200 : 503).json({
status: ready ? "ready" : "not_ready",
checks: {
database: databaseCheck.status,
migrations: migrationsCurrent ? "current" : "pending",
},
});
});
return router;
}
function createDetailedHealthRouter({ config, database, lifecycle, migrations }) {
const router = express.Router();
router.get("/", (request, response) => {
const databaseCheck = databaseHealth(database);
const ready =
lifecycle.ready &&
databaseCheck.status === "ok" &&
migrations.pendingVersions.length === 0;
const configuration = redactedConfig(config);
const status = ready ? "degraded" : "not_ready";
response.status(ready ? 200 : 503).json({
status,
configuration,
services: {
database: {
...databaseCheck,
currentSchemaVersion: migrations.currentVersion,
latestSchemaVersion: migrations.latestAvailableVersion,
},
storage: {
status: "not_checked",
configured: configuration.storage.configured,
},
mqtt: {
status: "not_started",
configured: configuration.mqtt.configured,
},
notifications: {
status: "not_started",
configured: configuration.notifications.configured,
},
playback: { status: "not_started" },
displays: { status: "not_started" },
},
});
});
return router;
}
module.exports = {
createDetailedHealthRouter,
createHealthRouter,
databaseHealth,
};
+51
View File
@@ -0,0 +1,51 @@
const path = require("node:path");
require("dotenv").config({ path: path.resolve(__dirname, "../../.env") });
const { loadConfig, redactedConfig } = require("./config");
const { createRuntime } = require("./runtime");
async function startApplication({ env = process.env, logger = console } = {}) {
const config = loadConfig(env);
const runtime = createRuntime({ config, logger });
const address = await runtime.start();
const displayedHost = address.family === "IPv6" ? `[${address.address}]` : address.address;
logger.info?.(
`Announcements API listening on http://${displayedHost}:${address.port}`,
);
logger.info?.("Configuration loaded", redactedConfig(config));
let shutdownStarted = false;
const shutdown = async (signal) => {
if (shutdownStarted) return;
shutdownStarted = true;
try {
await runtime.stop(signal);
} catch (error) {
logger.error?.("Graceful shutdown failed", error);
process.exitCode = 1;
}
};
process.once("SIGTERM", shutdown);
process.once("SIGINT", shutdown);
return runtime;
}
async function main() {
try {
await startApplication();
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
if (require.main === module) {
main();
}
module.exports = {
startApplication,
};
+132
View File
@@ -0,0 +1,132 @@
const http = require("node:http");
const { Server: SocketServer } = require("socket.io");
const { createApp } = require("./app");
const { openDatabase } = require("./database");
const { runMigrations } = require("./database/migrate");
function listen(httpServer, port, host) {
return new Promise((resolve, reject) => {
function onError(error) {
httpServer.off("listening", onListening);
reject(error);
}
function onListening() {
httpServer.off("error", onError);
resolve(httpServer.address());
}
httpServer.once("error", onError);
httpServer.once("listening", onListening);
httpServer.listen(port, host);
});
}
function createRuntime({ config, logger = console }) {
const database = openDatabase(config.database);
let migrations;
try {
migrations = runMigrations(database);
} catch (error) {
database.close();
throw error;
}
const lifecycle = {
ready: false,
stopping: false,
};
const app = createApp({ config, database, lifecycle, migrations });
const httpServer = http.createServer(app);
const io = new SocketServer(httpServer, {
serveClient: false,
});
io.on("connection", (socket) => {
logger.info?.(`Socket.IO client connected: ${socket.id}`);
socket.on("disconnect", () => {
logger.info?.(`Socket.IO client disconnected: ${socket.id}`);
});
});
let address = null;
let startPromise = null;
let stopPromise = null;
async function start() {
if (stopPromise) {
throw new Error("Cannot start a runtime after shutdown has begun");
}
if (startPromise) {
return startPromise;
}
startPromise = listen(httpServer, config.port, config.host)
.then((listeningAddress) => {
address = listeningAddress;
lifecycle.ready = true;
return listeningAddress;
})
.catch((error) => {
database.close();
throw error;
});
return startPromise;
}
async function stop(reason = "shutdown") {
if (stopPromise) {
return stopPromise;
}
lifecycle.ready = false;
lifecycle.stopping = true;
stopPromise = new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
if (database.open) {
database.close();
}
resolve();
};
const timeout = setTimeout(() => {
logger.warn?.(
`Graceful shutdown timed out after ${config.shutdownTimeoutMs}ms`,
);
httpServer.closeAllConnections?.();
finish();
}, config.shutdownTimeoutMs);
timeout.unref?.();
if (!httpServer.listening) {
io.close();
finish();
return;
}
logger.info?.(`Stopping Announcements API (${reason})`);
io.close(finish);
});
return stopPromise;
}
return {
app,
config,
database,
httpServer,
io,
lifecycle,
migrations,
get address() {
return address;
},
start,
stop,
};
}
module.exports = {
createRuntime,
};
+23
View File
@@ -0,0 +1,23 @@
const express = require("express");
function createSettingsRouter(settingsService) {
const router = express.Router();
router.get("/", (request, response) => {
response.json(settingsService.get());
});
router.patch("/", (request, response, next) => {
try {
response.json(settingsService.update(request.body));
} catch (error) {
next(error);
}
});
return router;
}
module.exports = {
createSettingsRouter,
};
+157
View File
@@ -0,0 +1,157 @@
const SETTING_COLUMNS = Object.freeze({
defaultDurationMs: "default_duration_ms",
defaultBackgroundMode: "default_background_mode",
defaultSolidBackgroundColor: "default_solid_background_color",
defaultBackgroundDarkened: "default_background_darkened",
defaultBlurPx: "default_blur_px",
crossfadeDurationMs: "crossfade_duration_ms",
});
class SettingsValidationError extends Error {
constructor(message, details = {}) {
super(message);
this.name = "SettingsValidationError";
this.statusCode = 400;
this.code = "INVALID_SETTINGS";
this.details = details;
}
}
function mapSettings(row) {
return {
revision: row.revision,
defaultDurationMs: row.default_duration_ms,
defaultBackgroundMode: row.default_background_mode,
defaultSolidBackgroundColor: row.default_solid_background_color,
defaultBackgroundDarkened: Boolean(row.default_background_darkened),
defaultBlurPx: row.default_blur_px,
crossfadeDurationMs: row.crossfade_duration_ms,
updatedAt: row.updated_at,
};
}
function validateSafeInteger(name, value, { positive = false } = {}) {
if (!Number.isSafeInteger(value) || (positive ? value <= 0 : value < 0)) {
const qualifier = positive ? "a positive" : "a non-negative";
throw new SettingsValidationError(`${name} must be ${qualifier} integer`, {
field: name,
});
}
}
function validatePatch(input) {
if (input === null || typeof input !== "object" || Array.isArray(input)) {
throw new SettingsValidationError("Settings payload must be a JSON object");
}
const keys = Object.keys(input);
const unknown = keys.filter((key) => !Object.hasOwn(SETTING_COLUMNS, key));
if (unknown.length > 0) {
throw new SettingsValidationError("Settings payload contains unknown fields", {
fields: unknown,
});
}
if (keys.length === 0) {
throw new SettingsValidationError("At least one setting is required");
}
if ("defaultDurationMs" in input) {
validateSafeInteger("defaultDurationMs", input.defaultDurationMs, {
positive: true,
});
}
if (
"defaultBackgroundMode" in input &&
!["blurred", "solid", "black"].includes(input.defaultBackgroundMode)
) {
throw new SettingsValidationError(
"defaultBackgroundMode must be blurred, solid, or black",
{ field: "defaultBackgroundMode" },
);
}
if (
"defaultSolidBackgroundColor" in input &&
(typeof input.defaultSolidBackgroundColor !== "string" ||
!/^#[0-9a-f]{6}$/i.test(input.defaultSolidBackgroundColor))
) {
throw new SettingsValidationError(
"defaultSolidBackgroundColor must be a six-digit hex color",
{ field: "defaultSolidBackgroundColor" },
);
}
if (
"defaultBackgroundDarkened" in input &&
typeof input.defaultBackgroundDarkened !== "boolean"
) {
throw new SettingsValidationError(
"defaultBackgroundDarkened must be a boolean",
{ field: "defaultBackgroundDarkened" },
);
}
if ("defaultBlurPx" in input) {
validateSafeInteger("defaultBlurPx", input.defaultBlurPx);
}
if ("crossfadeDurationMs" in input) {
validateSafeInteger("crossfadeDurationMs", input.crossfadeDurationMs);
}
return input;
}
function createSettingsService(database, { now = Date.now } = {}) {
const read = database.prepare("SELECT * FROM app_settings WHERE id = 1");
const update = database.prepare(`
UPDATE app_settings
SET revision = revision + 1,
default_duration_ms = @defaultDurationMs,
default_background_mode = @defaultBackgroundMode,
default_solid_background_color = @defaultSolidBackgroundColor,
default_background_darkened = @defaultBackgroundDarkened,
default_blur_px = @defaultBlurPx,
crossfade_duration_ms = @crossfadeDurationMs,
updated_at = @updatedAt
WHERE id = 1
`);
function get() {
const row = read.get();
if (!row) {
throw new Error("The app_settings singleton row is missing");
}
return mapSettings(row);
}
const updateTransaction = database.transaction((patch) => {
validatePatch(patch);
const current = get();
const next = { ...current, ...patch };
const changed = Object.keys(patch).some((key) => current[key] !== next[key]);
if (!changed) {
return current;
}
update.run({
defaultDurationMs: next.defaultDurationMs,
defaultBackgroundMode: next.defaultBackgroundMode,
defaultSolidBackgroundColor: next.defaultSolidBackgroundColor,
defaultBackgroundDarkened: next.defaultBackgroundDarkened ? 1 : 0,
defaultBlurPx: next.defaultBlurPx,
crossfadeDurationMs: next.crossfadeDurationMs,
updatedAt: Math.max(now(), current.updatedAt + 1),
});
return get();
});
return {
get,
update: updateTransaction,
};
}
module.exports = {
SettingsValidationError,
createSettingsService,
mapSettings,
validatePatch,
};
+87
View File
@@ -0,0 +1,87 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { ConfigError, loadConfig, redactedConfig } = require("../src/config");
function validEnvironment(overrides = {}) {
return {
NODE_ENV: "production",
HOST: "127.0.0.1",
PORT: "7532",
DATABASE_PATH: "/data/db/announcements.sqlite",
APP_TIMEZONE: "America/Chicago",
S3_ENDPOINT: "http://seaweedfs:8333",
S3_REGION: "us-east-1",
S3_BUCKET: "announcements",
S3_ACCESS_KEY_ID: "test-access-key",
S3_SECRET_ACCESS_KEY: "test-secret-key",
S3_FORCE_PATH_STYLE: "true",
MQTT_URL: "mqtt://broker:1883",
MQTT_USERNAME: "mqtt-user",
MQTT_PASSWORD: "mqtt-password",
HA_WEBHOOK_URL: "http://home-assistant:8123/api/webhook/example",
HA_WEBHOOK_SECRET: "webhook-secret",
...overrides,
};
}
test("loadConfig validates and normalizes the supported environment", () => {
const config = loadConfig(validEnvironment());
assert.equal(config.environment, "production");
assert.equal(config.port, 7532);
assert.equal(config.timeZone, "America/Chicago");
assert.equal(config.storage.endpoint, "http://seaweedfs:8333");
assert.equal(config.storage.forcePathStyle, true);
});
test("production requires private S3 credentials", () => {
assert.throws(
() => loadConfig(validEnvironment({ S3_ACCESS_KEY_ID: "" })),
(error) =>
error instanceof ConfigError && error.field === "S3_ACCESS_KEY_ID",
);
const development = loadConfig(
validEnvironment({
NODE_ENV: "development",
S3_ACCESS_KEY_ID: "",
S3_SECRET_ACCESS_KEY: "",
}),
);
assert.equal(development.storage.accessKeyId, null);
assert.equal(development.storage.secretAccessKey, null);
});
test("loadConfig rejects invalid typed values", () => {
assert.throws(
() => loadConfig(validEnvironment({ PORT: "7532.5" })),
/PORT must be an integer/,
);
assert.throws(
() => loadConfig(validEnvironment({ APP_TIMEZONE: "Church/Local" })),
/valid IANA time zone/,
);
assert.throws(
() => loadConfig(validEnvironment({ S3_FORCE_PATH_STYLE: "sometimes" })),
/must be a boolean/,
);
assert.throws(
() => loadConfig(validEnvironment({ DATABASE_PATH: "relative.sqlite" })),
/must be an absolute path/,
);
});
test("redactedConfig never returns credentials, passwords, paths, or webhook URLs", () => {
const summary = redactedConfig(loadConfig(validEnvironment()));
const serialized = JSON.stringify(summary);
assert.equal(summary.storage.configured, true);
assert.equal(summary.mqtt.configured, true);
assert.equal(summary.notifications.configured, true);
assert.doesNotMatch(serialized, /test-access-key/);
assert.doesNotMatch(serialized, /test-secret-key/);
assert.doesNotMatch(serialized, /mqtt-password/);
assert.doesNotMatch(serialized, /webhook-secret/);
assert.doesNotMatch(serialized, /announcements\.sqlite/);
assert.doesNotMatch(serialized, /home-assistant/);
});
+275
View File
@@ -0,0 +1,275 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { openDatabase } = require("../src/database");
const { migrationStatus, runMigrations } = require("../src/database/migrate");
function migratedDatabase() {
const database = openDatabase({ path: ":memory:" });
const migrations = runMigrations(database);
return { database, migrations };
}
function columnNames(database, table) {
return database.pragma(`table_info(${table})`).map((column) => column.name);
}
test("initial migration creates every approved Phase 0 table and seed row", (t) => {
const { database, migrations } = migratedDatabase();
t.after(() => database.close());
const tables = database
.prepare(
`SELECT name FROM sqlite_master
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
ORDER BY name`,
)
.all()
.map((row) => row.name);
assert.deepEqual(tables, [
"app_settings",
"asset_import_sources",
"assets",
"display_clients",
"notification_incidents",
"notification_outbox",
"playback_state",
"playlist_entries",
"playlists",
"schema_migrations",
]);
assert.equal(migrations.currentVersion, 1);
assert.equal(migrations.latestAvailableVersion, 1);
assert.deepEqual(migrations.pendingVersions, []);
assert.deepEqual(database.prepare("SELECT * FROM app_settings").get(), {
id: 1,
revision: 1,
default_duration_ms: 7000,
default_background_mode: "blurred",
default_solid_background_color: "#000000",
default_background_darkened: 0,
default_blur_px: 20,
crossfade_duration_ms: 1000,
updated_at: database.prepare("SELECT updated_at FROM app_settings").get()
.updated_at,
});
const playback = database.prepare("SELECT * FROM playback_state").get();
assert.equal(playback.output_id, "global");
assert.equal(playback.mode, "on");
assert.equal(playback.pending_playlist_id, null);
assert.equal(playback.pending_playlist_requested_at, null);
assert.ok(columnNames(database, "playlist_entries").includes(
"background_darkened_override",
));
});
test("migrations are idempotent and report their status", (t) => {
const { database } = migratedDatabase();
t.after(() => database.close());
const rerun = runMigrations(database);
assert.equal(rerun.applied.length, 1);
assert.deepEqual(rerun.pendingVersions, []);
assert.deepEqual(migrationStatus(database), rerun);
});
test("a failed migration rolls back its schema and version record", (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "announcements-migrations-"));
const migrationPath = path.join(directory, "001_test.sql");
fs.writeFileSync(
migrationPath,
"CREATE TABLE should_roll_back (id INTEGER); THIS IS NOT SQL;",
);
const database = openDatabase({ path: ":memory:" });
t.after(() => {
database.close();
fs.rmSync(directory, { recursive: true, force: true });
});
assert.throws(() => runMigrations(database, directory));
assert.equal(
database
.prepare(
"SELECT COUNT(*) AS count FROM sqlite_master WHERE name = 'should_roll_back'",
)
.get().count,
0,
);
assert.equal(
database.prepare("SELECT COUNT(*) AS count FROM schema_migrations").get().count,
0,
);
fs.writeFileSync(migrationPath, "CREATE TABLE recovered (id INTEGER);");
const status = runMigrations(database, directory);
assert.equal(status.currentVersion, 1);
assert.equal(
database
.prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE name = 'recovered'")
.get().count,
1,
);
});
test("a library asset can occur only once in a given playlist", (t) => {
const { database } = migratedDatabase();
t.after(() => database.close());
const now = Date.now();
database
.prepare(
`INSERT INTO assets
(id, source_sha256, display_name, original_filename, status, created_at, updated_at)
VALUES (?, ?, ?, ?, 'ready', ?, ?)`,
)
.run("asset-1", "a".repeat(64), "Welcome", "welcome.png", now, now);
database
.prepare(
`INSERT INTO playlists (id, name, created_at, updated_at)
VALUES (?, ?, ?, ?)`,
)
.run("playlist-1", "Sunday", now, now);
const insert = database.prepare(
`INSERT INTO playlist_entries
(id, playlist_id, asset_id, position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
);
insert.run("entry-1", "playlist-1", "asset-1", 0, now, now);
assert.throws(
() => insert.run("entry-2", "playlist-1", "asset-1", 1, now, now),
/UNIQUE constraint failed/,
);
database
.prepare(
`INSERT INTO playlists (id, name, created_at, updated_at)
VALUES (?, ?, ?, ?)`,
)
.run("playlist-2", "Midweek", now, now);
assert.doesNotThrow(() =>
insert.run("entry-3", "playlist-2", "asset-1", 0, now, now),
);
assert.throws(
() => database.prepare("DELETE FROM assets WHERE id = 'asset-1'").run(),
/FOREIGN KEY constraint failed/,
);
});
test("darkening overrides accept inherited, off, and on only", (t) => {
const { database } = migratedDatabase();
t.after(() => database.close());
const now = Date.now();
database
.prepare(
`INSERT INTO playlists (id, name, created_at, updated_at)
VALUES ('playlist-1', 'Sunday', ?, ?)`,
)
.run(now, now);
const insertAsset = database.prepare(
`INSERT INTO assets
(id, source_sha256, display_name, original_filename, status, created_at, updated_at)
VALUES (?, ?, ?, ?, 'ready', ?, ?)`,
);
const insertEntry = database.prepare(
`INSERT INTO playlist_entries
(id, playlist_id, asset_id, background_darkened_override, position, created_at, updated_at)
VALUES (?, 'playlist-1', ?, ?, ?, ?, ?)`,
);
for (const [index, override] of [null, 0, 1].entries()) {
const assetId = `asset-${index}`;
insertAsset.run(
assetId,
String(index).repeat(64),
`Asset ${index}`,
`asset-${index}.png`,
now,
now,
);
assert.doesNotThrow(() =>
insertEntry.run(
`entry-${index}`,
assetId,
override,
index,
now,
now,
),
);
}
insertAsset.run(
"asset-invalid",
"f".repeat(64),
"Invalid override",
"invalid.png",
now,
now,
);
assert.throws(
() => insertEntry.run("entry-invalid", "asset-invalid", 2, 3, now, now),
/CHECK constraint failed/,
);
assert.throws(
() =>
database
.prepare("UPDATE app_settings SET default_background_darkened = 2 WHERE id = 1")
.run(),
/CHECK constraint failed/,
);
});
test("pending playlist activation requires a playlist and request time together", (t) => {
const { database } = migratedDatabase();
t.after(() => database.close());
const now = Date.now();
database
.prepare(
`INSERT INTO playlists (id, name, created_at, updated_at)
VALUES ('playlist-1', 'Sunday', ?, ?)`,
)
.run(now, now);
assert.throws(
() =>
database
.prepare(
"UPDATE playback_state SET pending_playlist_id = 'playlist-1' WHERE output_id = 'global'",
)
.run(),
/CHECK constraint failed/,
);
assert.doesNotThrow(() =>
database
.prepare(
`UPDATE playback_state
SET pending_playlist_id = 'playlist-1', pending_playlist_requested_at = ?
WHERE output_id = 'global'`,
)
.run(now),
);
assert.throws(
() => database.prepare("DELETE FROM playlists WHERE id = 'playlist-1'").run(),
/FOREIGN KEY constraint failed/,
);
assert.throws(
() =>
database
.prepare(
`UPDATE playback_state
SET pending_playlist_id = 'missing', pending_playlist_requested_at = ?
WHERE output_id = 'global'`,
)
.run(now),
/FOREIGN KEY constraint failed/,
);
});
+141
View File
@@ -0,0 +1,141 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { loadConfig } = require("../src/config");
const { createRuntime } = require("../src/runtime");
function testConfig() {
return loadConfig({
NODE_ENV: "test",
HOST: "127.0.0.1",
PORT: "0",
DATABASE_PATH: ":memory:",
APP_TIMEZONE: "America/Chicago",
SHUTDOWN_TIMEOUT_MS: "1000",
S3_ENDPOINT: "http://seaweedfs:8333",
S3_REGION: "us-east-1",
S3_BUCKET: "announcements",
S3_ACCESS_KEY_ID: "access-key-that-must-not-leak",
S3_SECRET_ACCESS_KEY: "secret-key-that-must-not-leak",
S3_FORCE_PATH_STYLE: "true",
MQTT_URL: "mqtt://broker:1883",
MQTT_PASSWORD: "mqtt-password-that-must-not-leak",
HA_WEBHOOK_URL: "http://home-assistant:8123/api/webhook/private-id",
});
}
const silentLogger = {
info() {},
warn() {},
error() {},
};
async function responseJson(baseUrl, path, init) {
const response = await fetch(`${baseUrl}${path}`, init);
return { response, body: await response.json() };
}
test("HTTP foundation exposes live, ready, detailed health, and persisted settings", async (t) => {
const runtime = createRuntime({ config: testConfig(), logger: silentLogger });
await runtime.start();
t.after(() => runtime.stop("test cleanup"));
const baseUrl = `http://127.0.0.1:${runtime.address.port}`;
const live = await responseJson(baseUrl, "/health/live");
assert.equal(live.response.status, 200);
assert.deepEqual(live.body, { status: "ok" });
const ready = await responseJson(baseUrl, "/health/ready");
assert.equal(ready.response.status, 200);
assert.equal(ready.body.status, "ready");
const status = await responseJson(baseUrl, "/api/health");
assert.equal(status.response.status, 200);
assert.equal(status.body.status, "degraded");
assert.equal(status.body.services.database.currentSchemaVersion, 1);
assert.equal(status.body.services.storage.status, "not_checked");
const statusText = JSON.stringify(status.body);
assert.doesNotMatch(statusText, /access-key-that-must-not-leak/);
assert.doesNotMatch(statusText, /secret-key-that-must-not-leak/);
assert.doesNotMatch(statusText, /mqtt-password-that-must-not-leak/);
assert.doesNotMatch(statusText, /private-id/);
const statusAlias = await responseJson(baseUrl, "/api/status");
assert.equal(statusAlias.response.status, 200);
assert.deepEqual(statusAlias.body, status.body);
const initial = await responseJson(baseUrl, "/api/settings");
assert.equal(initial.body.defaultBackgroundDarkened, false);
const changed = await responseJson(baseUrl, "/api/settings", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
defaultBackgroundDarkened: true,
defaultDurationMs: 9000,
}),
});
assert.equal(changed.response.status, 200);
assert.equal(changed.body.revision, 2);
assert.equal(changed.body.defaultBackgroundDarkened, true);
const reread = await responseJson(baseUrl, "/api/settings");
assert.equal(reread.body.defaultDurationMs, 9000);
});
test("invalid settings receive a structured client error", async (t) => {
const runtime = createRuntime({ config: testConfig(), logger: silentLogger });
await runtime.start();
t.after(() => runtime.stop("test cleanup"));
const baseUrl = `http://127.0.0.1:${runtime.address.port}`;
const result = await responseJson(baseUrl, "/api/settings", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ defaultBackgroundDarkened: 0.25 }),
});
assert.equal(result.response.status, 400);
assert.equal(result.body.error.code, "INVALID_SETTINGS");
});
test("liveness stays up while critical readiness failures return 503", async (t) => {
const runtime = createRuntime({ config: testConfig(), logger: silentLogger });
await runtime.start();
t.after(() => runtime.stop("test cleanup"));
const baseUrl = `http://127.0.0.1:${runtime.address.port}`;
runtime.lifecycle.ready = false;
let live = await responseJson(baseUrl, "/health/live");
let ready = await responseJson(baseUrl, "/health/ready");
assert.equal(live.response.status, 200);
assert.equal(ready.response.status, 503);
assert.equal(ready.body.status, "not_ready");
runtime.lifecycle.ready = true;
runtime.migrations.pendingVersions.push(2);
ready = await responseJson(baseUrl, "/health/ready");
assert.equal(ready.response.status, 503);
assert.equal(ready.body.checks.migrations, "pending");
runtime.migrations.pendingVersions.length = 0;
runtime.database.close();
live = await responseJson(baseUrl, "/health/live");
ready = await responseJson(baseUrl, "/health/ready");
assert.equal(live.response.status, 200);
assert.equal(ready.response.status, 503);
assert.equal(ready.body.checks.database, "error");
});
test("runtime startup and shutdown are idempotent", async () => {
const runtime = createRuntime({ config: testConfig(), logger: silentLogger });
const [firstAddress, secondAddress] = await Promise.all([
runtime.start(),
runtime.start(),
]);
assert.deepEqual(firstAddress, secondAddress);
assert.equal(runtime.lifecycle.ready, true);
await Promise.all([runtime.stop("test"), runtime.stop("test")]);
assert.equal(runtime.lifecycle.ready, false);
assert.equal(runtime.database.open, false);
});
+81
View File
@@ -0,0 +1,81 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { openDatabase } = require("../src/database");
const { runMigrations } = require("../src/database/migrate");
const {
SettingsValidationError,
createSettingsService,
} = require("../src/settings/service");
function createService() {
const database = openDatabase({ path: ":memory:" });
runMigrations(database);
return {
database,
service: createSettingsService(database, { now: () => 2_000_000_000_000 }),
};
}
test("settings are persisted with the approved defaults", (t) => {
const { database, service } = createService();
t.after(() => database.close());
assert.deepEqual(service.get(), {
revision: 1,
defaultDurationMs: 7000,
defaultBackgroundMode: "blurred",
defaultSolidBackgroundColor: "#000000",
defaultBackgroundDarkened: false,
defaultBlurPx: 20,
crossfadeDurationMs: 1000,
updatedAt: database.prepare("SELECT updated_at FROM app_settings").get()
.updated_at,
});
});
test("settings updates persist boolean darkening and increment revision", (t) => {
const { database, service } = createService();
t.after(() => database.close());
const updated = service.update({
defaultBackgroundDarkened: true,
defaultBlurPx: 24,
});
assert.equal(updated.revision, 2);
assert.equal(updated.defaultBackgroundDarkened, true);
assert.equal(updated.defaultBlurPx, 24);
assert.equal(
database.prepare("SELECT default_background_darkened FROM app_settings").get()
.default_background_darkened,
1,
);
});
test("settings reject strength values, unknown fields, and invalid values", (t) => {
const { database, service } = createService();
t.after(() => database.close());
const original = service.get();
assert.throws(
() => service.update({ defaultBlurPx: 24, darkeningOverride: 0.25 }),
(error) => error instanceof SettingsValidationError,
);
assert.deepEqual(service.get(), original);
assert.throws(
() => service.update({ defaultBlurPx: 24, defaultBackgroundDarkened: 1 }),
/must be a boolean/,
);
assert.deepEqual(service.get(), original);
assert.throws(
() => service.update({ defaultDurationMs: 0 }),
/positive integer/,
);
assert.deepEqual(service.get(), original);
assert.throws(
() => service.update({ toString: "not a setting" }),
/unknown fields/,
);
assert.deepEqual(service.get(), original);
});