diff --git a/.env.example b/.env.example index 9cb2a0f..4af0149 100644 --- a/.env.example +++ b/.env.example @@ -22,7 +22,6 @@ AUTH_OIDC_CLIENT_SECRET=replace-me # Web Push (generate with: pnpm vapid:generate — copy all three lines to .env) VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= -NEXT_PUBLIC_VAPID_PUBLIC_KEY= VAPID_SUBJECT=mailto:you@example.com # ntfy (optional fallback channel; leave blank to disable) diff --git a/docs/dev-login.md b/docs/dev-login.md index f6e15ee..54dbfe1 100644 --- a/docs/dev-login.md +++ b/docs/dev-login.md @@ -34,20 +34,69 @@ This note tracks the development-only work added to make the app easy to run and ## Current Local Run Procedure +One command starts the database, runs migrations, seeds, and launches the dev server: + ```powershell -docker compose -f docker-compose.dev.yaml up -d -pnpm db:migrate -pnpm db:seed -pnpm dev +pnpm dev:local ``` -Then open: +The script prints three URLs at startup: -```text -http://127.0.0.1:3000/login +| URL | Use for | +|-----|---------| +| `http://localhost:3000` | Browser on this machine | +| `http://192.168.1.74:3000` | Phone on the same WiFi (general UI testing) | +| `https://dev.ginnoir.com` | Push notifications + PWA install (needs Caddy — see below) | + +Then open `/login` and click **Dev login**. + +### HMR and restarts + +`next dev` has hot module replacement — most `.ts`/`.tsx` changes apply instantly without a restart. + +A full restart (`Ctrl+C` → `pnpm dev:local`) is needed for: +- `.env` changes +- `next.config.ts` changes + +### Clean slate + +To delete all local data and start fresh (e.g. after a destructive schema change): + +```powershell +pnpm dev:reset ``` -Click **Dev login**. +### HTTPS for push notification and PWA testing (one-time setup) + +Service workers and Web Push require HTTPS. The plain LAN address won't work for these. +Route through the existing Caddy server on the home server instead — no extra tooling needed. + +**Step 1 — DHCP reservation** + +Set a reservation on the router so the dev machine always gets `192.168.1.74`. + +**Step 2 — DNS record** + +Add a `dev.ginnoir.com` A record pointing to the same public IP as `fam.ginnoir.com`. + +**Step 3 — Windows Firewall** + +Allow inbound TCP 3000 on the dev machine (run once in an elevated PowerShell): + +```powershell +New-NetFirewallRule -DisplayName "famapp dev" -Direction Inbound ` + -Protocol TCP -LocalPort 3000 -Action Allow +``` + +**Step 4 — Caddy snippet** + +Paste `deploy/Caddyfile.dev.snippet` into the home server Caddyfile and reload: + +```bash +caddy reload --config /path/to/Caddyfile +``` + +After this, `https://dev.ginnoir.com` proxies to the dev machine with a real Let's Encrypt cert. ## Current Local E2E Procedure diff --git a/package.json b/package.json index 7401868..031bb29 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ }, "scripts": { "dev": "next dev", + "dev:network": "next dev --hostname 0.0.0.0", + "dev:local": "node scripts/dev.mjs", + "dev:reset": "node scripts/dev-reset.mjs", "build": "next build", "start": "next start", "lint": "eslint .", diff --git a/scripts/dev-reset.mjs b/scripts/dev-reset.mjs new file mode 100644 index 0000000..d0356cc --- /dev/null +++ b/scripts/dev-reset.mjs @@ -0,0 +1,24 @@ +/** + * Dev environment reset script. + * + * Tears down the local Postgres container and deletes its volume (all local + * data is lost), then performs a fresh startup: container up, migrate, seed, + * Next.js dev server. + * + * Use this when you want a completely clean local database — e.g. after a + * destructive schema change or to reproduce a fresh-install scenario. + * + * Usage: pnpm dev:reset + */ + +import { execSync } from "child_process"; +import { main } from "./dev.mjs"; + +console.log("\n⚠ Resetting dev environment — all local data will be deleted."); +execSync("docker compose -f docker-compose.dev.yaml down -v", { + stdio: "inherit", + shell: true, +}); + +console.log("\n▸ Restarting from scratch..."); +await main(); diff --git a/scripts/dev.mjs b/scripts/dev.mjs new file mode 100644 index 0000000..0373012 --- /dev/null +++ b/scripts/dev.mjs @@ -0,0 +1,118 @@ +/** + * Dev environment startup script. + * + * Starts the local Postgres container, waits for it to be ready, runs + * migrations + seed, then spawns `next dev` bound to 0.0.0.0 so the app + * is reachable from other devices on the LAN. + * + * Usage: pnpm dev:local + */ + +import { execSync, spawn } from "child_process"; +import net from "net"; +import os from "os"; +import { fileURLToPath } from "url"; + +/** Return the first LAN IPv4 address (prefers 192.168.x / 10.x ranges). */ +function getLanIp() { + const all = Object.values(os.networkInterfaces()) + .flat() + .filter((n) => n.family === "IPv4" && !n.internal) + .map((n) => n.address); + return ( + all.find((a) => a.startsWith("192.168.") || a.startsWith("10.")) ?? + all[0] ?? + "unknown" + ); +} + +/** Poll TCP host:port until it accepts a connection or the timeout expires. */ +function waitForTcp(host, port, timeoutMs = 30_000) { + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs; + + function attempt() { + const sock = net.createConnection({ host, port }); + sock.on("connect", () => { + sock.destroy(); + resolve(); + }); + sock.on("error", () => { + sock.destroy(); + if (Date.now() >= deadline) { + reject( + new Error( + `Postgres not ready on ${host}:${port} after ${timeoutMs / 1000}s. ` + + "Is Docker running? Check: docker compose -f docker-compose.dev.yaml logs" + ) + ); + } else { + setTimeout(attempt, 500); + } + }); + } + + attempt(); + }); +} + +/** Run a shell command synchronously, streaming output to the terminal. */ +function run(cmd) { + execSync(cmd, { stdio: "inherit", shell: true }); +} + +export async function main() { + // 1. Start DB container (idempotent — safe to call when already running) + console.log("\n▸ Starting database container..."); + run("docker compose -f docker-compose.dev.yaml up -d"); + + // 2. Wait for Postgres to accept connections + process.stdout.write("▸ Waiting for Postgres"); + const tick = setInterval(() => process.stdout.write("."), 500); + try { + await waitForTcp("127.0.0.1", 5432); + } finally { + clearInterval(tick); + process.stdout.write(" ready\n"); + } + + // 3. Apply any pending migrations (no-op if already current) + console.log("▸ Running migrations..."); + run("pnpm db:migrate"); + + // 4. Seed default data (idempotent) + console.log("▸ Seeding..."); + run("pnpm db:seed"); + + // 5. Print access URLs before the Next.js banner appears + const lanIp = getLanIp(); + console.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + console.log(` Local → http://localhost:3000`); + console.log(` LAN → http://${lanIp}:3000 (phone on same WiFi)`); + console.log(` HTTPS → https://dev.ginnoir.com (push/PWA — needs Caddy snippet)`); + console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); + + // 6. Spawn Next.js dev server bound to all interfaces. + // HMR handles most code changes without a restart. + // Restart needed for: .env changes, next.config.ts changes. + // Pass command as a single string with no args array — shell handles .cmd + // resolution on Windows and the empty args avoids DEP0190. + const next = spawn("pnpm run dev:network", [], { stdio: "inherit", shell: true }); + + // Forward Ctrl+C / SIGTERM to the child so it shuts down cleanly. + const forward = (sig) => () => next.kill(sig); + process.on("SIGINT", forward("SIGINT")); + process.on("SIGTERM", forward("SIGTERM")); + + await new Promise((resolve) => next.on("exit", (code) => resolve(code))); + process.exit(0); +} + +// Run when invoked directly (not imported by dev-reset.mjs) +const __filename = fileURLToPath(import.meta.url); +if (process.argv[1] === __filename) { + main().catch((err) => { + console.error("\ndev startup failed:", err.message); + process.exit(1); + }); +} diff --git a/scripts/send-test-push.mts b/scripts/send-test-push.mts new file mode 100644 index 0000000..923a762 --- /dev/null +++ b/scripts/send-test-push.mts @@ -0,0 +1,48 @@ +/** + * Dev script: send a test push notification to all subscriptions for the dev user. + * Usage: tsx --env-file=.env scripts/send-test-push.mts + */ +import webPush from "web-push"; +import postgres from "postgres"; + +const subject = process.env["VAPID_SUBJECT"]; +const publicKey = process.env["VAPID_PUBLIC_KEY"]; +const privateKey = process.env["VAPID_PRIVATE_KEY"]; +const databaseUrl = process.env["DATABASE_URL"]; + +if (!subject || !publicKey || !privateKey) { + console.error("VAPID env vars not set"); + process.exit(1); +} +if (!databaseUrl) { + console.error("DATABASE_URL not set"); + process.exit(1); +} + +webPush.setVapidDetails(subject, publicKey, privateKey); + +const sql = postgres(databaseUrl); + +const subs = await sql`SELECT id, user_id, endpoint, p256dh, auth FROM push_subscriptions`; +console.log(`Found ${subs.length} push subscription(s)`); + +if (subs.length === 0) { + console.log("No subscriptions — enable push notifications in Settings first."); + await sql.end(); + process.exit(0); +} + +for (const sub of subs) { + try { + await webPush.sendNotification( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + JSON.stringify({ title: "famapp test", body: "Push notifications are working!", url: "/settings" }), + ); + console.log(`Sent to ${sub.endpoint.slice(0, 60)}...`); + } catch (err: unknown) { + const e = err as { statusCode?: number; message?: string }; + console.error(`Failed (${e.statusCode ?? "?"}): ${e.message}`); + } +} + +await sql.end(); diff --git a/scripts/vapid-generate.mjs b/scripts/vapid-generate.mjs index 6deeee1..13c9473 100644 --- a/scripts/vapid-generate.mjs +++ b/scripts/vapid-generate.mjs @@ -3,4 +3,3 @@ import webPush from "web-push"; const { publicKey, privateKey } = webPush.generateVAPIDKeys(); console.log(`VAPID_PUBLIC_KEY=${publicKey}`); console.log(`VAPID_PRIVATE_KEY=${privateKey}`); -console.log(`NEXT_PUBLIC_VAPID_PUBLIC_KEY=${publicKey}`); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 60fc740..6d6f5e0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -149,6 +149,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo data-density={density} data-nav={initialDataNav} className={cn(fontVars, isDark ? "dark" : "")} + suppressHydrationWarning >