diff --git a/.env.example b/.env.example index a8548f3..21d2302 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,8 @@ PB_EMAIL= PB_PASSWORD= DEBUG_RECORD_ID= PUBLIC_PB_URL= + +# docker-compose (staging) +PORT=3001 +PB_DATA=./pb_data +PB_ENDPOINT=http://127.0.0.1:8090 diff --git a/docker/Dockerfile b/docker/Dockerfile index 62ed052..f8075f9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,7 @@ +# ── Build stage ────────────────────────────────────────── FROM node:22-alpine AS builder +ARG PUBLIC_PB_URL=/pb +ENV PUBLIC_PB_URL=$PUBLIC_PB_URL RUN corepack enable WORKDIR /app COPY . . @@ -6,8 +9,12 @@ RUN pnpm install --frozen-lockfile RUN pnpm --filter frontend build RUN pnpm --filter proxy build +# ── Runtime stage ──────────────────────────────────────── FROM node:22-alpine -RUN corepack enable && apk add --no-cache nginx +ARG PUBLIC_PB_URL=/pb +ENV PUBLIC_PB_URL=$PUBLIC_PB_URL +ENV PB_ENDPOINT=http://127.0.0.1:8090 +RUN corepack enable && apk add --no-cache nginx wget unzip ca-certificates WORKDIR /app COPY --from=builder /app/frontend/build ./frontend/build COPY --from=builder /app/proxy/dist ./proxy/dist @@ -15,8 +22,12 @@ COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/pnpm-lock.yaml ./ COPY docker/nginx.conf /etc/nginx/http.d/default.conf COPY docker/entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh +# Bundle PocketBase (same version as Dockerfile.dev) into the app container. +RUN wget -qO /tmp/pb.zip https://github.com/pocketbase/pocketbase/releases/download/v0.25.8/pocketbase_0.25.8_linux_amd64.zip \ + && unzip -o /tmp/pb.zip -d /usr/local/bin/ \ + && rm /tmp/pb.zip +RUN chmod +x /entrypoint.sh /usr/local/bin/pocketbase ENV FRONTEND_PORT=2080 ENV PROXY_PORT=3456 -EXPOSE 3001 +EXPOSE 3001 8090 CMD ["/entrypoint.sh"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 1119021..5235afa 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,6 +1,29 @@ #!/bin/sh set -e +PB_DATA=${PB_DATA_DIR:-/app/pb_data} + +# Seed a platform superuser from env (idempotent — no-op if it already exists). +# If PB_EMAIL/PB_PASSWORD aren't set, skip and rely on manual web setup. +if [ -n "$PB_EMAIL" ] && [ -n "$PB_PASSWORD" ]; then + echo "[entrypoint] Ensuring PocketBase superuser..." + pocketbase superuser upsert "$PB_EMAIL" "$PB_PASSWORD" --dir="$PB_DATA" || true +fi + +# Start PocketBase (internal only; published via loopback for admin UI). +pocketbase serve --http=0.0.0.0:8090 --dir="$PB_DATA" & + +# Wait for PB to be healthy before starting the proxy (which runs migrate). +echo "[entrypoint] Waiting for PocketBase..." +for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:8090/api/health >/dev/null 2>&1; then + echo "[entrypoint] PocketBase is healthy." + break + fi + sleep 1 +done + +# Start the app (frontend + proxy). The proxy auto-runs schema migration. PORT=${FRONTEND_PORT:-2080} node /app/frontend/build/index.js & PROXY_PORT=${PROXY_PORT:-3456} node /app/proxy/dist/index.js & diff --git a/docker/nginx.conf b/docker/nginx.conf index a3be060..05384a3 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -11,6 +11,7 @@ server { proxy_cache_bypass $http_upgrade; } + # App's Hono proxy (/api/*). location /api/ { proxy_pass http://127.0.0.1:3456; proxy_http_version 1.1; @@ -19,4 +20,25 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } + + # Browser → internal PocketBase SDK (REST + realtime WebSocket). Only the + # /api subtree the PocketBase JS SDK uses. The admin UI (/_ and everything + # else under /pb/) is intentionally NOT proxied, keeping it internal. + location /pb/api/ { + proxy_pass http://127.0.0.1:8090/api/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + 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_buffering off; + proxy_read_timeout 3600s; + } + + # Everything else under /pb/ (including /pb/_ admin UI) → 404. + location /pb/ { + return 404; + } } diff --git a/frontend/src/env.ts b/frontend/src/env.ts index afc4f39..ff58c7f 100644 --- a/frontend/src/env.ts +++ b/frontend/src/env.ts @@ -2,7 +2,5 @@ import { defineEnvVars } from '@sveltejs/kit/hooks'; export const variables = defineEnvVars({ DEBUG_RECORD_ID: {}, - SERVER_IP: {public: true}, - PB_PORT: {public: true}, - PUBLIC_PB_URL: {public: true} + PUBLIC_PB_URL: { public: true } }); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index da5c37f..5fdfb11 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,6 +1,6 @@ import type { Handle } from '@sveltejs/kit'; -const COOKIE_NAME = 'session'; +const COOKIE_NAME = 'famdon-sesh'; export const handle: Handle = async ({ event, resolve }) => { const raw = event.cookies.get(COOKIE_NAME); diff --git a/frontend/src/lib/client/api.ts b/frontend/src/lib/client/api.ts index b1dca22..6be0222 100644 --- a/frontend/src/lib/client/api.ts +++ b/frontend/src/lib/client/api.ts @@ -1,8 +1,10 @@ -import { SERVER_IP, PROXY_PORT } from '../../../../config.ts'; - -const BASE_URL = typeof window === 'undefined' - ? `http://${SERVER_IP}:${PROXY_PORT}` - : ''; +const BASE_URL = + typeof window === 'undefined' + ? typeof process !== 'undefined' + ? process.env.PROXY_URL || + `http://${process.env.SERVER_IP || '192.168.1.225'}:${process.env.PROXY_PORT || '3456'}` + : '' + : ''; async function memberFetch( method: string, diff --git a/frontend/src/lib/server/auth.ts b/frontend/src/lib/server/auth.ts index 087b4e7..4cdccf6 100644 --- a/frontend/src/lib/server/auth.ts +++ b/frontend/src/lib/server/auth.ts @@ -1,8 +1,8 @@ import { redirect } from '@sveltejs/kit'; import type { RequestEvent } from '@sveltejs/kit'; -import { SERVER_IP, PROXY_PORT } from '../../../../config.ts'; +import { PROXY_URL } from '$lib/server/env'; -const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; +const HONO_URL = PROXY_URL; export function getSession(event: RequestEvent) { return event.locals.session; diff --git a/frontend/src/lib/server/hono.ts b/frontend/src/lib/server/hono.ts index 1bc10aa..08e3a60 100644 --- a/frontend/src/lib/server/hono.ts +++ b/frontend/src/lib/server/hono.ts @@ -1,7 +1,7 @@ -import { SERVER_IP, PROXY_PORT } from '../../../../config.ts'; +import { PROXY_URL } from '$lib/server/env'; import type { RequestEvent } from '@sveltejs/kit'; -const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; +const HONO_URL = PROXY_URL; function sessionHeaders(event: RequestEvent): Record { const s = event.locals.session; diff --git a/frontend/src/lib/server/pb-admin.ts b/frontend/src/lib/server/pb-admin.ts index 4c109f8..15b693a 100644 --- a/frontend/src/lib/server/pb-admin.ts +++ b/frontend/src/lib/server/pb-admin.ts @@ -1,6 +1,4 @@ -import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from '../../../../config.ts'; - -const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`; +import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from './env'; let token: string | null = null; let tokenExpiry = 0; diff --git a/frontend/src/routes/[fam]/+layout.server.ts b/frontend/src/routes/[fam]/+layout.server.ts index 323289a..62a8d5e 100644 --- a/frontend/src/routes/[fam]/+layout.server.ts +++ b/frontend/src/routes/[fam]/+layout.server.ts @@ -1,6 +1,6 @@ -import { SERVER_IP, PROXY_PORT } from '../../../../config.ts'; +import { PROXY_URL } from '$lib/server/env'; -const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; +const HONO_URL = PROXY_URL; async function paydayCheck(famId: string, headers: Record) { try { diff --git a/frontend/src/routes/[fam]/[username]/+page.server.ts b/frontend/src/routes/[fam]/[username]/+page.server.ts index 789cc1c..8dee13b 100644 --- a/frontend/src/routes/[fam]/[username]/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/+page.server.ts @@ -1,9 +1,9 @@ import { fail, redirect } from '@sveltejs/kit'; import { hono } from '$lib/server/hono'; import { memberApi } from '$lib/client/api'; -import { SERVER_IP, PROXY_PORT } from '../../../../../config.ts'; +import { PROXY_URL } from '$lib/server/env'; -const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; +const HONO_URL = PROXY_URL; export async function load(event) { const session = event.locals.session; diff --git a/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts b/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts index 71ec924..8a0ad5f 100644 --- a/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts +++ b/frontend/src/routes/[fam]/[username]/preferences/+page.server.ts @@ -1,8 +1,8 @@ import { redirect } from '@sveltejs/kit'; import { hono } from '$lib/server/hono'; -import { SERVER_IP, PROXY_PORT } from '../../../../../../config.ts'; +import { PROXY_URL } from '$lib/server/env'; -const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; +const HONO_URL = PROXY_URL; export async function load(event) { const session = event.locals.session; diff --git a/frontend/src/routes/admin/+page.server.ts b/frontend/src/routes/admin/+page.server.ts index e04bf59..5bef2dc 100644 --- a/frontend/src/routes/admin/+page.server.ts +++ b/frontend/src/routes/admin/+page.server.ts @@ -1,6 +1,6 @@ import { pbAdmin } from '$lib/server/pb-admin'; import { redirect, fail } from '@sveltejs/kit'; -import { PB_EMAIL, PB_PASSWORD } from '../../../../config.ts'; +import { PB_EMAIL, PB_PASSWORD } from '$lib/server/env'; import type { Actions, PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ cookies }) => { diff --git a/frontend/src/routes/join/[code]/[member]/+page.server.ts b/frontend/src/routes/join/[code]/[member]/+page.server.ts index 9b1b6b6..3d4285a 100644 --- a/frontend/src/routes/join/[code]/[member]/+page.server.ts +++ b/frontend/src/routes/join/[code]/[member]/+page.server.ts @@ -1,8 +1,8 @@ import { fail, redirect } from '@sveltejs/kit'; import { setDeviceTokenCookie } from '$lib/server/auth'; -import { SERVER_IP, PROXY_PORT } from '../../../../../../config.ts'; +import { PROXY_URL } from '$lib/server/env'; -const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`; +const HONO_URL = PROXY_URL; export const actions = { default: async (event) => { diff --git a/package.json b/package.json index 4f0975d..2c82df9 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,16 @@ { "name": "famchamp-monorepo", "private": true, + "packageManager": "pnpm@10.30.3", "scripts": { "dev": "lsof -ti tcp:3456 | xargs -r kill -9 && pnpm -r --parallel dev", "start": "pnpm dev", "build": "pnpm -r build" }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + }, "version": "0.2.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f98732..585525f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: '@types/node': specifier: ^26.0.0 version: 26.0.0 + esbuild: + specifier: ^0.28.1 + version: 0.28.1 tsx: specifier: ^4.19.0 version: 4.22.4 diff --git a/proxy/package.json b/proxy/package.json index 679e3a3..10736ad 100644 --- a/proxy/package.json +++ b/proxy/package.json @@ -3,9 +3,9 @@ "private": true, "type": "module", "scripts": { - "dev": "tsx watch src/index.ts", - "build": "tsc", - "start": "node dist/index.js", + "dev": "tsx watch src/index.ts", + "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js", + "start": "node dist/index.js", "seed": "tsx scripts/seed.ts" }, "dependencies": { @@ -14,6 +14,7 @@ }, "devDependencies": { "@types/node": "^26.0.0", + "esbuild": "^0.28.1", "tsx": "^4.19.0", "typescript": "^5.7.0" } diff --git a/proxy/src/migrate.ts b/proxy/src/migrate.ts index f42db8a..60fd6aa 100644 --- a/proxy/src/migrate.ts +++ b/proxy/src/migrate.ts @@ -1,6 +1,4 @@ -import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts"; - -const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`; +import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from "./env.ts"; let token: string | null = null; @@ -32,7 +30,7 @@ async function getCollection(name: string): Promise { return data?.items?.[0] || null; } -async function createCollection(col: any): Promise { +async function createCollection(col: any): Promise { const t = await auth(); const res = await fetch(`${PB_ENDPOINT}/api/collections`, { method: "POST", @@ -46,6 +44,7 @@ async function createCollection(col: any): Promise { if (!res.ok) throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`); console.log(` ✓ Created collection: ${col.name}`); + return data?.id || null; } async function updateCollection(id: string, col: any): Promise { @@ -64,9 +63,223 @@ async function updateCollection(id: string, col: any): Promise { console.log(` ✓ Updated collection: ${col.name || id}`); } +// ── Field helpers (mirror proxy/scripts/seed.ts) ── +function text(name: string, required = false) { + return { name, type: "text", required }; +} +function uniqueText(name: string) { + return { name, type: "text", required: true, unique: true }; +} +function number(name: string, required = false) { + return { name, type: "number", required }; +} +function bool(name: string) { + return { name, type: "bool" }; +} +function date(name: string) { + return { name, type: "date" }; +} +function jsonField(name: string) { + return { name, type: "json" }; +} +function select(name: string, values: string[], required = false) { + return { name, type: "select", required, values, maxSelect: 1 }; +} +function rel(name: string, collectionId: string, required = false) { + return { + name, + type: "relation", + required, + collectionId, + maxSelect: 1, + cascadeDelete: false, + }; +} +function colDef(name: string, fields: any[]): any { + return { + name, + type: "base", + listRule: "", + viewRule: "", + createRule: null, + updateRule: null, + deleteRule: null, + fields, + }; +} + +// Bootstrap the full base schema on a fresh PocketBase (docker first boot). +// Idempotent — skips collections that already exist. +async function ensureSchema(): Promise { + if (await getCollection("fams")) { + console.log("[migrate] Base schema already present — skipping bootstrap."); + return; + } + console.log("[migrate] Bootstrapping base schema on fresh PocketBase..."); + + // Pass 1: independent + const famsId = (await createCollection( + colDef("fams", [ + text("name", true), + uniqueText("slug"), + text("inviteCode"), + text("stripeCustomerId"), + jsonField("featureFlags"), + jsonField("seasons"), + ]), + ))!; + await createCollection( + colDef("bonus_templates", [ + rel("famId", famsId, true), + text("name", true), + text("description"), + select("target", ["individual", "competitive", "collaborative"], true), + select("type", ["threshold", "count", "manual"], true), + select("occurrence", ["recurring", "once"], true), + select("rewardType", ["points", "cash", "prize"], true), + text("rewardValue", true), + number("criteriaValue"), + select("period", ["schedule", "daily", "weekly", "monthly"]), + ]), + ); + + // Pass 2: reference fams + await createCollection( + colDef("settings", [rel("famId", famsId, true), text("webhookUrl")]), + ); + const membersId = (await createCollection( + colDef("members", [ + rel("famId", famsId, true), + text("name", true), + text("color"), + text("deviceToken"), + text("deviceTokenHint"), + ]), + ))!; + const choreTemplatesId = (await createCollection( + colDef("chore_templates", [ + rel("famId", famsId, true), + text("name", true), + text("description"), + select("defaultFrequency", ["daily", "weekly"], true), + select("defaultType", ["points", "money"], true), + number("defaultValue", true), + ]), + ))!; + await createCollection( + colDef("fam_admins", [ + rel("famId", famsId, true), + text("userId", true), + text("email", true), + text("name"), + text("color"), + ]), + ); + const bonusConfigsId = (await createCollection( + colDef("bonus_configs", [ + rel("famId", famsId, true), + text("name", true), + text("description"), + select("target", ["individual", "competitive", "collaborative"], true), + select("type", ["threshold", "count", "manual"], true), + select("occurrence", ["recurring", "once"], true), + select("rewardType", ["points", "cash", "prize"], true), + text("rewardValue", true), + number("criteriaValue"), + rel("memberId", membersId), + select("period", ["schedule", "daily", "weekly", "monthly"]), + select("status", ["active", "completed"], true), + ]), + ))!; + + // Pass 3: nested deps + await createCollection( + colDef("weekly_history", [ + rel("famId", famsId, true), + rel("memberId", membersId, true), + date("weekStart"), + number("pointsEarned"), + number("moneyEarned"), + number("choresCompleted"), + number("bonusEarned"), + ]), + ); + await createCollection( + colDef("rewards", [ + rel("famId", famsId, true), + rel("memberId", membersId, true), + rel("bonusConfigId", bonusConfigsId), + text("label", true), + number("value", true), + select("rewardType", ["cash", "prize", "points"], true), + select("status", ["unclaimed", "requested", "claimed"], true), + select("claimable", ["immediate", "payday"], true), + text("settleDate"), + date("claimedAt"), + date("requestedAt"), + text("date"), + ]), + ); + const assignedChoresId = (await createCollection( + colDef("assigned_chores", [ + rel("famId", famsId, true), + rel("memberId", membersId, true), + rel("templateId", choreTemplatesId, true), + select("frequency", ["daily", "weekly"], true), + select("type", ["points", "money"], true), + number("value", true), + text("customName"), + jsonField("seasonIds"), + ]), + ))!; + await createCollection( + colDef("seasons", [ + rel("famId", famsId, true), + text("name", true), + text("color"), + bool("active"), + date("autoDisable"), + date("autoStart"), + ]), + ); + await createCollection( + colDef("completions", [ + rel("famId", famsId, true), + rel("memberId", membersId, true), + rel("assignedChoreId", assignedChoresId, true), + date("date"), + date("completedAt"), + ]), + ); + await createCollection( + colDef("messages", [ + rel("famId", famsId, true), + select("authorType", ["admin", "member"], true), + text("authorId", true), + text("authorName", true), + text("authorColor"), + text("content", true), + date("createdAt"), + ]), + ); + await createCollection( + colDef("chat_typing", [ + rel("famId", famsId, true), + text("actorId", true), + select("actorType", ["admin", "member"], true), + text("authorName", true), + text("authorColor"), + bool("typing"), + ]), + ); + console.log("[migrate] Base schema bootstrapped."); +} + export async function migrate(): Promise { console.log("[migrate] Checking PB collection schemas..."); + await ensureSchema(); + // ── 1. Create bonus_configs if missing ── const existing = await getCollection("bonus_configs"); if (!existing) { diff --git a/proxy/src/pb.ts b/proxy/src/pb.ts index 4ffac0d..b745054 100644 --- a/proxy/src/pb.ts +++ b/proxy/src/pb.ts @@ -1,6 +1,4 @@ -import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts"; - -const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`; +import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from "./env.ts"; let adminToken: string | null = null; let tokenExpiry = 0;