Build Phase 1 application foundation
This commit is contained in:
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
|
WORKDIR /app
|
||||||
|
|
||||||
RUN corepack enable
|
RUN corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
COPY server/package.json ./server/package.json
|
||||||
|
COPY client/package.json ./client/package.json
|
||||||
|
|
||||||
COPY . .
|
RUN pnpm install --frozen-lockfile --filter @announcements/client...
|
||||||
RUN pnpm build
|
|
||||||
|
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
|
EXPOSE 80
|
||||||
@@ -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
@@ -1,8 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "client",
|
"name": "@announcements/client",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.13.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
|||||||
Generated
-1572
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { io } from "socket.io-client";
|
import { io } from "socket.io-client";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
const socket = io("http://10.40.0.2:7532");
|
const socket = io();
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [slides, setSlides] = useState([]);
|
const [slides, setSlides] = useState([]);
|
||||||
|
|||||||
+18
-5
@@ -1,7 +1,20 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from "vite";
|
||||||
import react from '@vitejs/plugin-react'
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
export default defineConfig(({ mode }) => {
|
||||||
export default defineConfig({
|
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()],
|
plugins: [react()],
|
||||||
})
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": proxy,
|
||||||
|
"/health": proxy,
|
||||||
|
"/media": proxy,
|
||||||
|
"/socket.io": { ...proxy, ws: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|||||||
+97
-14
@@ -1,20 +1,103 @@
|
|||||||
|
name: "${COMPOSE_PROJECT_NAME:-announcements-v2}"
|
||||||
|
|
||||||
services:
|
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:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: server/Dockerfile
|
dockerfile: server/Dockerfile
|
||||||
container_name: announcements-server
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
init: true
|
||||||
- "7532:7532"
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
announcements-client:
|
HOST: 0.0.0.0
|
||||||
build:
|
PORT: "7532"
|
||||||
context: ./client
|
DATABASE_PATH: /data/db/announcements.sqlite
|
||||||
dockerfile: Dockerfile
|
APP_TIMEZONE: "${APP_TIMEZONE:-America/Chicago}"
|
||||||
container_name: announcements-client
|
SHUTDOWN_TIMEOUT_MS: "${SHUTDOWN_TIMEOUT_MS:-10000}"
|
||||||
restart: unless-stopped
|
DATABASE_BUSY_TIMEOUT_MS: "${DATABASE_BUSY_TIMEOUT_MS:-5000}"
|
||||||
ports:
|
TRUST_PROXY: "${TRUST_PROXY:-true}"
|
||||||
- "5174:80"
|
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:
|
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
@@ -1,24 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "Annoucements",
|
"name": "announcements",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"description": "",
|
"private": true,
|
||||||
"main": "index.js",
|
"description": "Church announcement management and signage",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon server/index.js",
|
"dev:server": "pnpm --filter @announcements/server dev",
|
||||||
"start": "node server/index.js"
|
"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": [],
|
"engines": {
|
||||||
"author": "",
|
"node": ">=22.13.0"
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"packageManager": "[email protected]"
|
||||||
"nodemon": "^3.1.14"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1366
-447
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
packages:
|
||||||
|
- client
|
||||||
|
- server
|
||||||
|
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- better-sqlite3
|
||||||
+24
-5
@@ -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
|
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 corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
|
||||||
RUN pnpm install --frozen-lockfile --prod
|
|
||||||
|
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
|
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
|
EXPOSE 7532
|
||||||
|
|
||||||
CMD ["node", "server/index.js"]
|
CMD ["node", "server/src/index.js"]
|
||||||
|
|||||||
-131
@@ -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}`);
|
|
||||||
});
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user