Phase 1 & 2: full-stack family dashboard scaffold
- pnpm monorepo (apps/client + apps/server) - Server: Express + node:sqlite with numbered migration runner, REST API for all 9 features (members, events, chores, shopping, meals, messages, countdowns, photos, settings) - Client: React 18 + Vite + TypeScript + Tailwind + Framer Motion + Zustand - Theme system: dark/light + 5 accent colors, CSS custom properties, anti-FOUC script, ThemeToggle on every surface - AppShell: collapsible sidebar, animated route transitions, mobile drawer - Phase 2 features: Calendar (custom month grid, event chips, add/edit modal), Chores (card grid, complete/reset, member filter, streaks), Shopping (multi-list tabs, animated check-off, quick-add bar, member assign) - Family member CRUD with avatar, color picker - Settings page: theme/accent, photo folder, slideshow, weather, date/time - Docker: multi-stage Dockerfile, docker-compose.yml, entrypoint with PUID/PGID - Unraid: CA XML template, CLI install script, UNRAID.md guide - .gitignore covering node_modules, dist, db files, secrets, build artifacts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
39
apps/server/src/db/db.ts
Normal file
39
apps/server/src/db/db.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { DatabaseSync, type StatementSync } from 'node:sqlite';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR ?? path.join(__dirname, '../../data');
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const _db = new DatabaseSync(path.join(DATA_DIR, 'family.db'));
|
||||
|
||||
_db.exec('PRAGMA journal_mode = WAL');
|
||||
_db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
/**
|
||||
* Transaction wrapper matching better-sqlite3's API:
|
||||
* const fn = db.transaction((args) => { ... });
|
||||
* fn(args); // runs inside BEGIN / COMMIT, rolls back on throw
|
||||
*/
|
||||
function transaction<TArgs extends unknown[], TReturn>(
|
||||
fn: (...args: TArgs) => TReturn
|
||||
): (...args: TArgs) => TReturn {
|
||||
return (...args: TArgs): TReturn => {
|
||||
_db.exec('BEGIN');
|
||||
try {
|
||||
const result = fn(...args);
|
||||
_db.exec('COMMIT');
|
||||
return result;
|
||||
} catch (err) {
|
||||
try { _db.exec('ROLLBACK'); } catch { /* ignore rollback errors */ }
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export db with the transaction helper attached, preserving full DatabaseSync interface
|
||||
const db = Object.assign(_db, { transaction });
|
||||
|
||||
export type Db = typeof db;
|
||||
export type Statement = StatementSync;
|
||||
export default db;
|
||||
120
apps/server/src/db/migrations/001_initial.ts
Normal file
120
apps/server/src/db/migrations/001_initial.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
export const id = '001_initial';
|
||||
|
||||
export const up = `
|
||||
-- ─── Family Members ───────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#6366f1',
|
||||
avatar TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ─── App Settings (key/value store) ───────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- ─── Calendar Events ──────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
start_at TEXT NOT NULL,
|
||||
end_at TEXT NOT NULL,
|
||||
all_day INTEGER NOT NULL DEFAULT 0,
|
||||
recurrence TEXT,
|
||||
member_id INTEGER REFERENCES members(id) ON DELETE SET NULL,
|
||||
color TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ─── Shopping Lists ───────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS shopping_lists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shopping_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
quantity TEXT,
|
||||
checked INTEGER NOT NULL DEFAULT 0,
|
||||
member_id INTEGER REFERENCES members(id) ON DELETE SET NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ─── Chores ───────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS chores (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
member_id INTEGER REFERENCES members(id) ON DELETE SET NULL,
|
||||
recurrence TEXT NOT NULL DEFAULT 'none',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
due_date TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chore_completions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chore_id INTEGER NOT NULL REFERENCES chores(id) ON DELETE CASCADE,
|
||||
member_id INTEGER REFERENCES members(id) ON DELETE SET NULL,
|
||||
completed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ─── Meal Planner (one meal per day — dinner) ─────────────────────
|
||||
CREATE TABLE IF NOT EXISTS meals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
recipe_url TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(date)
|
||||
);
|
||||
|
||||
-- ─── Message Board ────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
member_id INTEGER REFERENCES members(id) ON DELETE SET NULL,
|
||||
body TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#fef08a',
|
||||
emoji TEXT,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ─── Countdowns ───────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS countdowns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
target_date TEXT NOT NULL,
|
||||
emoji TEXT,
|
||||
color TEXT NOT NULL DEFAULT '#6366f1',
|
||||
show_on_dashboard INTEGER NOT NULL DEFAULT 1,
|
||||
event_id INTEGER REFERENCES events(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ─── Seed default settings ────────────────────────────────────────
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES
|
||||
('theme', 'light'),
|
||||
('accent', 'indigo'),
|
||||
('photo_folder', ''),
|
||||
('slideshow_speed', '6000'),
|
||||
('slideshow_order', 'random'),
|
||||
('idle_timeout', '120000'),
|
||||
('time_format', '12h'),
|
||||
('date_format', 'MM/DD/YYYY'),
|
||||
('weather_api_key', ''),
|
||||
('weather_location',''),
|
||||
('weather_units', 'imperial');
|
||||
|
||||
INSERT OR IGNORE INTO shopping_lists (id, name) VALUES (1, 'Groceries');
|
||||
`;
|
||||
123
apps/server/src/db/runner.ts
Normal file
123
apps/server/src/db/runner.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Migration runner
|
||||
*
|
||||
* How it works:
|
||||
* 1. Creates a `_migrations` table on first run.
|
||||
* 2. Loads all migration modules from ./migrations/ in filename order.
|
||||
* 3. Skips any migration whose `id` is already recorded in `_migrations`.
|
||||
* 4. Executes pending migrations inside individual transactions.
|
||||
* 5. Records each successful migration with a timestamp.
|
||||
*
|
||||
* Adding a new migration:
|
||||
* - Create `apps/server/src/db/migrations/NNN_description.ts`
|
||||
* - Export `id` (string, matches filename) and `up` (SQL string).
|
||||
* - Optionally export `down` (SQL string) for rollback support.
|
||||
* - The runner picks it up automatically on next startup.
|
||||
*/
|
||||
|
||||
import db from './db';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
interface Migration {
|
||||
id: string;
|
||||
up: string;
|
||||
down?: string;
|
||||
}
|
||||
|
||||
function bootstrap() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
function loadMigrations(): Migration[] {
|
||||
const dir = path.join(__dirname, 'migrations');
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => f.endsWith('.ts') || f.endsWith('.js'))
|
||||
.sort()
|
||||
.map((file) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const mod = require(path.join(dir, file)) as Migration;
|
||||
if (!mod.id || !mod.up) {
|
||||
throw new Error(`Migration ${file} must export 'id' and 'up'`);
|
||||
}
|
||||
return mod;
|
||||
});
|
||||
}
|
||||
|
||||
function getApplied(): Set<string> {
|
||||
const rows = db.prepare('SELECT id FROM _migrations').all() as { id: string }[];
|
||||
return new Set(rows.map((r) => r.id));
|
||||
}
|
||||
|
||||
export function runMigrations() {
|
||||
bootstrap();
|
||||
|
||||
const migrations = loadMigrations();
|
||||
const applied = getApplied();
|
||||
const pending = migrations.filter((m) => !applied.has(m.id));
|
||||
|
||||
if (pending.length === 0) {
|
||||
console.log('[db] All migrations up to date.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[db] Running ${pending.length} pending migration(s)...`);
|
||||
|
||||
for (const migration of pending) {
|
||||
const apply = db.transaction(() => {
|
||||
db.exec(migration.up);
|
||||
db.prepare('INSERT INTO _migrations (id) VALUES (?)').run(migration.id);
|
||||
});
|
||||
|
||||
try {
|
||||
apply();
|
||||
console.log(`[db] ✓ Applied: ${migration.id}`);
|
||||
} catch (err) {
|
||||
console.error(`[db] ✗ Failed: ${migration.id}`, err);
|
||||
throw err; // Abort startup on migration failure
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[db] Migrations complete.');
|
||||
}
|
||||
|
||||
export function rollback(targetId: string) {
|
||||
const migrations = loadMigrations();
|
||||
const applied = getApplied();
|
||||
|
||||
// Find all applied migrations after targetId in reverse order
|
||||
const toRollback = migrations
|
||||
.filter((m) => applied.has(m.id) && m.id > targetId)
|
||||
.reverse();
|
||||
|
||||
if (toRollback.length === 0) {
|
||||
console.log('[db] Nothing to roll back.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const migration of toRollback) {
|
||||
if (!migration.down) {
|
||||
console.warn(`[db] ⚠ No down migration for: ${migration.id} — skipping`);
|
||||
continue;
|
||||
}
|
||||
const revert = db.transaction(() => {
|
||||
db.exec(migration.down!);
|
||||
db.prepare('DELETE FROM _migrations WHERE id = ?').run(migration.id);
|
||||
});
|
||||
try {
|
||||
revert();
|
||||
console.log(`[db] ✓ Rolled back: ${migration.id}`);
|
||||
} catch (err) {
|
||||
console.error(`[db] ✗ Rollback failed: ${migration.id}`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
apps/server/src/index.ts
Normal file
45
apps/server/src/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { runMigrations } from './db/runner';
|
||||
|
||||
import membersRouter from './routes/members';
|
||||
import settingsRouter from './routes/settings';
|
||||
import eventsRouter from './routes/events';
|
||||
import shoppingRouter from './routes/shopping';
|
||||
import choresRouter from './routes/chores';
|
||||
import mealsRouter from './routes/meals';
|
||||
import messagesRouter from './routes/messages';
|
||||
import countdownsRouter from './routes/countdowns';
|
||||
import photosRouter from './routes/photos';
|
||||
|
||||
// Run DB migrations on startup — aborts if any migration fails
|
||||
runMigrations();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT ?? 3001;
|
||||
const CLIENT_ORIGIN = process.env.CLIENT_ORIGIN ?? 'http://localhost:5173';
|
||||
|
||||
app.use(cors({ origin: CLIENT_ORIGIN }));
|
||||
app.use(express.json());
|
||||
|
||||
app.use('/api/members', membersRouter);
|
||||
app.use('/api/settings', settingsRouter);
|
||||
app.use('/api/events', eventsRouter);
|
||||
app.use('/api/shopping', shoppingRouter);
|
||||
app.use('/api/chores', choresRouter);
|
||||
app.use('/api/meals', mealsRouter);
|
||||
app.use('/api/messages', messagesRouter);
|
||||
app.use('/api/countdowns', countdownsRouter);
|
||||
app.use('/api/photos', photosRouter);
|
||||
|
||||
// Serve built client — in Docker the client dist is copied here at build time
|
||||
const CLIENT_DIST = path.join(__dirname, '../../client/dist');
|
||||
app.use(express.static(CLIENT_DIST));
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(CLIENT_DIST, 'index.html'));
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Family Planner running on http://0.0.0.0:${PORT}`);
|
||||
});
|
||||
71
apps/server/src/routes/chores.ts
Normal file
71
apps/server/src/routes/chores.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db/db';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const { member_id } = req.query;
|
||||
let query = `
|
||||
SELECT c.*, m.name as member_name, m.color as member_color,
|
||||
(SELECT COUNT(*) FROM chore_completions cc WHERE cc.chore_id = c.id) as completion_count
|
||||
FROM chores c
|
||||
LEFT JOIN members m ON c.member_id = m.id
|
||||
`;
|
||||
if (member_id) {
|
||||
query += ' WHERE c.member_id = ?';
|
||||
return res.json(db.prepare(query + ' ORDER BY c.due_date ASC').all(member_id as string));
|
||||
}
|
||||
res.json(db.prepare(query + ' ORDER BY c.due_date ASC').all());
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { title, description, member_id, recurrence, due_date } = req.body;
|
||||
if (!title?.trim()) return res.status(400).json({ error: 'Title is required' });
|
||||
const result = db
|
||||
.prepare('INSERT INTO chores (title, description, member_id, recurrence, due_date) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(title.trim(), description ?? null, member_id ?? null, recurrence ?? 'none', due_date ?? null);
|
||||
res.status(201).json(db.prepare('SELECT * FROM chores WHERE id = ?').get(result.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
const existing = db.prepare('SELECT * FROM chores WHERE id = ?').get(req.params.id) as any;
|
||||
if (!existing) return res.status(404).json({ error: 'Chore not found' });
|
||||
const { title, description, member_id, recurrence, status, due_date } = req.body;
|
||||
db.prepare('UPDATE chores SET title=?, description=?, member_id=?, recurrence=?, status=?, due_date=? WHERE id=?').run(
|
||||
title?.trim() ?? existing.title,
|
||||
description !== undefined ? description : existing.description,
|
||||
member_id !== undefined ? member_id : existing.member_id,
|
||||
recurrence ?? existing.recurrence,
|
||||
status ?? existing.status,
|
||||
due_date !== undefined ? due_date : existing.due_date,
|
||||
req.params.id
|
||||
);
|
||||
res.json(db.prepare('SELECT * FROM chores WHERE id = ?').get(req.params.id));
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
const result = db.prepare('DELETE FROM chores WHERE id = ?').run(req.params.id);
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Chore not found' });
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
router.post('/:id/complete', (req, res) => {
|
||||
const { member_id } = req.body;
|
||||
const chore = db.prepare('SELECT * FROM chores WHERE id = ?').get(req.params.id) as any;
|
||||
if (!chore) return res.status(404).json({ error: 'Chore not found' });
|
||||
db.prepare('INSERT INTO chore_completions (chore_id, member_id) VALUES (?, ?)').run(req.params.id, member_id ?? chore.member_id);
|
||||
db.prepare("UPDATE chores SET status = 'done' WHERE id = ?").run(req.params.id);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.get('/:id/completions', (req, res) => {
|
||||
res.json(
|
||||
db.prepare(`
|
||||
SELECT cc.*, m.name as member_name FROM chore_completions cc
|
||||
LEFT JOIN members m ON cc.member_id = m.id
|
||||
WHERE cc.chore_id = ? ORDER BY cc.completed_at DESC
|
||||
`).all(req.params.id)
|
||||
);
|
||||
});
|
||||
|
||||
export default router;
|
||||
48
apps/server/src/routes/countdowns.ts
Normal file
48
apps/server/src/routes/countdowns.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db/db';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
res.json(
|
||||
db.prepare(`
|
||||
SELECT c.*, e.title as event_title
|
||||
FROM countdowns c
|
||||
LEFT JOIN events e ON c.event_id = e.id
|
||||
WHERE c.target_date >= date('now')
|
||||
ORDER BY c.target_date ASC
|
||||
`).all()
|
||||
);
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { title, target_date, emoji, color, show_on_dashboard, event_id } = req.body;
|
||||
if (!title?.trim() || !target_date) return res.status(400).json({ error: 'title and target_date are required' });
|
||||
const result = db
|
||||
.prepare('INSERT INTO countdowns (title, target_date, emoji, color, show_on_dashboard, event_id) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(title.trim(), target_date, emoji ?? null, color ?? '#6366f1', show_on_dashboard !== false ? 1 : 0, event_id ?? null);
|
||||
res.status(201).json(db.prepare('SELECT * FROM countdowns WHERE id = ?').get(result.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
const existing = db.prepare('SELECT * FROM countdowns WHERE id = ?').get(req.params.id) as any;
|
||||
if (!existing) return res.status(404).json({ error: 'Countdown not found' });
|
||||
const { title, target_date, emoji, color, show_on_dashboard } = req.body;
|
||||
db.prepare('UPDATE countdowns SET title=?, target_date=?, emoji=?, color=?, show_on_dashboard=? WHERE id=?').run(
|
||||
title?.trim() ?? existing.title,
|
||||
target_date ?? existing.target_date,
|
||||
emoji !== undefined ? emoji : existing.emoji,
|
||||
color ?? existing.color,
|
||||
show_on_dashboard !== undefined ? (show_on_dashboard ? 1 : 0) : existing.show_on_dashboard,
|
||||
req.params.id
|
||||
);
|
||||
res.json(db.prepare('SELECT * FROM countdowns WHERE id = ?').get(req.params.id));
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
const result = db.prepare('DELETE FROM countdowns WHERE id = ?').run(req.params.id);
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Countdown not found' });
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
60
apps/server/src/routes/events.ts
Normal file
60
apps/server/src/routes/events.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db/db';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const { start, end } = req.query as { start?: string; end?: string };
|
||||
let query = 'SELECT * FROM events';
|
||||
const params: string[] = [];
|
||||
if (start && end) {
|
||||
query += ' WHERE start_at >= ? AND start_at <= ?';
|
||||
params.push(start, end);
|
||||
}
|
||||
query += ' ORDER BY start_at ASC';
|
||||
res.json(db.prepare(query).all(...params));
|
||||
});
|
||||
|
||||
router.get('/:id', (req, res) => {
|
||||
const row = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
|
||||
if (!row) return res.status(404).json({ error: 'Event not found' });
|
||||
res.json(row);
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { title, description, start_at, end_at, all_day, recurrence, member_id, color } = req.body;
|
||||
if (!title?.trim() || !start_at || !end_at)
|
||||
return res.status(400).json({ error: 'title, start_at, and end_at are required' });
|
||||
const result = db
|
||||
.prepare(`INSERT INTO events (title, description, start_at, end_at, all_day, recurrence, member_id, color)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(title.trim(), description ?? null, start_at, end_at, all_day ? 1 : 0, recurrence ?? null, member_id ?? null, color ?? null);
|
||||
res.status(201).json(db.prepare('SELECT * FROM events WHERE id = ?').get(result.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
const existing = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id) as any;
|
||||
if (!existing) return res.status(404).json({ error: 'Event not found' });
|
||||
const { title, description, start_at, end_at, all_day, recurrence, member_id, color } = req.body;
|
||||
db.prepare(`UPDATE events SET title=?, description=?, start_at=?, end_at=?, all_day=?, recurrence=?, member_id=?, color=? WHERE id=?`)
|
||||
.run(
|
||||
title?.trim() ?? existing.title,
|
||||
description !== undefined ? description : existing.description,
|
||||
start_at ?? existing.start_at,
|
||||
end_at ?? existing.end_at,
|
||||
all_day !== undefined ? (all_day ? 1 : 0) : existing.all_day,
|
||||
recurrence !== undefined ? recurrence : existing.recurrence,
|
||||
member_id !== undefined ? member_id : existing.member_id,
|
||||
color !== undefined ? color : existing.color,
|
||||
req.params.id
|
||||
);
|
||||
res.json(db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id));
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
const result = db.prepare('DELETE FROM events WHERE id = ?').run(req.params.id);
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Event not found' });
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
37
apps/server/src/routes/meals.ts
Normal file
37
apps/server/src/routes/meals.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db/db';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get meals for a date range (e.g. ?start=2024-01-01&end=2024-01-31)
|
||||
router.get('/', (req, res) => {
|
||||
const { start, end } = req.query as { start?: string; end?: string };
|
||||
if (start && end) {
|
||||
return res.json(db.prepare('SELECT * FROM meals WHERE date >= ? AND date <= ? ORDER BY date ASC').all(start, end));
|
||||
}
|
||||
res.json(db.prepare('SELECT * FROM meals ORDER BY date ASC').all());
|
||||
});
|
||||
|
||||
router.get('/:date', (req, res) => {
|
||||
const row = db.prepare('SELECT * FROM meals WHERE date = ?').get(req.params.date);
|
||||
if (!row) return res.status(404).json({ error: 'No meal for this date' });
|
||||
res.json(row);
|
||||
});
|
||||
|
||||
router.put('/:date', (req, res) => {
|
||||
const { title, description, recipe_url } = req.body as { title: string; description?: string; recipe_url?: string };
|
||||
if (!title?.trim()) return res.status(400).json({ error: 'Title is required' });
|
||||
db.prepare(`
|
||||
INSERT INTO meals (date, title, description, recipe_url) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(date) DO UPDATE SET title=excluded.title, description=excluded.description, recipe_url=excluded.recipe_url
|
||||
`).run(req.params.date, title.trim(), description ?? null, recipe_url ?? null);
|
||||
res.json(db.prepare('SELECT * FROM meals WHERE date = ?').get(req.params.date));
|
||||
});
|
||||
|
||||
router.delete('/:date', (req, res) => {
|
||||
const result = db.prepare('DELETE FROM meals WHERE date = ?').run(req.params.date);
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'No meal for this date' });
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
47
apps/server/src/routes/members.ts
Normal file
47
apps/server/src/routes/members.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db/db';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
const rows = db.prepare('SELECT * FROM members ORDER BY name ASC').all();
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.get('/:id', (req, res) => {
|
||||
const row = db.prepare('SELECT * FROM members WHERE id = ?').get(req.params.id);
|
||||
if (!row) return res.status(404).json({ error: 'Member not found' });
|
||||
res.json(row);
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { name, color, avatar } = req.body as { name: string; color?: string; avatar?: string };
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
|
||||
const result = db
|
||||
.prepare('INSERT INTO members (name, color, avatar) VALUES (?, ?, ?)')
|
||||
.run(name.trim(), color ?? '#6366f1', avatar ?? null);
|
||||
const created = db.prepare('SELECT * FROM members WHERE id = ?').get(result.lastInsertRowid);
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
const { name, color, avatar } = req.body as { name?: string; color?: string; avatar?: string };
|
||||
const existing = db.prepare('SELECT * FROM members WHERE id = ?').get(req.params.id) as any;
|
||||
if (!existing) return res.status(404).json({ error: 'Member not found' });
|
||||
db.prepare('UPDATE members SET name = ?, color = ?, avatar = ? WHERE id = ?').run(
|
||||
name?.trim() ?? existing.name,
|
||||
color ?? existing.color,
|
||||
avatar !== undefined ? avatar : existing.avatar,
|
||||
req.params.id
|
||||
);
|
||||
const updated = db.prepare('SELECT * FROM members WHERE id = ?').get(req.params.id);
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
const result = db.prepare('DELETE FROM members WHERE id = ?').run(req.params.id);
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Member not found' });
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
48
apps/server/src/routes/messages.ts
Normal file
48
apps/server/src/routes/messages.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db/db';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
res.json(
|
||||
db.prepare(`
|
||||
SELECT msg.*, m.name as member_name, m.color as member_color
|
||||
FROM messages msg
|
||||
LEFT JOIN members m ON msg.member_id = m.id
|
||||
WHERE msg.expires_at IS NULL OR msg.expires_at > datetime('now')
|
||||
ORDER BY msg.pinned DESC, msg.created_at DESC
|
||||
`).all()
|
||||
);
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { member_id, body, color, emoji, pinned, expires_at } = req.body;
|
||||
if (!body?.trim()) return res.status(400).json({ error: 'Body is required' });
|
||||
const result = db
|
||||
.prepare('INSERT INTO messages (member_id, body, color, emoji, pinned, expires_at) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(member_id ?? null, body.trim(), color ?? '#fef08a', emoji ?? null, pinned ? 1 : 0, expires_at ?? null);
|
||||
res.status(201).json(db.prepare('SELECT * FROM messages WHERE id = ?').get(result.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.patch('/:id', (req, res) => {
|
||||
const existing = db.prepare('SELECT * FROM messages WHERE id = ?').get(req.params.id) as any;
|
||||
if (!existing) return res.status(404).json({ error: 'Message not found' });
|
||||
const { body, color, emoji, pinned, expires_at } = req.body;
|
||||
db.prepare('UPDATE messages SET body=?, color=?, emoji=?, pinned=?, expires_at=? WHERE id=?').run(
|
||||
body?.trim() ?? existing.body,
|
||||
color ?? existing.color,
|
||||
emoji !== undefined ? emoji : existing.emoji,
|
||||
pinned !== undefined ? (pinned ? 1 : 0) : existing.pinned,
|
||||
expires_at !== undefined ? expires_at : existing.expires_at,
|
||||
req.params.id
|
||||
);
|
||||
res.json(db.prepare('SELECT * FROM messages WHERE id = ?').get(req.params.id));
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
const result = db.prepare('DELETE FROM messages WHERE id = ?').run(req.params.id);
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Message not found' });
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
61
apps/server/src/routes/photos.ts
Normal file
61
apps/server/src/routes/photos.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db/db';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.bmp']);
|
||||
|
||||
function scanDir(dir: string): string[] {
|
||||
if (!dir || !fs.existsSync(dir)) return [];
|
||||
const results: string[] = [];
|
||||
function recurse(current: string) {
|
||||
let entries: fs.Dirent[];
|
||||
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return; }
|
||||
for (const entry of entries) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) recurse(full);
|
||||
else if (IMAGE_EXTS.has(path.extname(entry.name).toLowerCase())) results.push(full);
|
||||
}
|
||||
}
|
||||
recurse(dir);
|
||||
return results;
|
||||
}
|
||||
|
||||
// PHOTOS_DIR env var (set in Docker) overrides the DB setting.
|
||||
// This lets Unraid users bind-mount their photo library to /photos
|
||||
// without having to change the settings in the UI.
|
||||
function resolvePhotoFolder(): string {
|
||||
if (process.env.PHOTOS_DIR) return process.env.PHOTOS_DIR;
|
||||
const row = db.prepare("SELECT value FROM settings WHERE key = 'photo_folder'").get() as any;
|
||||
return row?.value ?? '';
|
||||
}
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
const folder = resolvePhotoFolder();
|
||||
const files = scanDir(folder);
|
||||
res.json({ folder, count: files.length, files: files.map((f) => path.basename(f)) });
|
||||
});
|
||||
|
||||
router.get('/file/:filename', (req, res) => {
|
||||
const folder = resolvePhotoFolder();
|
||||
if (!folder) return res.status(404).json({ error: 'Photo folder not configured' });
|
||||
|
||||
// Security: prevent path traversal
|
||||
const filename = path.basename(req.params.filename);
|
||||
const filepath = path.join(folder, filename);
|
||||
if (!filepath.startsWith(folder)) return res.status(403).json({ error: 'Forbidden' });
|
||||
if (!fs.existsSync(filepath)) return res.status(404).json({ error: 'File not found' });
|
||||
res.sendFile(filepath);
|
||||
});
|
||||
|
||||
// Return all photos as a flat list with their relative paths for the slideshow
|
||||
router.get('/slideshow', (_req, res) => {
|
||||
const folder = resolvePhotoFolder();
|
||||
const files = scanDir(folder);
|
||||
const urls = files.map((f) => `/api/photos/file/${encodeURIComponent(path.relative(folder, f).replace(/\\/g, '/'))}`);
|
||||
res.json({ count: urls.length, urls });
|
||||
});
|
||||
|
||||
export default router;
|
||||
23
apps/server/src/routes/settings.ts
Normal file
23
apps/server/src/routes/settings.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db/db';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
|
||||
const settings = Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
||||
res.json(settings);
|
||||
});
|
||||
|
||||
router.patch('/', (req, res) => {
|
||||
const updates = req.body as Record<string, string>;
|
||||
const upsert = db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value');
|
||||
const updateMany = db.transaction((pairs: [string, string][]) => {
|
||||
for (const [k, v] of pairs) upsert.run(k, String(v));
|
||||
});
|
||||
updateMany(Object.entries(updates));
|
||||
const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
|
||||
res.json(Object.fromEntries(rows.map((r) => [r.key, r.value])));
|
||||
});
|
||||
|
||||
export default router;
|
||||
67
apps/server/src/routes/shopping.ts
Normal file
67
apps/server/src/routes/shopping.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../db/db';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ── Lists ──────────────────────────────────────────────────────────────────
|
||||
router.get('/lists', (_req, res) => {
|
||||
res.json(db.prepare('SELECT * FROM shopping_lists ORDER BY name ASC').all());
|
||||
});
|
||||
|
||||
router.post('/lists', (req, res) => {
|
||||
const { name } = req.body as { name: string };
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
|
||||
const result = db.prepare('INSERT INTO shopping_lists (name) VALUES (?)').run(name.trim());
|
||||
res.status(201).json(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(result.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.delete('/lists/:id', (req, res) => {
|
||||
const result = db.prepare('DELETE FROM shopping_lists WHERE id = ?').run(req.params.id);
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'List not found' });
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ── Items ──────────────────────────────────────────────────────────────────
|
||||
router.get('/lists/:listId/items', (req, res) => {
|
||||
res.json(
|
||||
db.prepare('SELECT * FROM shopping_items WHERE list_id = ? ORDER BY sort_order ASC, id ASC').all(req.params.listId)
|
||||
);
|
||||
});
|
||||
|
||||
router.post('/lists/:listId/items', (req, res) => {
|
||||
const { name, quantity, member_id } = req.body as { name: string; quantity?: string; member_id?: number };
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
|
||||
const maxOrder = (db.prepare('SELECT MAX(sort_order) as m FROM shopping_items WHERE list_id = ?').get(req.params.listId) as any)?.m ?? 0;
|
||||
const result = db
|
||||
.prepare('INSERT INTO shopping_items (list_id, name, quantity, member_id, sort_order) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(req.params.listId, name.trim(), quantity ?? null, member_id ?? null, maxOrder + 1);
|
||||
res.status(201).json(db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(result.lastInsertRowid));
|
||||
});
|
||||
|
||||
router.patch('/items/:id', (req, res) => {
|
||||
const existing = db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(req.params.id) as any;
|
||||
if (!existing) return res.status(404).json({ error: 'Item not found' });
|
||||
const { name, quantity, checked, member_id, sort_order } = req.body;
|
||||
db.prepare('UPDATE shopping_items SET name=?, quantity=?, checked=?, member_id=?, sort_order=? WHERE id=?').run(
|
||||
name?.trim() ?? existing.name,
|
||||
quantity !== undefined ? quantity : existing.quantity,
|
||||
checked !== undefined ? (checked ? 1 : 0) : existing.checked,
|
||||
member_id !== undefined ? member_id : existing.member_id,
|
||||
sort_order !== undefined ? sort_order : existing.sort_order,
|
||||
req.params.id
|
||||
);
|
||||
res.json(db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(req.params.id));
|
||||
});
|
||||
|
||||
router.delete('/items/:id', (req, res) => {
|
||||
const result = db.prepare('DELETE FROM shopping_items WHERE id = ?').run(req.params.id);
|
||||
if (result.changes === 0) return res.status(404).json({ error: 'Item not found' });
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
router.delete('/lists/:listId/items/checked', (req, res) => {
|
||||
db.prepare('DELETE FROM shopping_items WHERE list_id = ? AND checked = 1').run(req.params.listId);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user