feat: add dev startup script, fix push notifications, and scope test sends to device

- pnpm dev:local/dev:reset: orchestrate DB container, migrations, seed, and Next.js dev server in one command; Caddy snippet + docs for HTTPS via dev.ginnoir.com
- Fix dev login on HTTPS: set both authjs.session-token and __Secure-authjs.session-token so Auth.js finds the session regardless of cookie name resolution
- Suppress hydration mismatch on <html> caused by pre-paint script changing data-nav before React hydrates
- VAPID startup warning if keys not configured; remove dead NEXT_PUBLIC_VAPID_PUBLIC_KEY var
- PushOptIn: hydrate subscription state on mount; reuse existing subscription on iOS to avoid redundant prompts
- sendPushToEndpoint: new function to send to a single device endpoint
- sendTestNotification: scoped to the calling device's endpoint (ownership-verified) instead of all user subscriptions
This commit is contained in:
ginnoir
2026-06-01 17:45:20 -05:00
parent 9612a54e52
commit 19308be768
14 changed files with 334 additions and 26 deletions
-1
View File
@@ -22,7 +22,6 @@ AUTH_OIDC_CLIENT_SECRET=replace-me
# Web Push (generate with: pnpm vapid:generate — copy all three lines to .env) # Web Push (generate with: pnpm vapid:generate — copy all three lines to .env)
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY= VAPID_PRIVATE_KEY=
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
VAPID_SUBJECT=mailto:you@example.com VAPID_SUBJECT=mailto:you@example.com
# ntfy (optional fallback channel; leave blank to disable) # ntfy (optional fallback channel; leave blank to disable)
+57 -8
View File
@@ -34,20 +34,69 @@ This note tracks the development-only work added to make the app easy to run and
## Current Local Run Procedure ## Current Local Run Procedure
One command starts the database, runs migrations, seeds, and launches the dev server:
```powershell ```powershell
docker compose -f docker-compose.dev.yaml up -d pnpm dev:local
pnpm db:migrate
pnpm db:seed
pnpm dev
``` ```
Then open: The script prints three URLs at startup:
```text | URL | Use for |
http://127.0.0.1:3000/login |-----|---------|
| `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 ## Current Local E2E Procedure
+3
View File
@@ -9,6 +9,9 @@
}, },
"scripts": { "scripts": {
"dev": "next dev", "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", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint .", "lint": "eslint .",
+24
View File
@@ -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();
+118
View File
@@ -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);
});
}
+48
View File
@@ -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();
-1
View File
@@ -3,4 +3,3 @@ import webPush from "web-push";
const { publicKey, privateKey } = webPush.generateVAPIDKeys(); const { publicKey, privateKey } = webPush.generateVAPIDKeys();
console.log(`VAPID_PUBLIC_KEY=${publicKey}`); console.log(`VAPID_PUBLIC_KEY=${publicKey}`);
console.log(`VAPID_PRIVATE_KEY=${privateKey}`); console.log(`VAPID_PRIVATE_KEY=${privateKey}`);
console.log(`NEXT_PUBLIC_VAPID_PUBLIC_KEY=${publicKey}`);
+1
View File
@@ -149,6 +149,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
data-density={density} data-density={density}
data-nav={initialDataNav} data-nav={initialDataNav}
className={cn(fontVars, isDark ? "dark" : "")} className={cn(fontVars, isDark ? "dark" : "")}
suppressHydrationWarning
> >
<head> <head>
<script dangerouslySetInnerHTML={{ __html: prePaintScript }} /> <script dangerouslySetInnerHTML={{ __html: prePaintScript }} />
+10 -6
View File
@@ -2,7 +2,7 @@ import { signIn } from "@/lib/auth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { DEV_LOGIN_COOKIE, isDevLoginEnabled } from "@/lib/dev-login-config"; import { isDevLoginEnabled } from "@/lib/dev-login-config";
import { createDevSession } from "@/lib/dev-login"; import { createDevSession } from "@/lib/dev-login";
import { BrandMark } from "@/components/brand-mark"; import { BrandMark } from "@/components/brand-mark";
@@ -37,13 +37,17 @@ export default function LoginPage() {
<form <form
action={async () => { action={async () => {
"use server"; "use server";
// Auth.js may resolve either "authjs.session-token" (HTTP/dev) or
// "__Secure-authjs.session-token" (HTTPS) depending on AUTH_URL,
// trustHost, and proxy headers. Set both so the session is found
// regardless — this is dev-only code, correctness > elegance.
const { sessionToken, expires } = await createDevSession(); const { sessionToken, expires } = await createDevSession();
const cookieStore = await cookies(); const cookieStore = await cookies();
cookieStore.set(DEV_LOGIN_COOKIE, sessionToken, { const base = { httpOnly: true, sameSite: "lax" as const, path: "/", expires };
httpOnly: true, cookieStore.set("authjs.session-token", sessionToken, base);
sameSite: "lax", cookieStore.set("__Secure-authjs.session-token", sessionToken, {
path: "/", ...base,
expires, secure: true,
}); });
redirect("/"); redirect("/");
}} }}
+10 -3
View File
@@ -4,7 +4,7 @@ import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { pushSubscriptions } from "@/modules/_core/schema"; import { pushSubscriptions } from "@/modules/_core/schema";
import { getCurrentSession } from "@/lib/session"; import { getCurrentSession } from "@/lib/session";
import { sendPush } from "@/modules/_core/push"; import { sendPushToEndpoint } from "@/modules/_core/push";
type PushSubscriptionJSON = { type PushSubscriptionJSON = {
endpoint: string; endpoint: string;
@@ -35,9 +35,16 @@ export async function unsubscribeFromPush(endpoint: string): Promise<void> {
.where(and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint))); .where(and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)));
} }
export async function sendTestNotification(): Promise<void> { export async function sendTestNotification(endpoint: string): Promise<void> {
const { user } = await getCurrentSession(); const { user } = await getCurrentSession();
await sendPush(user.id, { // Verify the endpoint belongs to the current user before sending.
const [owned] = await db
.select({ id: pushSubscriptions.id })
.from(pushSubscriptions)
.where(and(eq(pushSubscriptions.userId, user.id), eq(pushSubscriptions.endpoint, endpoint)))
.limit(1);
if (!owned) return;
await sendPushToEndpoint(endpoint, {
title: "famapp test", title: "famapp test",
body: "Push notifications are working!", body: "Push notifications are working!",
url: "/settings", url: "/settings",
+26 -6
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { useState, useTransition, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
subscribeToPush, subscribeToPush,
@@ -23,6 +23,20 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [testSent, setTestSent] = useState(false); const [testSent, setTestSent] = useState(false);
// Hydrate subscription state from the browser on mount so the UI reflects
// reality even when the user navigates away and returns to Settings.
useEffect(() => {
if (!vapidKey || !("serviceWorker" in navigator) || !("PushManager" in window)) return;
navigator.serviceWorker.ready.then((reg) =>
reg.pushManager.getSubscription().then((existing) => {
if (existing) {
setEndpoint(existing.endpoint);
setStatus("subscribed");
}
}),
);
}, [vapidKey]);
if (!vapidKey) return null; if (!vapidKey) return null;
if (!("serviceWorker" in navigator) || !("PushManager" in window)) { if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
return ( return (
@@ -35,10 +49,15 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
async function subscribe() { async function subscribe() {
try { try {
const registration = await navigator.serviceWorker.ready; const registration = await navigator.serviceWorker.ready;
const sub = await registration.pushManager.subscribe({ // Reuse an existing browser subscription rather than calling subscribe()
userVisibleOnly: true, // again — avoids redundant prompts and inconsistent behaviour on iOS.
applicationServerKey: urlBase64ToUint8Array(vapidKey), const existing = await registration.pushManager.getSubscription();
}); const sub =
existing ??
(await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey),
}));
const json = sub.toJSON() as { endpoint: string; keys: { p256dh: string; auth: string } }; const json = sub.toJSON() as { endpoint: string; keys: { p256dh: string; auth: string } };
startTransition(async () => { startTransition(async () => {
await subscribeToPush(json, navigator.userAgent); await subscribeToPush(json, navigator.userAgent);
@@ -63,8 +82,9 @@ export function PushOptIn({ vapidKey }: { vapidKey: string }) {
} }
function sendTest() { function sendTest() {
if (!endpoint) return;
startTransition(async () => { startTransition(async () => {
await sendTestNotification(); await sendTestNotification(endpoint);
setTestSent(true); setTestSent(true);
setTimeout(() => setTestSent(false), 3000); setTimeout(() => setTestSent(false), 3000);
}); });
+10
View File
@@ -2,5 +2,15 @@ export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") { if (process.env.NEXT_RUNTIME === "nodejs") {
const { startReminderWorker } = await import("@/modules/_core/reminders"); const { startReminderWorker } = await import("@/modules/_core/reminders");
startReminderWorker(); startReminderWorker();
const vapidSubject = process.env["VAPID_SUBJECT"];
const vapidPublic = process.env["VAPID_PUBLIC_KEY"];
const vapidPrivate = process.env["VAPID_PRIVATE_KEY"];
if (!vapidSubject || !vapidPublic || !vapidPrivate) {
console.warn(
"[famapp] VAPID keys not configured — web push notifications are disabled. " +
"Run `pnpm vapid:generate` and add VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY to .env",
);
}
} }
} }
+1 -1
View File
@@ -22,6 +22,6 @@ export type { QuickAddItem, SerializedQuickAddItem, SerializedWidgetMeta } from
export { logActivity, logShareActivity } from "./activity"; export { logActivity, logShareActivity } from "./activity";
export { createShareLink, resolveShareToken, revokeShareLink } from "./share"; export { createShareLink, resolveShareToken, revokeShareLink } from "./share";
export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share"; export type { ShareLinkCapabilities, CreateShareLinkResult } from "./share";
export { sendPush } from "./push"; export { sendPush, sendPushToEndpoint } from "./push";
export { notify } from "./notify"; export { notify } from "./notify";
export { scheduleReminder, cancelReminder, listReminders, startReminderWorker } from "./reminders"; export { scheduleReminder, cancelReminder, listReminders, startReminderWorker } from "./reminders";
+26
View File
@@ -14,6 +14,32 @@ function ensureVapidConfigured() {
webPush.setVapidDetails(subject, publicKey, privateKey); webPush.setVapidDetails(subject, publicKey, privateKey);
} }
export async function sendPushToEndpoint(
endpoint: string,
payload: { title: string; body: string; url?: string },
) {
ensureVapidConfigured();
const [sub] = await db
.select()
.from(pushSubscriptions)
.where(eq(pushSubscriptions.endpoint, endpoint))
.limit(1);
if (!sub) return;
try {
await webPush.sendNotification(
{ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
JSON.stringify({ title: payload.title, body: payload.body, url: payload.url ?? "/" }),
);
} catch (err) {
const status = (err as { statusCode?: number }).statusCode;
if (status === 404 || status === 410) {
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint));
} else {
logger.error({ err }, "push delivery failed");
}
}
}
export async function sendPush( export async function sendPush(
userId: string, userId: string,
payload: { title: string; body: string; url?: string }, payload: { title: string; body: string; url?: string },