Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65d36bab4e | ||
|
|
298e6b868d | ||
|
|
6d45b7e836 | ||
|
|
7f2c5a44dd | ||
|
|
dd980c6932 | ||
|
|
6b25d67537 | ||
|
|
080d26816e | ||
|
|
0b0deff700 | ||
|
|
eabcebdb00 | ||
|
|
0fef3d4ab6 | ||
|
|
fe282ba470 | ||
|
|
f3e38c576c | ||
|
|
a86f5471ce | ||
|
|
5f6b756342 | ||
|
|
2fd0677c5f | ||
|
|
5dcd49d4c9 | ||
|
|
29795e0d51 | ||
|
|
8921cb0444 | ||
|
|
e3d2b5a364 | ||
|
|
ed310d042f | ||
|
|
30af9f63bf | ||
|
|
e9bb2a5444 | ||
|
|
a95f10fcde | ||
|
|
b255aaeac1 | ||
|
|
19308be768 | ||
|
|
9612a54e52 | ||
|
|
e130cda6c5 | ||
|
|
2129f9718f | ||
|
|
484f747f66 | ||
|
|
c66bf68373 | ||
|
|
1e076bd2f3 | ||
|
|
64b8180d9a | ||
|
|
39fcd54f0c | ||
|
|
524efac05f | ||
|
|
bb3a770cf3 | ||
|
|
01945fbd0a | ||
|
|
1185f5ab50 | ||
|
|
c73338e256 | ||
|
|
285a460eb8 | ||
|
|
b6abe052c9 | ||
|
|
ec20f1bc87 | ||
|
|
afff22e994 | ||
|
|
8aab25e0ac | ||
|
|
d5a8bf9d95 | ||
|
|
2ad9521ef9 | ||
|
|
8fda3e47dd | ||
|
|
734268cb75 | ||
|
|
39aa5704f9 | ||
|
|
d5deee9a46 | ||
|
|
28475e483d | ||
|
|
1133fa8aac | ||
|
|
b9d3cce73a | ||
|
|
b4dde92757 | ||
|
|
d7069d14a2 |
@@ -2,7 +2,6 @@ node_modules
|
||||
.next
|
||||
.git
|
||||
deploy
|
||||
drizzle
|
||||
docs
|
||||
*.md
|
||||
.env*
|
||||
|
||||
+13
-1
@@ -19,7 +19,7 @@ AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
|
||||
AUTH_OIDC_CLIENT_ID=replace-me
|
||||
AUTH_OIDC_CLIENT_SECRET=replace-me
|
||||
|
||||
# Web Push (generate with: pnpm vapid:generate)
|
||||
# Web Push (generate with: pnpm vapid:generate — copy all three lines to .env)
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:you@example.com
|
||||
@@ -30,3 +30,15 @@ NTFY_TOPIC=
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
# GitHub (required for pnpm release — creates a GitHub Release)
|
||||
GITHUB_TOKEN=
|
||||
|
||||
# MinIO object storage (used for plant/container image uploads)
|
||||
MINIO_ENDPOINT=http://localhost:9000
|
||||
MINIO_ROOT_USER=famapp
|
||||
MINIO_ROOT_PASSWORD=changeme
|
||||
MINIO_BUCKET=garden
|
||||
|
||||
# Perenual plant species API (https://perenual.com — free tier available)
|
||||
PERENUAL_API_KEY=
|
||||
|
||||
+19
-8
@@ -1,7 +1,17 @@
|
||||
# ── famapp ────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Public URL for the app (used in share links, OIDC redirect URIs, etc.)
|
||||
NEXT_PUBLIC_APP_URL=https://fam.ginnoir.com
|
||||
# Image tag to deploy. Pin to a specific version after first deploy
|
||||
# (e.g. ghcr.io/ginnoir/famapp:v0.1.0). `latest` is fine for staging/initial.
|
||||
FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:latest
|
||||
# `always` pulls on every `up`; set `missing` if you want to skip pulls.
|
||||
FAMAPP_PULL_POLICY=always
|
||||
# Authentik image tag. Bump in lockstep with Authentik release notes.
|
||||
AUTHENTIK_IMAGE_TAG=2024.12.3
|
||||
# Run drizzle migrations on container start. Leave true.
|
||||
RUN_MIGRATIONS=true
|
||||
|
||||
# Public URL for the app — used by Auth.js for OIDC redirect URIs.
|
||||
AUTH_URL=https://fam.ginnoir.com
|
||||
|
||||
# famapp Postgres credentials (used to build DATABASE_URL inside compose.yaml)
|
||||
FAMAPP_DB_USER=famapp
|
||||
@@ -11,20 +21,21 @@ FAMAPP_DB_NAME=famapp
|
||||
# Auth.js session secret — generate with: openssl rand -base64 32
|
||||
AUTH_SECRET=replace-with-openssl-rand-base64-32
|
||||
|
||||
# OIDC provider (Authentik) — task 06 will fill these in after bootstrapping
|
||||
# OIDC provider (Authentik) — fill in after bootstrapping Authentik
|
||||
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
|
||||
AUTH_OIDC_CLIENT_ID=replace-me
|
||||
AUTH_OIDC_CLIENT_SECRET=replace-me
|
||||
|
||||
# Web Push VAPID keys — generate with: pnpm vapid:generate
|
||||
# Web Push VAPID keys — generate with: pnpm vapid:generate (run from the repo)
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
# Must be "mailto:<address>" or a URL
|
||||
VAPID_SUBJECT=mailto:you@example.com
|
||||
VAPID_SUBJECT=mailto:3nigma.matt@gmail.com
|
||||
|
||||
# ntfy (optional push fallback — leave blank to disable)
|
||||
NTFY_URL=
|
||||
NTFY_TOPIC=
|
||||
# ntfy push fallback — uses your existing ntfy instance at ntfy.ginnoir.com
|
||||
# Leave NTFY_URL blank to disable ntfy fanout.
|
||||
NTFY_URL=https://ntfy.ginnoir.com
|
||||
NTFY_TOPIC=famapp
|
||||
|
||||
# Log level: error | warn | info | debug
|
||||
LOG_LEVEL=info
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.33.3
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm typecheck
|
||||
|
||||
- run: pnpm lint
|
||||
|
||||
- run: pnpm format:check
|
||||
|
||||
- run: pnpm build
|
||||
env:
|
||||
# Build only — no DB connection. Provide a placeholder so any
|
||||
# module-level reads of DATABASE_URL don't crash the build.
|
||||
DATABASE_URL: postgres://placeholder:placeholder@localhost:5432/placeholder
|
||||
AUTH_SECRET: ci-placeholder-secret
|
||||
NEXT_PUBLIC_APP_URL: http://localhost:3000
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
+7
-2
@@ -9,6 +9,9 @@ dist/
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Generated at build time by next.config.ts — do not commit
|
||||
public/sw.js
|
||||
|
||||
# Auto-generated by Next.js — never edit, never commit
|
||||
next-env.d.ts
|
||||
|
||||
@@ -41,8 +44,10 @@ playwright-report/
|
||||
test-results/
|
||||
tests/.auth/
|
||||
|
||||
# Drizzle generated artifacts (migrations themselves are committed)
|
||||
drizzle/meta/
|
||||
# drizzle/meta/ intentionally committed — migrator requires _journal.json at runtime
|
||||
|
||||
# Backups
|
||||
deploy/backups/data/
|
||||
|
||||
# Design handoff bundle (reference only, not committed)
|
||||
.design-tmp/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pnpm commitlint --edit "$1"
|
||||
@@ -0,0 +1 @@
|
||||
pnpm lint-staged
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"npm": {
|
||||
"publish": false
|
||||
},
|
||||
"git": {
|
||||
"commitMessage": "chore: release v${version}",
|
||||
"tagName": "v${version}",
|
||||
"requireBranch": "main"
|
||||
},
|
||||
"github": {
|
||||
"release": true,
|
||||
"releaseName": "v${version}"
|
||||
},
|
||||
"plugins": {
|
||||
"@release-it/conventional-changelog": {
|
||||
"preset": {
|
||||
"name": "conventionalcommits",
|
||||
"types": [
|
||||
{ "type": "feat", "section": "Features" },
|
||||
{ "type": "fix", "section": "Bug Fixes" },
|
||||
{ "type": "perf", "section": "Performance" },
|
||||
{ "type": "refactor", "section": "Refactoring" },
|
||||
{ "type": "docs", "section": "Documentation" },
|
||||
{ "type": "chore", "section": "Maintenance", "hidden": true },
|
||||
{ "type": "ci", "section": "CI/CD", "hidden": true },
|
||||
{ "type": "test", "section": "Tests", "hidden": true }
|
||||
]
|
||||
},
|
||||
"infile": "CHANGELOG.md"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to famapp are documented here.
|
||||
|
||||
## [0.3.0](https://github.com/ginnoir/famapp/compare/v0.2.0...v0.3.0) (2026-06-01)
|
||||
|
||||
### Features
|
||||
|
||||
- add dev startup script, fix push notifications, and scope test sends to device ([0c12b05](https://github.com/ginnoir/famapp/commit/0c12b05d0989630b3ec1445f65048bd766d5f8d1))
|
||||
- expand theme system to 10 full-skin palettes with light and dark modes ([c3c8ae3](https://github.com/ginnoir/famapp/commit/c3c8ae37a280206186dfde82ecb270aea5e0c512))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- refresh server components after nav style change to fix broken layout ([f55d685](https://github.com/ginnoir/famapp/commit/f55d685a794a539d2d6256d97c1a9c4e5fa3b8bf))
|
||||
- scope rail nav-label hiding to sidebar and fall back fab-only to sidebar on desktop ([671b5fa](https://github.com/ginnoir/famapp/commit/671b5fae469b3cfcafe08b20ee2ce8ac15eef994))
|
||||
|
||||
### Documentation
|
||||
|
||||
- add github_token to .env.example ([5630537](https://github.com/ginnoir/famapp/commit/563053727908186a9816416fdf54c914002011de))
|
||||
|
||||
## Pre-release history
|
||||
|
||||
Changes from before the release workflow was established (2026-06-01):
|
||||
|
||||
- Production deployment scaffolding: tag-based release workflow (GHCR), migration entrypoint in container, prod-side dev-login startup assertion, image-pinned compose, pre-deploy checklist.
|
||||
- famapp/authentik-server now expose host ports (3010/9200) so existing Caddy stack can proxy by IP, matching the existing homelab pattern.
|
||||
- Auth.js: replaced `NEXT_PUBLIC_APP_URL` with `AUTH_URL` in compose (correct next-auth v5 var).
|
||||
- Push opt-in: VAPID public key now passed as prop from server component instead of `NEXT_PUBLIC_*` so pre-built images work without a build-time env var.
|
||||
- ntfy integration wired to existing `ntfy.ginnoir.com` instance via `NTFY_URL`/`NTFY_TOPIC` env vars.
|
||||
+13
-1
@@ -32,6 +32,18 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
|
||||
# Migration assets — drizzle/ holds SQL + meta journal; migrate.mjs runs
|
||||
# at startup via the entrypoint. Explicitly copy the full drizzle-orm and
|
||||
# postgres packages because the standalone tracer only includes subpaths
|
||||
# the app itself imports, not the postgres-js/migrator subpath.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts/migrate.mjs ./scripts/migrate.mjs
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts/seed.mjs ./scripts/seed.mjs
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/drizzle-orm ./node_modules/drizzle-orm
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres ./node_modules/postgres
|
||||
COPY --chown=nextjs:nodejs docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
|
||||
@@ -19,10 +19,32 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
|
||||
- **10 — Calendar module**. Added `calendars` and `calendar_events` schema + migration `0003_rainy_ravenous.sql`, default Home/Personal calendar seeding, first-login default calendar creation, visibility-safe calendar/event queries, CRUD server actions, FullCalendar-backed `/calendar` UI with sidebar calendar management and event create/edit/delete/drag updates. Calendar manifest now registers share/reminder/search capabilities, two configurable widgets, and quick-add entries. Added Playwright happy-path spec in `tests/e2e/calendar.spec.ts`. `pnpm db:generate`, `pnpm typecheck`, `pnpm lint`, and `pnpm build` pass.
|
||||
- **11 — Lists module**. Added `lists` and `list_items` schema + migration `0004_opposite_wraith.sql`, default Shopping/Tasks seeding on first access/sign-in/seed, household-gated list and item CRUD server actions, reorder support, and Postgres `LISTEN/NOTIFY` to SSE bridge documented in ADR `0002`. Added `/lists` grouped index, `/lists/[id]` keyboard-first item entry with checkbox toggles and swipe/delete, manifest entity/search/widget/quick-add registrations, and Playwright happy-path spec in `tests/e2e/lists.spec.ts`. `pnpm typecheck`, `pnpm lint`, and `pnpm build` pass.
|
||||
- **12 — Notes module**. Added generic core `reminders` table plus household-scoped `notes` schema in migration `0006_new_hannibal_king.sql`, notes CRUD server actions, reminder synchronization for `notes.note`, `/notes` index, `/notes/new`, `/notes/[id]` editor with safe markdown preview, manifest entity/search/reminder/share registration, `notes.filtered` widget registration, quick-add placeholder, and Playwright happy-path spec in `tests/e2e/notes.spec.ts`. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and notes E2E pass.
|
||||
- **20 — Dashboard composition (single-dashboard MVP)**. Added `default_dashboard_layout` jsonb column to `users` + migration `0007_uneven_living_lightning.sql`. Created `src/modules/_core/manifest.tsx` (`core.activity` placeholder widget) and registered it. Updated all three module manifests (calendar, lists, notes) with real async server component widget renders (data-fetching, empty states). Created `src/lib/dashboard.ts` (layout parsing + `computeDefaultLayout` greedy packer). Built `src/app/page.tsx` — 12-col CSS Grid, static `smColSpan` lookup for Tailwind class safety, per-widget `<Suspense>` for parallel loading, graceful skip for unknown widget IDs. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
|
||||
- **21 — Quick-add registry**. Added `url: string` to `QuickAddAction` type (action is now optional). Added `getQuickAdds()` / `SerializedQuickAddItem` to registry (strips non-serializable `action` fn before crossing server→client boundary). Updated all three module manifests with navigation URLs. Built `QuickAddProvider` (context + cmd+k global shortcut), `QuickAddFab` (opens sheet, replaces plain button in dashboard), `QuickAddSheet` (bottom drawer / desktop popover grouped by module), and `CommandPalette` (cmdk-powered modal with arrow + enter + esc keyboard nav). Provider in root layout receives actions from `getQuickAdds()` at render time — adding a module's `quickAdds` automatically appears in both surfaces. Also added `.claude/**` to ESLint ignores to prevent stale worktree build artifacts from failing lint. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
|
||||
- **22 — Activity log**. Added `activity_log` table to `_core/schema.ts` with index on `(household_id, created_at desc)`. Migration `0008_activity_log.sql` applied. `logActivity()` server function in `_core/activity.ts` reads current session and inserts a row. Added `ActivityLogEntry` type and optional `renderActivity?(entry): string` to `EntityTypeRegistration` in `_core/module.ts`. All three module manifests implement `renderActivity` for each entity type (human-readable, no hardcoded branches in the widget). Replaced `core.activity` widget stub with a real async server component that queries the last 20 rows via `getEntityType(entry.entityType)?.renderActivity(entry)`. Wired `logActivity()` into every create/update/delete in calendar, lists, and notes server actions. Also added `text` to `getAuthorizedItem` select so toggle/delete log the item text. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
|
||||
- **30 — Share-link service**. Added `share_links` table to `_core/schema.ts` + migration `0009_share_links.sql`. Created `_core/share.ts` with `createShareLink`, `resolveShareToken`, `revokeShareLink`, and `getActiveShareLinks`. Token is 32 random bytes (URL-safe base64), stored as SHA-256 hash — raw token only returned at creation. `createShareLink` guards that the entity type is registered with `canShare === true`. `resolveShareToken` returns null for expired or revoked tokens. All three functions exported from `_core/index.ts`. `/settings` page gained a Share links card: lists active links (entity label, read/write capabilities, expiry) with a Revoke button per link (server action in `settings/actions.ts`). `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
|
||||
- **25 — Multiple dashboards per user**. Added `dashboards` table (migration `0012_dashboards.sql`). Migrated each user's `default_dashboard_layout` into a "Home" dashboard row with `is_default = true`; dropped the interim column. Server actions: `listDashboards`, `createDashboard`, `renameDashboard`, `deleteDashboard`, `setDefaultDashboard`, `reorderDashboards`, `saveDashboardLayout`, `resetDashboardLayout`, `resolveWidgetConfigOptions`. `/` redirects to the user's default `/d/<slug>`. Dashboard switcher in AppNav renders tabs (active highlighted client-side) with a `+` button to create new dashboards and a kebab menu on the active tab for rename / set-default / delete. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
|
||||
- **26 — Customizable layout + widget configuration**. Installed `react-grid-layout` v2. Dashboard pages check `?edit=1` to enter edit mode, rendering a client `DashboardEditor` instead of the static grid. Editor uses `react-grid-layout` with `gridConfig`/`dragConfig` v2 API; each widget shell shows a drag handle, configure button (⚙), and remove button (🗑). `WidgetPicker` is a two-step modal: step 1 lists all registry widgets grouped by category; step 2 is a `WidgetConfigurator` auto-generated from the widget's default config — handles `"all"|string[]` multi-selects, booleans, numbers, and enums. `resolveWidgetConfigOptions` server action fetches dynamic options (calendars, lists). Save validates each config against its registered Zod schema. Reset to defaults calls `computeDefaultLayout()`. `pnpm typecheck`, `pnpm build` pass.
|
||||
- **31 — Public share viewer**. Made `actorId` nullable in `activity_log` (migration `0010_nullable_actor_id.sql`, `onDelete: "set null"`) for anonymous share-page mutations. Added `logShareActivity` to `_core/activity.ts` (no session, explicit `householdId`). Added `householdId` to `resolveShareToken` return. Added `renderSharedView` to `EntityTypeRegistration` type. Each module implements `loadForShare` (bare DB queries, no session) and `renderSharedView`: calendar shows upcoming 90-day events or single-event details, lists shows items with optional toggle, notes shows title + body. `toggleShareListItem` server action lives in `lists/server/share-actions.ts` — validates token write capability, verifies item→list→household chain, logs `share.toggle` with `actorId = null`. `/app/s/[token]/page.tsx` resolves token, dispatches to `loadForShare` + `renderSharedView`, returns friendly error for invalid/expired tokens, sets `noindex`. Middleware `/s/*` exemption confirmed present. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and all 4 E2E specs pass.
|
||||
|
||||
- **50 — PWA shell**. `public/manifest.webmanifest` (name, short_name, icons, theme_color, display: standalone, start_url `/`). Placeholder PNG icons at 180, 192, 384, 512 (regular + maskable) generated by `scripts/generate-icons.mjs` (`pnpm gen:icons`); `public/icon.svg` committed as source. Hand-rolled service worker at `public/sw.js`: precaches `offline.html` on install, cache-first for `/_next/static/`, network-first for navigation with offline fallback, network-only for API routes. `src/components/pwa-register.tsx` registers the SW client-side. `src/components/install-prompt.tsx` shows a dismissible banner: `beforeinstallprompt` on Android/Chrome, a one-time "Add to Home Screen" hint on iOS (detected via UA + `navigator.maxTouchPoints`, suppressed in standalone mode). Root layout exports `viewport` (themeColor), updated `metadata` (manifest, appleWebApp, apple-touch-icon), and mounts both new components. `pnpm typecheck`, `pnpm lint`, `pnpm build` all clean.
|
||||
- **51 — Offline shell + service worker caching**. `next.config.ts` generates `public/sw.js` as a side effect on every `next build` / `next dev` invocation, embedding a build timestamp as `CACHE_VERSION` (stable `"dev"` string in development to avoid hot-reload cache churn; epoch milliseconds in production). SW strategies: stale-while-revalidate for `/_next/static/` chunks and navigation HTML (cached page served instantly, network update fires in background); network-first with 2-second abort timeout for API GETs falling back to cache; network-only for mutations (POST/PATCH/DELETE/PUT) — if offline, all controlled clients receive `{ type: "OFFLINE_MUTATION" }` via `postMessage` and a synthetic 503 is returned. Activate handler evicts all `famapp-*` caches whose suffix doesn't match the current version, then claims clients. `pwa-register.tsx` extended with three inline toasts: amber "offline" banner (persistent, driven by `navigator.onLine` + `online`/`offline` events), red "changes can't be saved" toast (auto-dismisses in 4 s, driven by SW postMessage), and indigo "new version available — refresh" bottom toast (driven by `controllerchange` with `hadController` guard). `pnpm typecheck`, `pnpm build` pass.
|
||||
|
||||
- **40 — Web Push (VAPID)**. Installed `web-push` + `@types/web-push`. Added `pnpm vapid:generate` script (`scripts/vapid-generate.mjs`) that prints all three env vars to stdout. Added `push_subscriptions` table to `_core/schema.ts` + migration `0013_push_notify_reminders.sql`. Created `_core/push.ts` with `sendPush(userId, payload)` — iterates subscriptions, removes 404/410 stale entries. Added `push` and `notificationclick` event handlers to the generated `public/sw.js` template. Created `<PushOptIn />` client component on `/settings` (opt-in button → `subscribeToPush` server action, disable button → `unsubscribeFromPush`, test button → `sendTestNotification`). Documented `NEXT_PUBLIC_VAPID_PUBLIC_KEY` in `.env.example`. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
|
||||
|
||||
- **42 — Notification bus + ntfy adapter**. Added `notifications` table and `notif_push`/`notif_inapp`/`notif_ntfy` columns on `users` (migration `0013_push_notify_reminders.sql`). Created `_core/notify.ts` with `notify(userId, { title, body, url, channels? })` — fans out to push (if VAPID configured), in-app DB insert, and ntfy POST (if `NTFY_URL`+`NTFY_TOPIC` set). Added `<NotificationBell />` async server component in `AppNav`: queries last 20 notifications, shows unread badge, dropdown inbox with mark-read and mark-all-read. Added `<NotifyChannelToggles />` client component with per-channel checkboxes in `/settings`. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
|
||||
|
||||
- **41 — Reminders engine**. Added `fired_at` and `created_by` columns to `reminders` table (migration `0013_push_notify_reminders.sql`); default channel changed to `'auto'`. Created `_core/reminders.ts` with `scheduleReminder` (upsert by entity), `cancelReminder`, `listReminders`, and `tickReminders` (30 s tick, `pg_try_advisory_xact_lock` guard). `startReminderWorker()` started via `src/instrumentation.ts` on the Node.js runtime. Notes actions updated to use `scheduleReminder`/`cancelReminder` instead of raw SQL. Calendar `createEvent` accepts optional `remindMinutesBefore` and schedules a reminder; `deleteEvent` calls `cancelReminder`. Calendar-shell event dialog shows "Remind me 30 min before" checkbox (new events only, checked by default). Reminder worker confirmed starting on server boot (logged in dev server output). `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
|
||||
|
||||
- **60 — Postgres backups**. `famapp-backup` Alpine service added to `deploy/compose.yaml`; scripts in `deploy/backups/`: `backup.sh` (`pg_dump -Fc` for famapp-db + authentik-db nightly at 02:00), `retain.sh` (14 daily / 8 weekly / 6 monthly), `restore.sh` (restore from any dump file), `entrypoint.sh` (installs postgresql-client, sets up crontab, starts crond). Backup files stored in `backups` named Docker volume. Restore procedure in `deploy/backups/README.md`. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
|
||||
- **61 — Rate limiting on share links**. `src/lib/rate-limit.ts`: pure-JS Edge-compatible sliding-window counter (50 req/min, 1-min window, 10k-key LRU eviction) with `consume()`, `isRateLimited()`, and `recordFailure()` exports. `src/middleware.ts` calls `consume(ip:prefix)` for every `/s/[token]` request and returns 429 with `Retry-After: 60` when the bucket is exceeded. `src/app/s/[token]/page.tsx` additionally tracks only failed `resolveShareToken` lookups via `recordFailure()` in the Node.js runtime (separate module instance from middleware; Redis would unify them for multi-replica deployments). `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
|
||||
- **62 — Structured logging**. Installed `pino` + `pino-pretty` (dev). `src/lib/logger.ts`: pino instance — JSON in production (`stdout`), pretty-printed in dev; level from `LOG_LEVEL` env (default `info`); `pid`/`hostname` stripped, ISO timestamps. `src/middleware.ts` logs every request as structured JSON via `console.log` (Edge-compatible; pino not available in Edge runtime) with `method`, `path`, `status`, `ms`, `authenticated`. All `console.error`/`console.log` calls in `_core/push.ts`, `_core/notify.ts`, `_core/reminders.ts` replaced with `logger.error`/`logger.info`; sensitive fields (endpoint URLs, keys) are never logged as named fields. `next.config.ts` adds `serverExternalPackages: ["pino","pino-pretty"]` so webpack does not bundle them. `pnpm typecheck`, `pnpm lint`, `pnpm build` pass.
|
||||
|
||||
## Next up
|
||||
|
||||
- Next task in `docs/tasks/`.
|
||||
- **Ready to tag v0.1.0.** All phase 1–7 tasks complete and production wiring verified.
|
||||
- Run `docs/tasks/09-pre-deploy-checklist.md` before pushing the tag.
|
||||
- On the server: clone repo to `/srv/famapp`, copy `.env.production.example` → `/srv/famapp/deploy/.env`, fill in secrets (AUTH_SECRET, DB passwords, AUTHENTIK_SECRET_KEY, VAPID keys, AUTH_OIDC_CLIENT_ID/SECRET), add the Caddyfile snippet, bootstrap Authentik per `deploy/authentik/README.md`, then `docker compose -f deploy/compose.yaml up -d`.
|
||||
|
||||
## Development login/testing notes
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export default {
|
||||
extends: ["@commitlint/config-conventional"],
|
||||
rules: {
|
||||
"type-enum": [
|
||||
2,
|
||||
"always",
|
||||
["feat", "fix", "refactor", "docs", "test", "chore", "perf", "ci", "revert"],
|
||||
],
|
||||
"subject-case": [2, "always", "lower-case"],
|
||||
"subject-max-length": [2, "always", 100],
|
||||
},
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
# Include from main Caddyfile or paste into the existing one.
|
||||
# famapp — paste into your existing Caddyfile.
|
||||
# famapp runs on 3010, authentik-server on 9200 (both bound to the host by deploy/compose.yaml).
|
||||
|
||||
fam.ginnoir.com {
|
||||
reverse_proxy famapp:3000
|
||||
reverse_proxy 192.168.1.69:3010
|
||||
}
|
||||
|
||||
auth.ginnoir.com {
|
||||
reverse_proxy authentik-server:9000
|
||||
reverse_proxy 192.168.1.69:9200
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Deploying famapp
|
||||
|
||||
Trunk-based: `main` is always green. Production deploys only from version tags (`vX.Y.Z`). The dev-login flow is retained for local development behind a double gate (`NODE_ENV !== "production"` **and** `ENABLE_DEV_LOGIN=true`); a startup assertion in `src/lib/dev-login-config.ts` makes a misconfigured prod fail loud instead of silently exposing it.
|
||||
|
||||
## One-time host setup
|
||||
|
||||
1. Install Docker + Compose plugin on the host.
|
||||
2. `git clone` this repo to e.g. `/srv/famapp`.
|
||||
3. Copy `.env.production.example` → `/srv/famapp/deploy/.env` and fill in real values.
|
||||
- `openssl rand -base64 32` for `AUTH_SECRET`.
|
||||
- `openssl rand -base64 60` for `AUTHENTIK_SECRET_KEY`.
|
||||
- `pnpm vapid:generate` (locally) for the three VAPID lines.
|
||||
4. Bootstrap Authentik per `deploy/authentik/README.md`. Save the OIDC client id/secret into `.env`.
|
||||
5. Wire Caddy with `deploy/Caddyfile.snippet`.
|
||||
|
||||
## Cutting a release
|
||||
|
||||
```bash
|
||||
# from your dev machine, on main, with a clean working tree
|
||||
git tag v0.1.0
|
||||
git push origin v0.1.0
|
||||
```
|
||||
|
||||
`.github/workflows/release.yml` builds + pushes `ghcr.io/ginnoir/famapp:v0.1.0`, `:0.1`, and `:latest` to GHCR.
|
||||
|
||||
## Deploying a release on the host
|
||||
|
||||
```bash
|
||||
cd /srv/famapp/deploy
|
||||
# pin to the tag you just cut
|
||||
echo 'FAMAPP_IMAGE=ghcr.io/ginnoir/famapp:v0.1.0' >> .env # or edit in place
|
||||
docker compose pull famapp
|
||||
docker compose up -d famapp
|
||||
docker compose logs -f famapp # watch migrations + boot
|
||||
```
|
||||
|
||||
The container's entrypoint runs `node scripts/migrate.mjs` before starting the server. To skip migrations on a given start (rare — e.g. emergency rollback to an older schema-compatible image), set `RUN_MIGRATIONS=false`.
|
||||
|
||||
## Rollback
|
||||
|
||||
Edit `.env` to point `FAMAPP_IMAGE` at the previous tag, then `docker compose up -d famapp`. If the rollback target predates a migration that's already applied, restore from backup (`deploy/backups/README.md`) before bringing the older image up.
|
||||
|
||||
## Pre-deploy checklist
|
||||
|
||||
Run [docs/tasks/09-pre-deploy-checklist.md](../docs/tasks/09-pre-deploy-checklist.md) before every deploy.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Backups
|
||||
|
||||
The `famapp-backup` service performs nightly compressed `pg_dump` of both
|
||||
`famapp-db` and `authentik-db` at **02:00 server time**.
|
||||
|
||||
## Storage layout
|
||||
|
||||
```
|
||||
/backups/ (named Docker volume: famapp_backups)
|
||||
famapp/
|
||||
daily/ ← last 14 days
|
||||
weekly/ ← last 8 Sundays
|
||||
monthly/ ← last 6 first-of-month dumps
|
||||
authentik/
|
||||
daily/
|
||||
weekly/
|
||||
monthly/
|
||||
```
|
||||
|
||||
Dump files are named `YYYY-MM-DD.dump` in custom (`-Fc`) format (internal
|
||||
compression, ~3–5× smaller than plain SQL).
|
||||
|
||||
## Retention
|
||||
|
||||
| Tier | Kept | Trigger |
|
||||
| ------- | ---- | ---------------- |
|
||||
| daily | 14 | every night |
|
||||
| weekly | 8 | Sunday night |
|
||||
| monthly | 6 | 1st of the month |
|
||||
|
||||
Retention is enforced by `retain.sh` at the end of each `backup.sh` run.
|
||||
|
||||
## Restore procedure
|
||||
|
||||
### 1. Identify the dump
|
||||
|
||||
```sh
|
||||
# List available dumps
|
||||
docker exec famapp-backup-1 ls /backups/famapp/daily/
|
||||
```
|
||||
|
||||
### 2a. Restore inside the backup container (recommended)
|
||||
|
||||
```sh
|
||||
docker exec famapp-backup-1 /scripts/restore.sh \
|
||||
/backups/famapp/daily/2024-06-01.dump \
|
||||
postgres://famapp:SECRET@famapp-db:5432/famapp
|
||||
```
|
||||
|
||||
Replace `SECRET` with the value of `FAMAPP_DB_PASSWORD` in your `.env` file.
|
||||
For authentik:
|
||||
|
||||
```sh
|
||||
docker exec famapp-backup-1 /scripts/restore.sh \
|
||||
/backups/authentik/daily/2024-06-01.dump \
|
||||
postgres://authentik:SECRET@authentik-db:5432/authentik
|
||||
```
|
||||
|
||||
### 2b. Restore to a separate database (safe — non-destructive)
|
||||
|
||||
Create a fresh target database first, then restore into it:
|
||||
|
||||
```sh
|
||||
# Create the target DB
|
||||
docker exec famapp-db-1 createdb \
|
||||
-U "$FAMAPP_DB_USER" famapp_restore
|
||||
|
||||
# Restore
|
||||
docker exec famapp-backup-1 /scripts/restore.sh \
|
||||
/backups/famapp/daily/2024-06-01.dump \
|
||||
postgres://famapp:SECRET@famapp-db:5432/famapp_restore
|
||||
```
|
||||
|
||||
### 2c. Restore on a fresh host (disaster recovery)
|
||||
|
||||
```sh
|
||||
# Copy the dump file out of the volume
|
||||
docker cp famapp-backup-1:/backups/famapp/daily/2024-06-01.dump ./
|
||||
|
||||
# Spin up a temporary Postgres container and restore
|
||||
docker run --rm \
|
||||
-e PGPASSWORD=SECRET \
|
||||
-v "$(pwd)/2024-06-01.dump:/dump.dump:ro" \
|
||||
postgres:16-alpine \
|
||||
pg_restore -h <new-db-host> -U famapp -d famapp \
|
||||
--no-owner --no-acl /dump.dump
|
||||
```
|
||||
|
||||
## Off-site replication
|
||||
|
||||
The backups live in the `famapp_backups` Docker named volume. To copy them
|
||||
to another host, rsync the volume's data directory periodically (e.g. from a
|
||||
host cron job):
|
||||
|
||||
```sh
|
||||
# On the Docker host, add to /etc/cron.d/famapp-rsync:
|
||||
30 3 * * * root rsync -a --delete \
|
||||
/var/lib/docker/volumes/famapp_backups/_data/ \
|
||||
user@offsite-server:/opt/famapp-backups/
|
||||
```
|
||||
|
||||
Encryption at rest is handled at the disk/filesystem layer (e.g. LUKS).
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/sh
|
||||
# Nightly pg_dump for famapp-db and authentik-db.
|
||||
# Called by crond. Outputs to /backups/<db>/daily/<YYYY-MM-DD>.dump
|
||||
# and copies into weekly/ (Sundays) and monthly/ (1st of month).
|
||||
set -eu
|
||||
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
DOW=$(date +%u) # 1=Mon … 7=Sun
|
||||
DOM=$(date +%d | sed 's/^0*//') # day-of-month without leading zero
|
||||
|
||||
dump_db() {
|
||||
local name="$1"
|
||||
local host="$2"
|
||||
local port="$3"
|
||||
local user="$4"
|
||||
local pass="$5"
|
||||
local dbname="$6"
|
||||
|
||||
local daily_dir="/backups/$name/daily"
|
||||
local dest="$daily_dir/$TODAY.dump"
|
||||
|
||||
mkdir -p "$daily_dir" \
|
||||
"/backups/$name/weekly" \
|
||||
"/backups/$name/monthly"
|
||||
|
||||
echo "[backup] dumping $name ..."
|
||||
PGPASSWORD="$pass" pg_dump \
|
||||
-h "$host" -p "$port" -U "$user" -d "$dbname" \
|
||||
-Fc -f "$dest"
|
||||
echo "[backup] $name → $dest"
|
||||
|
||||
if [ "$DOW" = "7" ]; then
|
||||
cp "$dest" "/backups/$name/weekly/$TODAY.dump"
|
||||
echo "[backup] weekly copy saved for $name"
|
||||
fi
|
||||
|
||||
if [ "$DOM" = "1" ]; then
|
||||
cp "$dest" "/backups/$name/monthly/$TODAY.dump"
|
||||
echo "[backup] monthly copy saved for $name"
|
||||
fi
|
||||
}
|
||||
|
||||
dump_db famapp \
|
||||
"${FAMAPP_DB_HOST:-famapp-db}" \
|
||||
"${FAMAPP_DB_PORT:-5432}" \
|
||||
"$FAMAPP_DB_USER" \
|
||||
"$FAMAPP_DB_PASSWORD" \
|
||||
"$FAMAPP_DB_NAME"
|
||||
|
||||
dump_db authentik \
|
||||
"${AUTHENTIK_DB_HOST:-authentik-db}" \
|
||||
"${AUTHENTIK_DB_PORT:-5432}" \
|
||||
"$AUTHENTIK_DB_USER" \
|
||||
"$AUTHENTIK_DB_PASSWORD" \
|
||||
"$AUTHENTIK_DB_NAME"
|
||||
|
||||
/scripts/retain.sh
|
||||
echo "[backup] complete"
|
||||
@@ -0,0 +1 @@
|
||||
0 2 * * * /scripts/backup.sh >> /var/log/backup.log 2>&1
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
# Container entrypoint: install postgresql-client, create backup directories,
|
||||
# install crontab, and start crond.
|
||||
set -eu
|
||||
|
||||
apk add --no-cache postgresql-client >/dev/null
|
||||
|
||||
mkdir -p \
|
||||
/backups/famapp/daily /backups/famapp/weekly /backups/famapp/monthly \
|
||||
/backups/authentik/daily /backups/authentik/weekly /backups/authentik/monthly
|
||||
|
||||
# Make scripts executable (volume mount may strip +x).
|
||||
chmod +x /scripts/backup.sh /scripts/retain.sh /scripts/restore.sh
|
||||
|
||||
# Install root crontab from the mounted file.
|
||||
mkdir -p /var/spool/cron/crontabs
|
||||
cp /scripts/crontab /var/spool/cron/crontabs/root
|
||||
chmod 600 /var/spool/cron/crontabs/root
|
||||
|
||||
echo "[entrypoint] backup service started — first run at 02:00"
|
||||
exec crond -f -l 2
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# Restore a pg_dump (-Fc format) to a target Postgres database.
|
||||
# The target DB must already exist and be empty (or you accept overwriting data).
|
||||
#
|
||||
# Usage:
|
||||
# restore.sh <dump-file> <target-db-url>
|
||||
#
|
||||
# Example (inside the backup container):
|
||||
# /scripts/restore.sh \
|
||||
# /backups/famapp/daily/2024-06-01.dump \
|
||||
# postgres://famapp:secret@famapp-db:5432/famapp
|
||||
#
|
||||
# Example (from the host via docker exec):
|
||||
# docker exec famapp-backup-1 /scripts/restore.sh \
|
||||
# /backups/famapp/daily/2024-06-01.dump \
|
||||
# postgres://famapp:secret@famapp-db:5432/famapp_restore
|
||||
set -eu
|
||||
|
||||
DUMP_FILE="${1:?Usage: restore.sh <dump-file> <target-db-url>}"
|
||||
TARGET_URL="${2:?Usage: restore.sh <dump-file> <target-db-url>}"
|
||||
|
||||
[ -f "$DUMP_FILE" ] || { echo "[restore] ERROR: $DUMP_FILE not found"; exit 1; }
|
||||
|
||||
echo "[restore] $DUMP_FILE → $TARGET_URL"
|
||||
pg_restore -d "$TARGET_URL" --no-owner --no-acl --exit-on-error "$DUMP_FILE"
|
||||
echo "[restore] done"
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
# Prune old backup files according to retention policy.
|
||||
# daily: keep 14 weekly: keep 8 monthly: keep 6
|
||||
set -eu
|
||||
|
||||
prune() {
|
||||
local dir="$1"
|
||||
local keep="$2"
|
||||
[ -d "$dir" ] || return 0
|
||||
# Files are YYYY-MM-DD.dump; lexicographic sort = chronological.
|
||||
# tail skips the N newest; xargs deletes the rest.
|
||||
ls -1 "$dir"/*.dump 2>/dev/null \
|
||||
| sort \
|
||||
| head -n "-$keep" \
|
||||
| xargs -r rm -f --
|
||||
}
|
||||
|
||||
for db in famapp authentik; do
|
||||
prune "/backups/$db/daily" 14
|
||||
prune "/backups/$db/weekly" 8
|
||||
prune "/backups/$db/monthly" 6
|
||||
done
|
||||
|
||||
echo "[retain] done"
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
|
||||
echo "running migrations..."
|
||||
node /app/scripts/migrate.mjs
|
||||
else
|
||||
echo "skipping migrations (RUN_MIGRATIONS=$RUN_MIGRATIONS)"
|
||||
fi
|
||||
|
||||
exec node /app/server.js
|
||||
@@ -11,5 +11,19 @@ services:
|
||||
volumes:
|
||||
- famapp-db-data:/var/lib/postgresql/data
|
||||
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
command: server /data --console-address ":9001"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MINIO_ROOT_USER: famapp
|
||||
MINIO_ROOT_PASSWORD: changeme
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
volumes:
|
||||
- famapp-minio-data:/data
|
||||
|
||||
volumes:
|
||||
famapp-db-data:
|
||||
famapp-minio-data:
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
|
||||
echo "running migrations..."
|
||||
node /app/scripts/migrate.mjs
|
||||
else
|
||||
echo "skipping migrations (RUN_MIGRATIONS=$RUN_MIGRATIONS)"
|
||||
fi
|
||||
|
||||
node /app/scripts/seed.mjs
|
||||
|
||||
exec node /app/server.js
|
||||
@@ -0,0 +1,36 @@
|
||||
# 0003 — Release workflow
|
||||
|
||||
Date: 2026-06-01
|
||||
Status: accepted
|
||||
|
||||
## Context
|
||||
|
||||
The project needed a consistent, enforced standard for commit messages, changelog generation, versioning, and GitHub releases — especially important since both humans and AI agents (Claude Code, Codex) commit to the repo.
|
||||
|
||||
## Decision
|
||||
|
||||
- **commitlint** (`@commitlint/config-conventional`) enforces conventional commit format at the `commit-msg` git hook
|
||||
- **husky** v9 wires the hooks; `prepare` installs them on `pnpm install`
|
||||
- **lint-staged** runs prettier + eslint on staged files at the `pre-commit` hook
|
||||
- **release-it** + `@release-it/conventional-changelog` manages the full release cycle:
|
||||
- bumps `package.json` version (semver)
|
||||
- generates/prepends to `CHANGELOG.md`
|
||||
- creates a signed git tag (`v{version}`)
|
||||
- creates a GitHub Release with the generated notes
|
||||
- pushes the tag and commit
|
||||
|
||||
Release commands:
|
||||
|
||||
- `pnpm release` — interactive (prompts for increment type)
|
||||
- `pnpm release:patch` / `:minor` / `:major` — non-interactive
|
||||
- `pnpm release:dry` — preview without writing anything
|
||||
- Requires `GITHUB_TOKEN` in env for GitHub Release creation
|
||||
|
||||
Conventional commit types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `revert`
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every commit is validated; bad format is rejected immediately
|
||||
- CHANGELOG.md is auto-generated from commit history — no manual upkeep
|
||||
- Releases are reproducible: one command, idempotent output
|
||||
- `chore`, `ci`, `test` commits are hidden in the changelog; `feat`, `fix`, `perf`, `refactor`, `docs` are surfaced
|
||||
+58
-8
@@ -34,20 +34,70 @@ 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
|
||||
|
||||
|
||||
@@ -25,11 +25,19 @@ Cheap to set up before modules exist, painful to retrofit afterwards. Locking in
|
||||
Use shadcn's CSS-variable conventions, scoped by `data-theme` on `<html>`:
|
||||
|
||||
```css
|
||||
:root { /* default light tokens */ }
|
||||
.dark { /* default dark tokens */ }
|
||||
:root {
|
||||
/* default light tokens */
|
||||
}
|
||||
.dark {
|
||||
/* default dark tokens */
|
||||
}
|
||||
|
||||
[data-theme="warm"] { /* warm light */ }
|
||||
[data-theme="warm"].dark { /* warm dark */ }
|
||||
[data-theme="warm"] {
|
||||
/* warm light */
|
||||
}
|
||||
[data-theme="warm"].dark {
|
||||
/* warm dark */
|
||||
}
|
||||
```
|
||||
|
||||
Ship at least **two** themes (`default` + one more) so the architecture is actually exercised. Token values can be placeholder — refining the palettes is a separate later concern.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# 09 — Pre-deploy checklist (recurring)
|
||||
|
||||
Run this before every production deploy (first deploy and each tagged release). Dev-login is intentionally retained behind a double gate; this checklist is what keeps that gate honest.
|
||||
|
||||
## Env hygiene
|
||||
|
||||
- [ ] Server `.env` (next to `deploy/compose.yaml`) does **not** set:
|
||||
- `ENABLE_DEV_LOGIN`
|
||||
- `DEV_LOGIN_EMAIL`, `DEV_LOGIN_NAME`, `DEV_HOUSEHOLD_NAME`
|
||||
- [ ] Server `.env` sets real values for:
|
||||
- `NEXT_PUBLIC_APP_URL` (https)
|
||||
- `AUTH_SECRET` (`openssl rand -base64 32`)
|
||||
- `AUTH_OIDC_ISSUER`, `AUTH_OIDC_CLIENT_ID`, `AUTH_OIDC_CLIENT_SECRET`
|
||||
- `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`
|
||||
- `FAMAPP_DB_*`, `AUTHENTIK_DB_*`, `AUTHENTIK_SECRET_KEY`
|
||||
- [ ] `FAMAPP_IMAGE` pins a specific version tag (`ghcr.io/ginnoir/famapp:vX.Y.Z`), not `latest`, after first deploy.
|
||||
|
||||
## Code-side guard
|
||||
|
||||
`src/lib/dev-login-config.ts` throws on import if `NODE_ENV=production` and `ENABLE_DEV_LOGIN=true`. Container will refuse to start.
|
||||
|
||||
- [ ] Confirm guard is still present (do not remove without updating this checklist).
|
||||
|
||||
## Smoke after deploy
|
||||
|
||||
- [ ] `https://fam.ginnoir.com/login` shows only **Sign in with SSO** (no Dev login button).
|
||||
- [ ] Real Authentik sign-in succeeds, lands on dashboard.
|
||||
- [ ] First user appears in DB with household membership, default calendars, default lists.
|
||||
- [ ] No `dev@famapp.local` row in production `users` table.
|
||||
- [ ] Push opt-in works (settings → enable → test notification arrives).
|
||||
- [ ] A share link created from `/settings` resolves at `/s/<token>` while signed out.
|
||||
|
||||
## Backup / rollback
|
||||
|
||||
- [ ] Last `famapp-backup` cron run succeeded (check container logs).
|
||||
- [ ] Previous image tag is known and recorded in `CHANGELOG.md`, so rollback = bump `FAMAPP_IMAGE` and `docker compose up -d famapp`.
|
||||
@@ -1,47 +0,0 @@
|
||||
# 09 — Production dev-login removal gate
|
||||
|
||||
## Goal
|
||||
|
||||
Before the first production deployment, verify that development-only login/test shortcuts cannot be enabled accidentally in production.
|
||||
|
||||
## Depends on
|
||||
|
||||
- 06
|
||||
- 07
|
||||
- local dev-login setup in `docs/dev-login.md`
|
||||
|
||||
## Scope
|
||||
|
||||
- Review all production env sources:
|
||||
- `.env.production.example`
|
||||
- `deploy/compose.yaml`
|
||||
- any host-level Docker Compose override files
|
||||
- deployment secrets on the server
|
||||
- Confirm production does not set:
|
||||
- `ENABLE_DEV_LOGIN=true`
|
||||
- `DEV_LOGIN_EMAIL`
|
||||
- `DEV_LOGIN_NAME`
|
||||
- `DEV_HOUSEHOLD_NAME`
|
||||
- Confirm production Authentik variables are real:
|
||||
- `AUTH_OIDC_ISSUER`
|
||||
- `AUTH_OIDC_CLIENT_ID`
|
||||
- `AUTH_OIDC_CLIENT_SECRET`
|
||||
- Build with production-like env and verify `/login` renders only the SSO login path.
|
||||
- Verify the app still protects private routes without a valid Auth.js session cookie.
|
||||
- Verify real Authentik login creates the expected user, household membership, default calendars, and default lists.
|
||||
- Remove any accidental dev users from the production database.
|
||||
|
||||
## Optional hardening
|
||||
|
||||
- Remove `src/lib/dev-login.ts` and the Dev login form from `src/app/login/page.tsx` entirely before first production deployment.
|
||||
- If retaining the code for future local development, keep the current double gate:
|
||||
- `NODE_ENV !== "production"`
|
||||
- `ENABLE_DEV_LOGIN=true`
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Production env cannot enable Dev login accidentally.
|
||||
- [ ] `/login` in production does not show **Dev login**.
|
||||
- [ ] Direct dev-login action execution is unavailable in production.
|
||||
- [ ] Real Authentik login works on the production domain.
|
||||
- [ ] No `dev@famapp.local` or configured dev-login user exists in production data.
|
||||
@@ -6,7 +6,7 @@ Implement the `calendar` module: first-class **calendars** (multiple per househo
|
||||
|
||||
## Why
|
||||
|
||||
Calendars are entities, not a singleton concept. A user might run a *Personal* (private) calendar, a *Family* (household-shared) calendar, and a *Wedding planning* calendar that gets share-linked publicly. The calendar module ships that abstraction; widgets and the share-link service then reuse it generically.
|
||||
Calendars are entities, not a singleton concept. A user might run a _Personal_ (private) calendar, a _Family_ (household-shared) calendar, and a _Wedding planning_ calendar that gets share-linked publicly. The calendar module ships that abstraction; widgets and the share-link service then reuse it generically.
|
||||
|
||||
## Depends on
|
||||
|
||||
|
||||
@@ -22,5 +22,14 @@ A single global `+` button on the dashboard (and a `cmd+k` palette anywhere) tha
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Adding a new module that registers a quick-add appears in both the FAB sheet and the cmd-k palette without touching core.
|
||||
- [ ] Keyboard navigation works in the palette (arrows + enter + escape).
|
||||
- [x] Adding a new module that registers a quick-add appears in both the FAB sheet and the cmd-k palette without touching core.
|
||||
- [x] Keyboard navigation works in the palette (arrows + enter + escape).
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- `QuickAddAction` gained a `url: string` field; `action` made optional (future inline modals).
|
||||
- `SerializedQuickAddItem` strips the non-serializable `action` fn so it can cross the server→client RSC boundary as props to `QuickAddProvider`.
|
||||
- `getQuickAdds()` in the registry returns one `SerializedQuickAddItem` per registered quick-add, enriched with `moduleId` / `moduleName` for grouping.
|
||||
- Root layout calls `getQuickAdds()` and passes the result to `<QuickAddProvider>` which wraps the whole app. `<QuickAddSheet>` and `<CommandPalette>` live inside the provider and read from context.
|
||||
- `cmdk` installed for the command palette; keyboard shortcut (cmd/ctrl+k) wired via `useEffect` in the provider.
|
||||
- `.claude/**` added to ESLint ignores to prevent stale worktree `.next` build artifacts from failing lint.
|
||||
|
||||
@@ -6,7 +6,7 @@ Each user can create any number of named dashboards, switch between them, and ch
|
||||
|
||||
## Why
|
||||
|
||||
Decouples "the dashboard" from "user's home page". A user might have a *Daily*, *Garden*, and *Wedding planning* dashboard, each composing different widgets. This task introduces the entity; task 26 adds the editor.
|
||||
Decouples "the dashboard" from "user's home page". A user might have a _Daily_, _Garden_, and _Wedding planning_ dashboard, each composing different widgets. This task introduces the entity; task 26 adds the editor.
|
||||
|
||||
## Depends on
|
||||
|
||||
|
||||
@@ -52,8 +52,15 @@ Same component is reused for **Configure** on an already-placed widget. Re-runs
|
||||
{
|
||||
"version": 1,
|
||||
"widgets": [
|
||||
{ "widgetId": "lists.list", "config": { "listIds": ["..."], "showCompleted": false }, "x": 0, "y": 0, "w": 6, "h": 4 }
|
||||
]
|
||||
{
|
||||
"widgetId": "lists.list",
|
||||
"config": { "listIds": ["..."], "showCompleted": false },
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 4,
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
# 70 — Garden infrastructure (MinIO + upload route + schema)
|
||||
|
||||
## Goal
|
||||
|
||||
Lay the infrastructure foundation for the garden module: add a MinIO object-storage container to the compose stack, create a generic file-upload API route, define the Drizzle schema for all four garden tables, and run the migration.
|
||||
|
||||
No UI or business logic — just the plumbing that every subsequent garden task depends on.
|
||||
|
||||
## Depends on
|
||||
|
||||
- 03 (Drizzle + Postgres), 07 (household seed)
|
||||
|
||||
## Scope
|
||||
|
||||
### compose.yaml additions (`deploy/compose.yaml`)
|
||||
|
||||
Add a `minio` service and a `garden-uploads` named volume:
|
||||
|
||||
```yaml
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
|
||||
volumes:
|
||||
- garden-uploads:/data
|
||||
ports:
|
||||
- "9000:9000" # API
|
||||
- "9001:9001" # Console (dev only — restrict in prod)
|
||||
healthcheck:
|
||||
test: ["CMD", "mc", "ready", "local"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
garden-uploads:
|
||||
```
|
||||
|
||||
Add `MINIO_ENDPOINT`, `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`, `MINIO_BUCKET` to `.env.example`.
|
||||
|
||||
### MinIO client (`src/lib/minio.ts`)
|
||||
|
||||
Thin singleton using the `minio` npm package:
|
||||
|
||||
- Connect using `MINIO_ENDPOINT`, `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`.
|
||||
- Export `minioClient` and `MINIO_BUCKET` constant.
|
||||
- On first use, create the bucket if it does not exist (`bucketExists` + `makeBucket`).
|
||||
|
||||
### Upload API route (`src/app/api/uploads/route.ts`)
|
||||
|
||||
- `POST /api/uploads` — accepts `multipart/form-data` with a single `file` field.
|
||||
- Validates: authenticated session required (401 if not); max file size 5 MB (413 if exceeded); MIME type must match `image/*` (415 if not).
|
||||
- Generates a storage key: `garden/<householdId>/<randomUUID>.<ext>`.
|
||||
- Streams to MinIO via `putObject`.
|
||||
- Returns `{ url: "/api/uploads/<key>" }`.
|
||||
- `GET /api/uploads/[...key]` — proxies the object back from MinIO using `getObject`. Sets `Cache-Control: public, max-age=31536000, immutable`.
|
||||
|
||||
This route is generic — not garden-specific. Other modules can reuse it.
|
||||
|
||||
### Schema (`src/modules/garden/schema.ts`)
|
||||
|
||||
Four tables, all `household_id`-scoped:
|
||||
|
||||
**`garden_containers`**
|
||||
|
||||
- `id` uuid pk
|
||||
- `household_id` fk households cascade
|
||||
- `name` text not null
|
||||
- `type` text not null default `'other'` — free text, convention: `'shelf' | 'terrarium' | 'raised-bed' | 'window-box' | 'single-pot' | 'outdoor' | 'other'`
|
||||
- `location_notes` text nullable
|
||||
- `cover_image_url` text nullable
|
||||
- `created_at`, `updated_at` timestamptz
|
||||
|
||||
Index on `household_id`.
|
||||
|
||||
**`garden_plants`**
|
||||
|
||||
- `id` uuid pk
|
||||
- `household_id` fk households cascade
|
||||
- `container_id` uuid nullable fk `garden_containers` set-null on delete
|
||||
- `name` text not null
|
||||
- `scientific_name` text nullable
|
||||
- `species_id` text nullable — external Perenual species ID, stored as string
|
||||
- `category` text not null default `'other'` — free text, convention: `'succulent' | 'tropical' | 'herb' | 'vegetable' | 'tree' | 'flower' | 'other'`
|
||||
- `notes` text nullable
|
||||
- `acquisition_date` date nullable
|
||||
- `growth_stage` text nullable — convention: `'seedling' | 'juvenile' | 'mature' | 'flowering' | 'fruiting' | 'dormant'`
|
||||
- `health_status` text not null default `'healthy'` — convention: `'healthy' | 'stressed' | 'sick' | 'dormant'`
|
||||
- `sunlight` text nullable
|
||||
- `watering_notes` text nullable
|
||||
- `fertilizing_notes` text nullable
|
||||
- `primary_image_url` text nullable
|
||||
- `images` jsonb not null default `'[]'` — typed as `string[]`, stores upload URL paths
|
||||
- `created_at`, `updated_at` timestamptz
|
||||
|
||||
Indexes on `(household_id)`, `(household_id, container_id)`.
|
||||
|
||||
**`garden_care_logs`**
|
||||
|
||||
- `id` uuid pk
|
||||
- `plant_id` uuid fk `garden_plants` cascade
|
||||
- `household_id` fk households cascade
|
||||
- `care_type` text not null — convention: `'watered' | 'fertilized' | 'repotted' | 'pruned' | 'misted' | 'inspected' | 'treated' | 'propagated' | 'custom'`
|
||||
- `performed_by` uuid fk users set-null on delete
|
||||
- `notes` text nullable
|
||||
- `performed_at` timestamptz not null default now()
|
||||
- `created_at` timestamptz
|
||||
|
||||
Indexes on `(plant_id, performed_at desc)`, `(household_id)`.
|
||||
|
||||
**`garden_care_schedules`**
|
||||
|
||||
- `id` uuid pk
|
||||
- `plant_id` uuid fk `garden_plants` cascade
|
||||
- `household_id` fk households cascade
|
||||
- `care_type` text not null
|
||||
- `interval_days` int not null
|
||||
- `last_performed_at` timestamptz nullable
|
||||
- `next_due_at` timestamptz nullable — recomputed after every care log entry
|
||||
- `enabled` boolean not null default true
|
||||
- `created_at`, `updated_at` timestamptz
|
||||
|
||||
Unique index on `(plant_id, care_type)` — one schedule per plant per care type.
|
||||
Index on `(household_id, next_due_at)` for dashboard queries.
|
||||
|
||||
### Drizzle migration
|
||||
|
||||
Generate and commit under `drizzle/`. Run `pnpm db:migrate` to apply.
|
||||
|
||||
### Module scaffold (`src/modules/garden/`)
|
||||
|
||||
Create the directory with stubs:
|
||||
|
||||
- `schema.ts` (complete, from above)
|
||||
- `manifest.tsx` (minimal stub: `id: "garden"`, `name: "Garden"`, empty `entities: []`)
|
||||
- `server/actions.ts` (empty stub)
|
||||
- `server/queries.ts` (empty stub)
|
||||
|
||||
Register in `src/modules/index.ts`:
|
||||
|
||||
```typescript
|
||||
import gardenManifest from "./garden/manifest";
|
||||
registerModule(gardenManifest);
|
||||
```
|
||||
|
||||
### Environment
|
||||
|
||||
Add to `.env.example`:
|
||||
|
||||
```
|
||||
MINIO_ENDPOINT=http://localhost:9000
|
||||
MINIO_ROOT_USER=famapp
|
||||
MINIO_ROOT_PASSWORD=changeme
|
||||
MINIO_BUCKET=garden
|
||||
PERENUAL_API_KEY= # filled in task 72
|
||||
```
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Any garden-specific UI.
|
||||
- Perenual API integration (task 72).
|
||||
- Care logic (tasks 73–74).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `docker compose up minio` starts and passes health-check.
|
||||
- [ ] `POST /api/uploads` rejects unauthenticated requests with 401.
|
||||
- [ ] `POST /api/uploads` rejects files > 5 MB with 413.
|
||||
- [ ] `POST /api/uploads` accepts a valid JPEG and returns `{ url: "..." }`; `GET` of that URL returns the image bytes.
|
||||
- [ ] `pnpm db:migrate` applies cleanly; all four tables exist with the correct columns.
|
||||
- [ ] `garden` module appears in `/debug/registry`.
|
||||
@@ -0,0 +1,82 @@
|
||||
# 71 — Garden containers
|
||||
|
||||
## Goal
|
||||
|
||||
Implement full CRUD for **containers** — the grouping layer above individual plants (shelves, terrariums, raised beds, etc.). A container has a name, type, location notes, and an optional cover photo.
|
||||
|
||||
## Depends on
|
||||
|
||||
- 70 (garden infrastructure — schema, MinIO, upload route)
|
||||
|
||||
## Scope
|
||||
|
||||
### Server (`src/modules/garden/server/`)
|
||||
|
||||
Add to `actions.ts`:
|
||||
|
||||
- `createContainer(input: { name, type, locationNotes?, coverImageUrl? })` — Zod-validated, `getCurrentSession`, insert, `logActivity`, `revalidatePath("/garden")`.
|
||||
- `updateContainer(input: { id, name?, type?, locationNotes?, coverImageUrl? })` — patch, household membership check, logActivity.
|
||||
- `deleteContainer(input: { id })` — household membership check; plants with this `container_id` have it set to null (handled by schema `ON DELETE SET NULL`); logActivity; revalidatePath.
|
||||
|
||||
Add to `queries.ts`:
|
||||
|
||||
- `listContainers()` — returns all containers for the current household with a computed `plantCount`.
|
||||
- `getContainer(id)` — returns the container + all its plants (with last-watered date derived from care logs — use a lateral join or subquery).
|
||||
|
||||
### UI (`src/modules/garden/components/`)
|
||||
|
||||
**`container-list.tsx`** (server component)
|
||||
|
||||
- Grid of cards. Each card: cover photo (placeholder icon if none), name, type badge, plant count.
|
||||
- "New container" button → sheet/dialog.
|
||||
|
||||
**`container-detail.tsx`** (server component)
|
||||
|
||||
- Header: cover photo, name, type, location notes, edit/delete buttons.
|
||||
- Plant grid (placeholder for now — task 72 fills in the plant cards).
|
||||
- "+ Add plant to this container" button — links to `/garden/plants/new?containerId=<id>`.
|
||||
|
||||
**`container-form.tsx`** (client component)
|
||||
|
||||
- Fields: name (required), type (select — 7 options), location notes (textarea), cover photo (file input → `POST /api/uploads` → stores returned URL).
|
||||
- Used for both create and edit via an optional `existing` prop.
|
||||
|
||||
### Pages (`src/app/garden/`)
|
||||
|
||||
- `page.tsx` — `/garden` root page: two tabs — "Plants" (placeholder for task 72) and "Containers". Containers tab renders `<ContainerList />`.
|
||||
- `containers/[id]/page.tsx` — renders `<ContainerDetail />`.
|
||||
- `containers/new/page.tsx` — renders `<ContainerForm />` in create mode; redirects to `/garden` on success.
|
||||
|
||||
### Nav
|
||||
|
||||
Add `/garden` to the bottom nav and sidebar. Use the `sprout` Lucide icon (or nearest available). Register in the garden manifest:
|
||||
|
||||
```typescript
|
||||
nav: { href: "/garden", label: "Garden", icon: "sprout" }
|
||||
```
|
||||
|
||||
### Activity log
|
||||
|
||||
`renderActivity` for `garden.container` entity type in the manifest:
|
||||
|
||||
- `create` → `Created container "${name}"`
|
||||
- `update` → `Updated container "${name}"`
|
||||
- `delete` → `Deleted container`
|
||||
|
||||
### Share links
|
||||
|
||||
Containers are shareable (read-only). Add `loadForShare` and `renderSharedView` to the entity registration so a share link shows the container's name, type, location notes, cover photo, and plant names. Add `loadContainerForShare(id)` in `src/modules/garden/server/share-queries.ts`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Plant cards inside the container detail (task 72 fills those in).
|
||||
- Care tracking (tasks 73–74).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Create / edit / delete containers works end-to-end.
|
||||
- [ ] Cover photo uploads to MinIO and renders on the card and detail page.
|
||||
- [ ] Deleting a container nullifies `container_id` on its plants rather than deleting the plants.
|
||||
- [ ] `/garden` page renders with a Containers tab.
|
||||
- [ ] `garden.container` entity is registered with share support in the manifest.
|
||||
- [ ] Activity log entries appear for create / update / delete.
|
||||
@@ -0,0 +1,117 @@
|
||||
# 72 — Garden plants
|
||||
|
||||
## Goal
|
||||
|
||||
Implement full CRUD for individual **plants**. Each plant has a name, category, care notes, health status, up to 10 photos, an optional container assignment, and species data pulled from the **Perenual API** when the user searches for a known species.
|
||||
|
||||
## Depends on
|
||||
|
||||
- 70 (schema, MinIO, upload route), 71 (containers — container assignment picker)
|
||||
|
||||
## Scope
|
||||
|
||||
### Perenual API wrapper (`src/modules/garden/server/species-lookup.ts`)
|
||||
|
||||
Perenual (perenual.com) provides a free-tier plant species API. Key endpoint: `GET /api/species-list?key=<KEY>&q=<query>`.
|
||||
|
||||
Implement:
|
||||
|
||||
```typescript
|
||||
export type SpeciesSuggestion = {
|
||||
id: string;
|
||||
common_name: string;
|
||||
scientific_name: string;
|
||||
watering: string; // e.g. "frequent", "average", "minimum"
|
||||
sunlight: string[]; // e.g. ["full sun", "part shade"]
|
||||
cycle: string; // e.g. "Perennial"
|
||||
default_image_url: string | null;
|
||||
};
|
||||
|
||||
export async function searchSpecies(query: string): Promise<SpeciesSuggestion[]>;
|
||||
export async function getSpeciesById(id: string): Promise<SpeciesSuggestion | null>;
|
||||
```
|
||||
|
||||
Cache results for 24 hours in a `garden_species_cache` table (columns: `species_id` text pk, `data` jsonb, `cached_at` timestamptz). Add this table to the schema in `src/modules/garden/schema.ts` and include it in the migration from task 70 (or add a new migration).
|
||||
|
||||
If the API is unreachable or returns an error, log the failure and return `[]` / `null`. `PERENUAL_API_KEY` from env; if absent, `searchSpecies` returns `[]` silently so the app works without the key.
|
||||
|
||||
### Server (`src/modules/garden/server/`)
|
||||
|
||||
Add to `actions.ts`:
|
||||
|
||||
- `createPlant(input)` — Zod-validated, `getCurrentSession`, insert, `logActivity`, `revalidatePath("/garden")`.
|
||||
- `updatePlant(input: { id, ...partials })` — patch, household check, logActivity.
|
||||
- `deletePlant(input: { id })` — household check, logActivity; care logs + schedules cascade-delete via DB.
|
||||
- `addPlantImage(input: { id, url })` — appends URL to `images` jsonb array; reject if already 10 images.
|
||||
- `removePlantImage(input: { id, url })` — removes URL from array; if it was `primary_image_url`, set primary to first remaining or null.
|
||||
- `setPrimaryImage(input: { id, url })` — sets `primary_image_url`; url must already be in `images`.
|
||||
|
||||
Add to `queries.ts`:
|
||||
|
||||
- `listPlants({ containerId? })` — all plants in household optionally filtered by container. Include last-care timestamps per type via a lateral subquery.
|
||||
- `getPlant(id)` — full plant data + container name + care log summary (last 5 entries) + active schedules.
|
||||
|
||||
### UI (`src/modules/garden/components/`)
|
||||
|
||||
**`plant-list.tsx`** (server component)
|
||||
|
||||
- Grouped by container first; "Unassigned" group for plants with no container.
|
||||
- Each card: primary image thumbnail, name, health badge, "last watered X days ago", overdue care indicator.
|
||||
- Replaces the placeholder in the "Plants" tab on `/garden/page.tsx` from task 71.
|
||||
|
||||
**`plant-detail.tsx`** (server component)
|
||||
|
||||
- Three-tab layout: **Info**, **Gallery**, **Care** (Care tab filled in by task 73).
|
||||
- Info tab: all fields, container link, health/stage badges, species info.
|
||||
- Gallery tab: photo grid, primary image star-toggle, delete individual image, upload new image.
|
||||
|
||||
**`plant-form.tsx`** (client component)
|
||||
|
||||
- Fields: name (required), category (select), container (select, nullable), health status (select), growth stage (select).
|
||||
- Species search combobox: debounced search → `searchSpecies` → select → auto-fills scientific name, sunlight, watering notes. User can override.
|
||||
- Care notes textareas: watering notes, fertilizing notes, general notes.
|
||||
- Acquisition date picker.
|
||||
- Image uploader: drag-and-drop or click, previews, max 10. First uploaded image auto-set as primary.
|
||||
- `containerId` query-param pre-fill (from task 71 "Add plant to container" button).
|
||||
|
||||
**`species-search.tsx`** (client component)
|
||||
|
||||
- Debounced combobox calling a `/api/garden/species-search` route handler. Fires `onSelect(suggestion)` on pick.
|
||||
|
||||
### Pages (`src/app/garden/`)
|
||||
|
||||
- `plants/new/page.tsx` — `<PlantForm />` in create mode.
|
||||
- `plants/[id]/page.tsx` — `<PlantDetail />` with edit/delete controls.
|
||||
- `plants/[id]/edit/page.tsx` — `<PlantForm />` in edit mode.
|
||||
|
||||
### Activity log
|
||||
|
||||
`renderActivity` for `garden.plant`:
|
||||
|
||||
- `create` → `Added plant "${name}"`
|
||||
- `update` → `Updated "${name}"`
|
||||
- `delete` → `Removed plant "${name}"`
|
||||
|
||||
### Search
|
||||
|
||||
Register `search` on the `garden.plant` entity using `ilike` on `name || ' ' || coalesce(scientific_name, '')`.
|
||||
|
||||
### Share links
|
||||
|
||||
Plants are shareable (read-only). The shared view shows name, scientific name, primary image, gallery, health status, and care notes. Add `loadPlantForShare(id)` in `src/modules/garden/server/share-queries.ts`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Care log UI and schedules (task 73).
|
||||
- Dashboard widgets (task 75).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Create / edit / delete plants works end-to-end.
|
||||
- [ ] Species search returns Perenual results and auto-fills form fields on selection.
|
||||
- [ ] App works correctly via manual entry when `PERENUAL_API_KEY` is absent.
|
||||
- [ ] Up to 10 images upload; primary image can be set and changed; individual images can be removed.
|
||||
- [ ] Plant list on `/garden` groups by container with an "Unassigned" bucket.
|
||||
- [ ] `garden.plant` entity is registered with search and share support.
|
||||
- [ ] Activity log entries appear for create / update / delete.
|
||||
- [ ] Plant share link renders the read-only view correctly.
|
||||
@@ -0,0 +1,75 @@
|
||||
# 73 — Garden care tracking
|
||||
|
||||
## Goal
|
||||
|
||||
Implement the **care log** and **care schedule** systems. Users can log a care event for any plant (watered, fertilized, repotted, etc.), set up recurring schedules with an interval, and receive reminders when care is due.
|
||||
|
||||
## Depends on
|
||||
|
||||
- 70 (schema), 72 (plants exist to attach care records to)
|
||||
- 41 (reminders system — `scheduleReminder` / `cancelReminder`)
|
||||
|
||||
## Scope
|
||||
|
||||
### Care log actions (`src/modules/garden/server/actions.ts`)
|
||||
|
||||
- `logCare(input: { plantId, careType, notes?, performedAt? })` — Zod-validated; `performedAt` defaults to now(). Insert into `garden_care_logs`. After inserting: call `updateScheduleAfterCare(plantId, careType)`, call `logActivity`, `revalidatePath`.
|
||||
- `deleteCareLog(input: { id })` — household check, delete.
|
||||
|
||||
### Care schedule actions (`src/modules/garden/server/actions.ts`)
|
||||
|
||||
- `upsertCareSchedule(input: { plantId, careType, intervalDays, enabled? })` — upsert on `(plant_id, care_type)`. Recompute `next_due_at`: if `last_performed_at` exists use `last_performed_at + intervalDays`, else `now() + intervalDays`. Wire reminder: `scheduleReminder({ entityType: "garden.schedule", entityId: scheduleRow.id, fireAt: next_due_at })`.
|
||||
- `deleteCareSchedule(input: { id })` — `cancelReminder("garden.schedule", id)`, delete.
|
||||
- `toggleCareSchedule(input: { id, enabled })` — flip `enabled`; cancel reminder if disabling, reschedule if enabling.
|
||||
|
||||
### Schedule update helper (`src/modules/garden/server/care-schedule.ts`)
|
||||
|
||||
`updateScheduleAfterCare(plantId, careType)` — finds the schedule row, sets `last_performed_at = now()`, `next_due_at = now() + interval_days`, cancels old reminder, schedules new one.
|
||||
|
||||
Uses `entityType = "garden.schedule"` + `entityId = schedule.id` to satisfy the unique constraint on `(entity_type, entity_id)` in the reminders table — one reminder per schedule row, not per plant.
|
||||
|
||||
### Queries (`src/modules/garden/server/queries.ts`)
|
||||
|
||||
- `getCareLogs(plantId, limit?)` — most recent N logs ordered `performed_at desc`.
|
||||
- `getCareSchedules(plantId)` — all schedules with computed `daysUntilDue` and `isOverdue`.
|
||||
- `getOverduePlants(householdId)` — plants with at least one enabled schedule where `next_due_at < now()`, sorted most-overdue first.
|
||||
- `getCareDueSoon(householdId, withinDays)` — plants with care due within N days.
|
||||
|
||||
### UI (`src/modules/garden/components/`)
|
||||
|
||||
**`care-log-form.tsx`** (client component) — plant picker + care type select + optional notes + optional performed-at datetime (defaults to now). Used as a sheet and inline in the plant detail Care tab.
|
||||
|
||||
**`care-schedule-editor.tsx`** (client component) — list of active schedules per plant (care type, interval, next due, enabled toggle, delete). "Add schedule" form with care type + interval days.
|
||||
|
||||
**`care-history-list.tsx`** (server component) — chronological log entries: care type icon, "X days ago", performed-by avatar, notes.
|
||||
|
||||
### Plant detail Care tab (update `plant-detail.tsx` from task 72)
|
||||
|
||||
The Care tab (previously placeholder) renders: `<CareScheduleEditor />` → "Log care" button → `<CareHistoryList />`.
|
||||
|
||||
### Quick-add (update manifest)
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: "garden.log-care",
|
||||
label: "Log plant care",
|
||||
icon: "droplets",
|
||||
url: "/garden",
|
||||
}
|
||||
```
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Calendar/lists integration (task 74).
|
||||
- Dashboard widget (task 75).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Logging care inserts a log row and updates `last_performed_at` + `next_due_at` on the matching schedule.
|
||||
- [ ] Creating a schedule with interval 7 sets `next_due_at` to 7 days from now; a reminder is scheduled.
|
||||
- [ ] After logging care, old reminder is cancelled and a new one scheduled.
|
||||
- [ ] Disabling a schedule cancels its reminder; re-enabling reschedules it.
|
||||
- [ ] Deleting a schedule cancels its reminder.
|
||||
- [ ] `getOverduePlants` returns correctly sorted results.
|
||||
- [ ] Care tab in plant detail shows schedule editor, log form, and history.
|
||||
- [ ] "Log plant care" appears in the quick-add sheet.
|
||||
@@ -0,0 +1,83 @@
|
||||
# 74 — Garden integrations (calendar + lists)
|
||||
|
||||
## Goal
|
||||
|
||||
Wire the garden module into the two existing coordination modules:
|
||||
|
||||
1. **Calendar**: schedule a plant care event on the household calendar from within a care schedule row.
|
||||
2. **Lists**: push all overdue care tasks to the household task list in one tap.
|
||||
|
||||
## Depends on
|
||||
|
||||
- 73 (care schedules + overdue queries)
|
||||
- 10 (calendar module — `createEvent`)
|
||||
- 11 (lists module — `addItem`)
|
||||
|
||||
## Scope
|
||||
|
||||
### Calendar integration
|
||||
|
||||
#### Server action (`src/modules/garden/server/actions.ts`)
|
||||
|
||||
`scheduleOnCalendar(input: { scheduleId, calendarId, reminderMinutesBefore? })`:
|
||||
|
||||
- Look up the schedule + plant; household check.
|
||||
- `startAt` = `next_due_at` (reject with user-facing error if null).
|
||||
- `endAt` = `startAt + 30 min`; `allDay = false`.
|
||||
- Title convention: `"Water ${plant.name}"` / `"Fertilize ${plant.name}"` / `"Repot ${plant.name}"` / `"${careType} — ${plant.name}"` for other types.
|
||||
- Notes: `"Scheduled from garden. Next due: ${next_due_at.toLocaleDateString()}"`.
|
||||
- Call `createCalendarEvent(...)` from the bridge below.
|
||||
|
||||
#### Bridge file (`src/modules/garden/server/calendar-bridge.ts`)
|
||||
|
||||
```typescript
|
||||
export { createEvent as createCalendarEvent } from "@/modules/calendar/server/actions";
|
||||
export { listCalendars } from "@/modules/calendar/server/queries";
|
||||
```
|
||||
|
||||
This keeps the cross-module dependency explicit and swappable without touching `_core`.
|
||||
|
||||
#### UI
|
||||
|
||||
In `care-schedule-editor.tsx`, add an "Add to calendar" button per schedule row. Clicking it opens a popover with: calendar select (from `listCalendars`) + optional reminder-minutes input + "Schedule" button. Show a success toast linking to `/calendar`.
|
||||
|
||||
---
|
||||
|
||||
### Lists integration
|
||||
|
||||
#### Server action (`src/modules/garden/server/actions.ts`)
|
||||
|
||||
`pushOverdueToTaskList(): Promise<{ added: number }>`:
|
||||
|
||||
- Call `getOverduePlants(householdId)`.
|
||||
- Find the household default task list via the bridge below.
|
||||
- For each overdue `(plant, schedule)` pair insert a list item:
|
||||
- `text`: same title convention as the calendar integration.
|
||||
- `notes`: `"Overdue by ${daysOverdue} day(s)"`.
|
||||
- `dueAt`: `schedule.next_due_at`.
|
||||
- Skip pairs where an identical `text` item already exists in the list (prevent duplicates).
|
||||
- Return `{ added: N }`.
|
||||
|
||||
#### Bridge file (`src/modules/garden/server/lists-bridge.ts`)
|
||||
|
||||
```typescript
|
||||
export { addItem as addListItem } from "@/modules/lists/server/actions";
|
||||
export { listLists } from "@/modules/lists/server/queries";
|
||||
```
|
||||
|
||||
#### UI
|
||||
|
||||
Add an "Add overdue to task list" button to the `/garden` page header (available before the dashboard widget in task 75). Shows a toast: "Added N tasks to your task list." If N = 0, toast says "No overdue care tasks."
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Bi-directional sync (checking off a list item does not mark the plant as cared-for).
|
||||
- Recurring calendar events (garden creates single events only).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] "Add to calendar" creates a calendar event visible at `/calendar` with the correct title.
|
||||
- [ ] "Add overdue to task list" pushes one item per overdue schedule, skips existing duplicates.
|
||||
- [ ] Items appear in the task list at `/lists`.
|
||||
- [ ] Both actions enforce household scope.
|
||||
- [ ] Both are graceful no-ops when nothing is overdue / due.
|
||||
@@ -0,0 +1,144 @@
|
||||
# 75 — Garden dashboard, widgets, and manifest completion
|
||||
|
||||
## Goal
|
||||
|
||||
Complete the garden module: full manifest registration, two dashboard widgets, search integration, quick-add polish, and a Playwright happy-path test.
|
||||
|
||||
## Depends on
|
||||
|
||||
- 70–74 (all previous garden tasks)
|
||||
|
||||
## Scope
|
||||
|
||||
### Manifest completion (`src/modules/garden/manifest.tsx`)
|
||||
|
||||
Replace the task-70 stub with the full manifest.
|
||||
|
||||
**Entities:**
|
||||
|
||||
```typescript
|
||||
entities: [
|
||||
{
|
||||
type: "garden.container",
|
||||
label: { singular: "Container", plural: "Containers" },
|
||||
share: { canShare: true, defaultCapabilities: ["read"] },
|
||||
search: { search: searchContainers },
|
||||
resolveUrl: (id) => `/garden/containers/${id}`,
|
||||
loadForShare: loadContainerForShare,
|
||||
renderSharedView: ...,
|
||||
renderActivity: ...,
|
||||
},
|
||||
{
|
||||
type: "garden.plant",
|
||||
label: { singular: "Plant", plural: "Plants" },
|
||||
share: { canShare: true, defaultCapabilities: ["read"] },
|
||||
search: { search: searchPlants },
|
||||
resolveUrl: (id) => `/garden/plants/${id}`,
|
||||
loadForShare: loadPlantForShare,
|
||||
renderSharedView: ...,
|
||||
renderActivity: ...,
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
`garden.schedule` is internal (reminders only) — not registered as a shareable or searchable entity.
|
||||
|
||||
**Quick adds** (consolidate from tasks 71–73):
|
||||
|
||||
- `{ id: "garden.add-plant", label: "Add plant", icon: "leaf", url: "/garden/plants/new" }`
|
||||
- `{ id: "garden.add-container", label: "Add container", icon: "box", url: "/garden/containers/new" }`
|
||||
- `{ id: "garden.log-care", label: "Log plant care", icon: "droplets", url: "/garden?logCare=1" }`
|
||||
|
||||
**Nav**: `{ href: "/garden", label: "Garden", icon: "sprout" }`.
|
||||
|
||||
---
|
||||
|
||||
### Widget 1: `garden.care-due`
|
||||
|
||||
**Title**: "Plants needing care" | **Category**: "Garden"
|
||||
**Default size**: `{ w: 4, h: 4 }` | **Min size**: `{ w: 3, h: 2 }`
|
||||
|
||||
Config schema:
|
||||
|
||||
```typescript
|
||||
z.object({
|
||||
containerIds: z.union([z.literal("all"), z.array(z.string().uuid())]),
|
||||
daysAhead: z.number().int().min(0).max(30).default(0),
|
||||
});
|
||||
```
|
||||
|
||||
Default config: `{ containerIds: "all", daysAhead: 0 }`.
|
||||
|
||||
`resolveConfigOptions`: returns `{ containers: [{ id, name }] }`.
|
||||
|
||||
Render:
|
||||
|
||||
- Empty state if nothing is due: "All plants are on schedule."
|
||||
- Otherwise: compact list sorted by days overdue. Each row: plant name, care type icon, urgency badge ("X days overdue" / "due today"), inline "Log care" button (calls `logCare` action + revalidates — no navigation).
|
||||
- Truncate at 10 rows; "View all" link to `/garden`.
|
||||
- Rows with `next_due_at` within `daysAhead` days show as upcoming in a lighter style below overdue rows.
|
||||
|
||||
---
|
||||
|
||||
### Widget 2: `garden.overview`
|
||||
|
||||
**Title**: "Garden overview" | **Category**: "Garden"
|
||||
**Default size**: `{ w: 3, h: 2 }` | **Min size**: `{ w: 2, h: 2 }`
|
||||
|
||||
Config schema: `z.object({})`.
|
||||
|
||||
Render:
|
||||
|
||||
- Stat row: total plants, total containers, overdue care count.
|
||||
- "Next care" line: "Next: water [Plant] in N days" / "today" / "overdue".
|
||||
- Link to `/garden`.
|
||||
|
||||
---
|
||||
|
||||
### Widget component (`src/modules/garden/components/plant-widget.tsx`)
|
||||
|
||||
Server component implementing both widgets, following the pattern in `src/modules/lists/components/list-widget.tsx`.
|
||||
|
||||
---
|
||||
|
||||
### Search adapters
|
||||
|
||||
Implement `searchContainers` and `searchPlants` using `ilike` on name fields, returning `SearchResult[]`. Both appear in the command palette.
|
||||
|
||||
---
|
||||
|
||||
### Notification body improvement (optional)
|
||||
|
||||
If `tickReminders` in `src/modules/_core/reminders.ts` can be extended without structural changes — add a registry of entity-type resolvers and register `"garden.schedule"` to resolve to `"Time to water [plant name]"`. If it requires core changes, defer and note as known limitation.
|
||||
|
||||
---
|
||||
|
||||
### Playwright E2E test (`tests/garden.spec.ts`)
|
||||
|
||||
Happy path:
|
||||
|
||||
1. Log in as the seeded user.
|
||||
2. Create container "Living Room Shelf".
|
||||
3. Create plant "Pothos" in that container (manual entry, no species lookup).
|
||||
4. Add watering schedule: every 7 days.
|
||||
5. Log care: watered.
|
||||
6. Verify schedule `next_due_at` updates to ~7 days from now.
|
||||
7. Confirm plant appears grouped under "Living Room Shelf" on `/garden`.
|
||||
8. Add `garden.care-due` widget to the default dashboard.
|
||||
9. Confirm no overdue items (just watered).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Health trend charts.
|
||||
- Weather-based watering adjustments.
|
||||
- Social/community features.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Both widgets register and appear in the widget picker.
|
||||
- [ ] `garden.care-due` shows overdue plants sorted by urgency; inline log button works.
|
||||
- [ ] `garden.overview` shows correct counts.
|
||||
- [ ] Both entities appear in command palette search.
|
||||
- [ ] All three quick-adds appear in the `+` sheet.
|
||||
- [ ] Garden nav link appears in sidebar and bottom nav.
|
||||
- [ ] Playwright happy-path test passes.
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN "default_dashboard_layout" jsonb;
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE "activity_log" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"household_id" uuid NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"entity_id" uuid NOT NULL,
|
||||
"actor_id" uuid NOT NULL,
|
||||
"action" text NOT NULL,
|
||||
"payload" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "activity_log_household_created_idx" ON "activity_log" USING btree ("household_id","created_at");
|
||||
@@ -0,0 +1,29 @@
|
||||
CREATE TABLE "share_links" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"household_id" uuid NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"entity_id" uuid NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"capabilities" jsonb NOT NULL,
|
||||
"created_by" uuid NOT NULL,
|
||||
"expires_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "share_links_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "share_links" ADD CONSTRAINT "share_links_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "share_links" ADD CONSTRAINT "share_links_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "share_links_household_idx" ON "share_links" USING btree ("household_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "share_links_entity_idx" ON "share_links" USING btree ("entity_type","entity_id");
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "activity_log" ALTER COLUMN "actor_id" DROP NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "activity_log" DROP CONSTRAINT "activity_log_actor_id_users_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN "completion_visibility_hours" integer NOT NULL DEFAULT 24;
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE "dashboards" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"is_default" boolean NOT NULL DEFAULT false,
|
||||
"position" integer NOT NULL DEFAULT 0,
|
||||
"layout" jsonb NOT NULL DEFAULT '{"version":1,"widgets":[]}',
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "dashboards_user_slug_uq" ON "dashboards" ("user_id", "slug");
|
||||
|
||||
-- Migrate each existing user's default_dashboard_layout into a "Home" dashboard.
|
||||
-- Idempotent: ON CONFLICT DO NOTHING.
|
||||
INSERT INTO "dashboards" ("user_id", "name", "slug", "is_default", "position", "layout")
|
||||
SELECT
|
||||
"id",
|
||||
'Home',
|
||||
'home',
|
||||
true,
|
||||
0,
|
||||
COALESCE("default_dashboard_layout", '{"version":1,"widgets":[]}')
|
||||
FROM "users"
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
ALTER TABLE "users" DROP COLUMN IF EXISTS "default_dashboard_layout";
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Task 40: push_subscriptions table
|
||||
CREATE TABLE "push_subscriptions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
|
||||
"endpoint" text NOT NULL UNIQUE,
|
||||
"p256dh" text NOT NULL,
|
||||
"auth" text NOT NULL,
|
||||
"user_agent" text,
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX "push_subscriptions_user_idx" ON "push_subscriptions" ("user_id");
|
||||
|
||||
-- Task 42: notifications table
|
||||
CREATE TABLE "notifications" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
|
||||
"title" text NOT NULL,
|
||||
"body" text NOT NULL,
|
||||
"url" text,
|
||||
"read_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX "notifications_user_read_idx" ON "notifications" ("user_id", "read_at");
|
||||
|
||||
-- Task 42: per-user notification channel preferences
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "notif_push" boolean NOT NULL DEFAULT true,
|
||||
ADD COLUMN "notif_inapp" boolean NOT NULL DEFAULT true,
|
||||
ADD COLUMN "notif_ntfy" boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- Task 41: add fired_at and created_by to reminders
|
||||
ALTER TABLE "reminders"
|
||||
ADD COLUMN "fired_at" timestamp with time zone,
|
||||
ADD COLUMN "created_by" uuid REFERENCES "users"("id") ON DELETE SET NULL;
|
||||
|
||||
-- Task 41: update default channel value to 'auto'
|
||||
UPDATE "reminders" SET "channel" = 'auto' WHERE "channel" = 'in_app';
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "users" ADD COLUMN "theme_palette" text NOT NULL DEFAULT 'clay';--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "theme_font_pair" text NOT NULL DEFAULT 'serif-sans';--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "theme_density" text NOT NULL DEFAULT 'regular';--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "theme_dash_layout" text NOT NULL DEFAULT 'classic';--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "theme_cal_view" text NOT NULL DEFAULT 'month';--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "theme_nav_style" text NOT NULL DEFAULT 'rail-desktop';--> statement-breakpoint
|
||||
ALTER TABLE "users" DROP COLUMN IF EXISTS "theme";
|
||||
@@ -0,0 +1,74 @@
|
||||
-- Task 70: Garden module — containers, plants, care logs, care schedules, species cache
|
||||
|
||||
CREATE TABLE "garden_containers" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
|
||||
"name" text NOT NULL,
|
||||
"type" text NOT NULL DEFAULT 'other',
|
||||
"location_notes" text,
|
||||
"cover_image_url" text,
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX "garden_containers_household_idx" ON "garden_containers" ("household_id");
|
||||
|
||||
CREATE TABLE "garden_plants" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
|
||||
"container_id" uuid REFERENCES "garden_containers"("id") ON DELETE SET NULL,
|
||||
"name" text NOT NULL,
|
||||
"scientific_name" text,
|
||||
"species_id" text,
|
||||
"category" text NOT NULL DEFAULT 'other',
|
||||
"notes" text,
|
||||
"acquisition_date" date,
|
||||
"growth_stage" text,
|
||||
"health_status" text NOT NULL DEFAULT 'healthy',
|
||||
"sunlight" text,
|
||||
"watering_notes" text,
|
||||
"fertilizing_notes" text,
|
||||
"primary_image_url" text,
|
||||
"images" jsonb NOT NULL DEFAULT '[]',
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX "garden_plants_household_idx" ON "garden_plants" ("household_id");
|
||||
CREATE INDEX "garden_plants_household_container_idx" ON "garden_plants" ("household_id", "container_id");
|
||||
|
||||
CREATE TABLE "garden_care_logs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plant_id" uuid NOT NULL REFERENCES "garden_plants"("id") ON DELETE CASCADE,
|
||||
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
|
||||
"care_type" text NOT NULL,
|
||||
"performed_by" uuid REFERENCES "users"("id") ON DELETE SET NULL,
|
||||
"notes" text,
|
||||
"performed_at" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX "garden_care_logs_plant_performed_idx" ON "garden_care_logs" ("plant_id", "performed_at");
|
||||
CREATE INDEX "garden_care_logs_household_idx" ON "garden_care_logs" ("household_id");
|
||||
|
||||
CREATE TABLE "garden_care_schedules" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plant_id" uuid NOT NULL REFERENCES "garden_plants"("id") ON DELETE CASCADE,
|
||||
"household_id" uuid NOT NULL REFERENCES "households"("id") ON DELETE CASCADE,
|
||||
"care_type" text NOT NULL,
|
||||
"interval_days" integer NOT NULL,
|
||||
"last_performed_at" timestamp with time zone,
|
||||
"next_due_at" timestamp with time zone,
|
||||
"enabled" boolean NOT NULL DEFAULT true,
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "garden_care_schedules_plant_type_uq" ON "garden_care_schedules" ("plant_id", "care_type");
|
||||
CREATE INDEX "garden_care_schedules_household_due_idx" ON "garden_care_schedules" ("household_id", "next_due_at");
|
||||
|
||||
CREATE TABLE "garden_species_cache" (
|
||||
"species_id" text PRIMARY KEY NOT NULL,
|
||||
"data" jsonb NOT NULL,
|
||||
"cached_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add title/body to reminders for rich notification bodies
|
||||
ALTER TABLE reminders ADD COLUMN IF NOT EXISTS title text;
|
||||
ALTER TABLE reminders ADD COLUMN IF NOT EXISTS body text;
|
||||
|
||||
-- Add metadata to list_items for garden task linking
|
||||
ALTER TABLE list_items ADD COLUMN IF NOT EXISTS metadata jsonb;
|
||||
@@ -0,0 +1,176 @@
|
||||
{
|
||||
"id": "afe12cff-df99-417d-977c-3e6a1c77981a",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.household_members": {
|
||||
"name": "household_members",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"household_id": {
|
||||
"name": "household_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "household_member_role",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'member'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"household_members_household_id_households_id_fk": {
|
||||
"name": "household_members_household_id_households_id_fk",
|
||||
"tableFrom": "household_members",
|
||||
"tableTo": "households",
|
||||
"columnsFrom": [
|
||||
"household_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"household_members_user_id_users_id_fk": {
|
||||
"name": "household_members_user_id_users_id_fk",
|
||||
"tableFrom": "household_members",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"household_members_household_id_user_id_pk": {
|
||||
"name": "household_members_household_id_user_id_pk",
|
||||
"columns": [
|
||||
"household_id",
|
||||
"user_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.households": {
|
||||
"name": "households",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.household_member_role": {
|
||||
"name": "household_member_role",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"owner",
|
||||
"member"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
{
|
||||
"id": "c3d4e5f6-a7b8-4901-c3d4-e5f6a7b89012",
|
||||
"prevId": "afe12cff-df99-417d-977c-3e6a1c77981a",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.accounts": {
|
||||
"name": "accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_account_id": {
|
||||
"name": "provider_account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"token_type": {
|
||||
"name": "token_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"session_state": {
|
||||
"name": "session_state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"accounts_user_id_users_id_fk": {
|
||||
"name": "accounts_user_id_users_id_fk",
|
||||
"tableFrom": "accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"accounts_provider_provider_account_id_pk": {
|
||||
"name": "accounts_provider_provider_account_id_pk",
|
||||
"columns": ["provider", "provider_account_id"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"session_token": {
|
||||
"name": "session_token",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification_tokens": {
|
||||
"name": "verification_tokens",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"verification_tokens_identifier_token_pk": {
|
||||
"name": "verification_tokens_identifier_token_pk",
|
||||
"columns": ["identifier", "token"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.household_members": {
|
||||
"name": "household_members",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"household_id": {
|
||||
"name": "household_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "household_member_role",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'member'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"household_members_household_id_households_id_fk": {
|
||||
"name": "household_members_household_id_households_id_fk",
|
||||
"tableFrom": "household_members",
|
||||
"tableTo": "households",
|
||||
"columnsFrom": ["household_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"household_members_user_id_users_id_fk": {
|
||||
"name": "household_members_user_id_users_id_fk",
|
||||
"tableFrom": "household_members",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"household_members_household_id_user_id_pk": {
|
||||
"name": "household_members_household_id_user_id_pk",
|
||||
"columns": ["household_id", "user_id"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.households": {
|
||||
"name": "households",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.household_member_role": {
|
||||
"name": "household_member_role",
|
||||
"schema": "public",
|
||||
"values": ["owner", "member"]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
{
|
||||
"id": "eac5a1bb-3598-4f33-999f-58c4e562feb8",
|
||||
"prevId": "c3d4e5f6-a7b8-4901-c3d4-e5f6a7b89012",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.accounts": {
|
||||
"name": "accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_account_id": {
|
||||
"name": "provider_account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"token_type": {
|
||||
"name": "token_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"session_state": {
|
||||
"name": "session_state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"accounts_user_id_users_id_fk": {
|
||||
"name": "accounts_user_id_users_id_fk",
|
||||
"tableFrom": "accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"accounts_provider_provider_account_id_pk": {
|
||||
"name": "accounts_provider_provider_account_id_pk",
|
||||
"columns": [
|
||||
"provider",
|
||||
"provider_account_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.household_members": {
|
||||
"name": "household_members",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"household_id": {
|
||||
"name": "household_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "household_member_role",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'member'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"household_members_household_id_households_id_fk": {
|
||||
"name": "household_members_household_id_households_id_fk",
|
||||
"tableFrom": "household_members",
|
||||
"tableTo": "households",
|
||||
"columnsFrom": [
|
||||
"household_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"household_members_user_id_users_id_fk": {
|
||||
"name": "household_members_user_id_users_id_fk",
|
||||
"tableFrom": "household_members",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"household_members_household_id_user_id_pk": {
|
||||
"name": "household_members_household_id_user_id_pk",
|
||||
"columns": [
|
||||
"household_id",
|
||||
"user_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.households": {
|
||||
"name": "households",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"session_token": {
|
||||
"name": "session_token",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"theme": {
|
||||
"name": "theme",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'default'"
|
||||
},
|
||||
"theme_mode": {
|
||||
"name": "theme_mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'system'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification_tokens": {
|
||||
"name": "verification_tokens",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"verification_tokens_identifier_token_pk": {
|
||||
"name": "verification_tokens_identifier_token_pk",
|
||||
"columns": [
|
||||
"identifier",
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.household_member_role": {
|
||||
"name": "household_member_role",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"owner",
|
||||
"member"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
{
|
||||
"id": "ba74770c-55d7-4238-97c6-49431a4fcb4c",
|
||||
"prevId": "eac5a1bb-3598-4f33-999f-58c4e562feb8",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.accounts": {
|
||||
"name": "accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_account_id": {
|
||||
"name": "provider_account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"token_type": {
|
||||
"name": "token_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"session_state": {
|
||||
"name": "session_state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"accounts_user_id_users_id_fk": {
|
||||
"name": "accounts_user_id_users_id_fk",
|
||||
"tableFrom": "accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"accounts_provider_provider_account_id_pk": {
|
||||
"name": "accounts_provider_provider_account_id_pk",
|
||||
"columns": [
|
||||
"provider",
|
||||
"provider_account_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.household_members": {
|
||||
"name": "household_members",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"household_id": {
|
||||
"name": "household_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "household_member_role",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'member'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"household_members_household_id_households_id_fk": {
|
||||
"name": "household_members_household_id_households_id_fk",
|
||||
"tableFrom": "household_members",
|
||||
"tableTo": "households",
|
||||
"columnsFrom": [
|
||||
"household_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"household_members_user_id_users_id_fk": {
|
||||
"name": "household_members_user_id_users_id_fk",
|
||||
"tableFrom": "household_members",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"household_members_household_id_user_id_pk": {
|
||||
"name": "household_members_household_id_user_id_pk",
|
||||
"columns": [
|
||||
"household_id",
|
||||
"user_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.households": {
|
||||
"name": "households",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"session_token": {
|
||||
"name": "session_token",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"theme": {
|
||||
"name": "theme",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'default'"
|
||||
},
|
||||
"theme_mode": {
|
||||
"name": "theme_mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'system'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification_tokens": {
|
||||
"name": "verification_tokens",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"verification_tokens_identifier_token_pk": {
|
||||
"name": "verification_tokens_identifier_token_pk",
|
||||
"columns": [
|
||||
"identifier",
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.calendar_events": {
|
||||
"name": "calendar_events",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"calendar_id": {
|
||||
"name": "calendar_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"start_at": {
|
||||
"name": "start_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"end_at": {
|
||||
"name": "end_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"all_day": {
|
||||
"name": "all_day",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"location": {
|
||||
"name": "location",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"owner_id": {
|
||||
"name": "owner_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"rrule": {
|
||||
"name": "rrule",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"external_source": {
|
||||
"name": "external_source",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"external_id": {
|
||||
"name": "external_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"calendar_events_calendar_start_idx": {
|
||||
"name": "calendar_events_calendar_start_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "calendar_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "start_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"calendar_events_calendar_id_calendars_id_fk": {
|
||||
"name": "calendar_events_calendar_id_calendars_id_fk",
|
||||
"tableFrom": "calendar_events",
|
||||
"tableTo": "calendars",
|
||||
"columnsFrom": [
|
||||
"calendar_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"calendar_events_owner_id_users_id_fk": {
|
||||
"name": "calendar_events_owner_id_users_id_fk",
|
||||
"tableFrom": "calendar_events",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"owner_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"calendar_events_range_check": {
|
||||
"name": "calendar_events_range_check",
|
||||
"value": "\"calendar_events\".\"end_at\" >= \"calendar_events\".\"start_at\""
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.calendars": {
|
||||
"name": "calendars",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"household_id": {
|
||||
"name": "household_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_id": {
|
||||
"name": "owner_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"visibility": {
|
||||
"name": "visibility",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'household'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"calendars_household_idx": {
|
||||
"name": "calendars_household_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "household_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"calendars_owner_idx": {
|
||||
"name": "calendars_owner_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "owner_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"calendars_household_id_households_id_fk": {
|
||||
"name": "calendars_household_id_households_id_fk",
|
||||
"tableFrom": "calendars",
|
||||
"tableTo": "households",
|
||||
"columnsFrom": [
|
||||
"household_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"calendars_owner_id_users_id_fk": {
|
||||
"name": "calendars_owner_id_users_id_fk",
|
||||
"tableFrom": "calendars",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"owner_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"calendars_visibility_check": {
|
||||
"name": "calendars_visibility_check",
|
||||
"value": "\"calendars\".\"visibility\" in ('private', 'household')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.household_member_role": {
|
||||
"name": "household_member_role",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"owner",
|
||||
"member"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,927 @@
|
||||
{
|
||||
"id": "5ca276cc-01ef-429a-aa67-822ef6ed7cbd",
|
||||
"prevId": "ba74770c-55d7-4238-97c6-49431a4fcb4c",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.accounts": {
|
||||
"name": "accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_account_id": {
|
||||
"name": "provider_account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"token_type": {
|
||||
"name": "token_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"session_state": {
|
||||
"name": "session_state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"accounts_user_id_users_id_fk": {
|
||||
"name": "accounts_user_id_users_id_fk",
|
||||
"tableFrom": "accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"accounts_provider_provider_account_id_pk": {
|
||||
"name": "accounts_provider_provider_account_id_pk",
|
||||
"columns": [
|
||||
"provider",
|
||||
"provider_account_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.household_members": {
|
||||
"name": "household_members",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"household_id": {
|
||||
"name": "household_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "household_member_role",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'member'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"household_members_household_id_households_id_fk": {
|
||||
"name": "household_members_household_id_households_id_fk",
|
||||
"tableFrom": "household_members",
|
||||
"tableTo": "households",
|
||||
"columnsFrom": [
|
||||
"household_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"household_members_user_id_users_id_fk": {
|
||||
"name": "household_members_user_id_users_id_fk",
|
||||
"tableFrom": "household_members",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"household_members_household_id_user_id_pk": {
|
||||
"name": "household_members_household_id_user_id_pk",
|
||||
"columns": [
|
||||
"household_id",
|
||||
"user_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.households": {
|
||||
"name": "households",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"session_token": {
|
||||
"name": "session_token",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"theme": {
|
||||
"name": "theme",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'default'"
|
||||
},
|
||||
"theme_mode": {
|
||||
"name": "theme_mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'system'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification_tokens": {
|
||||
"name": "verification_tokens",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"verification_tokens_identifier_token_pk": {
|
||||
"name": "verification_tokens_identifier_token_pk",
|
||||
"columns": [
|
||||
"identifier",
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.calendar_events": {
|
||||
"name": "calendar_events",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"calendar_id": {
|
||||
"name": "calendar_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"start_at": {
|
||||
"name": "start_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"end_at": {
|
||||
"name": "end_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"all_day": {
|
||||
"name": "all_day",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"location": {
|
||||
"name": "location",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"owner_id": {
|
||||
"name": "owner_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"rrule": {
|
||||
"name": "rrule",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"external_source": {
|
||||
"name": "external_source",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"external_id": {
|
||||
"name": "external_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"calendar_events_calendar_start_idx": {
|
||||
"name": "calendar_events_calendar_start_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "calendar_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "start_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"calendar_events_calendar_id_calendars_id_fk": {
|
||||
"name": "calendar_events_calendar_id_calendars_id_fk",
|
||||
"tableFrom": "calendar_events",
|
||||
"tableTo": "calendars",
|
||||
"columnsFrom": [
|
||||
"calendar_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"calendar_events_owner_id_users_id_fk": {
|
||||
"name": "calendar_events_owner_id_users_id_fk",
|
||||
"tableFrom": "calendar_events",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"owner_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"calendar_events_range_check": {
|
||||
"name": "calendar_events_range_check",
|
||||
"value": "\"calendar_events\".\"end_at\" >= \"calendar_events\".\"start_at\""
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.calendars": {
|
||||
"name": "calendars",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"household_id": {
|
||||
"name": "household_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_id": {
|
||||
"name": "owner_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"visibility": {
|
||||
"name": "visibility",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'household'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"calendars_household_idx": {
|
||||
"name": "calendars_household_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "household_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"calendars_owner_idx": {
|
||||
"name": "calendars_owner_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "owner_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"calendars_household_id_households_id_fk": {
|
||||
"name": "calendars_household_id_households_id_fk",
|
||||
"tableFrom": "calendars",
|
||||
"tableTo": "households",
|
||||
"columnsFrom": [
|
||||
"household_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"calendars_owner_id_users_id_fk": {
|
||||
"name": "calendars_owner_id_users_id_fk",
|
||||
"tableFrom": "calendars",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"owner_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"calendars_visibility_check": {
|
||||
"name": "calendars_visibility_check",
|
||||
"value": "\"calendars\".\"visibility\" in ('private', 'household')"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.list_items": {
|
||||
"name": "list_items",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"list_id": {
|
||||
"name": "list_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"text": {
|
||||
"name": "text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"done": {
|
||||
"name": "done",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"qty": {
|
||||
"name": "qty",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"due_at": {
|
||||
"name": "due_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"assignee_id": {
|
||||
"name": "assignee_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"position": {
|
||||
"name": "position",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"list_items_list_position_idx": {
|
||||
"name": "list_items_list_position_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "list_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "position",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"list_items_assignee_idx": {
|
||||
"name": "list_items_assignee_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "assignee_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"list_items_list_id_lists_id_fk": {
|
||||
"name": "list_items_list_id_lists_id_fk",
|
||||
"tableFrom": "list_items",
|
||||
"tableTo": "lists",
|
||||
"columnsFrom": [
|
||||
"list_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"list_items_assignee_id_users_id_fk": {
|
||||
"name": "list_items_assignee_id_users_id_fk",
|
||||
"tableFrom": "list_items",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"assignee_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.lists": {
|
||||
"name": "lists",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"household_id": {
|
||||
"name": "household_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"archived": {
|
||||
"name": "archived",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"lists_household_type_idx": {
|
||||
"name": "lists_household_type_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "household_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "type",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"lists_household_archived_idx": {
|
||||
"name": "lists_household_archived_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "household_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "archived",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"lists_household_id_households_id_fk": {
|
||||
"name": "lists_household_id_households_id_fk",
|
||||
"tableFrom": "lists",
|
||||
"tableTo": "households",
|
||||
"columnsFrom": [
|
||||
"household_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.household_member_role": {
|
||||
"name": "household_member_role",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"owner",
|
||||
"member"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1778048377946,
|
||||
"tag": "0000_silent_magma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1746533400000,
|
||||
"tag": "0001_auth_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1778053473887,
|
||||
"tag": "0002_naive_starbolt",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1778054818403,
|
||||
"tag": "0003_rainy_ravenous",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1778055440944,
|
||||
"tag": "0004_opposite_wraith",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1778056000000,
|
||||
"tag": "0005_auth_schema_repair",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1778057808712,
|
||||
"tag": "0006_new_hannibal_king",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1778093344354,
|
||||
"tag": "0007_uneven_living_lightning",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1778120000000,
|
||||
"tag": "0008_activity_log",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1778200000000,
|
||||
"tag": "0009_share_links",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1778260000000,
|
||||
"tag": "0010_nullable_actor_id",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1778300000000,
|
||||
"tag": "0011_completion_visibility",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1778350000000,
|
||||
"tag": "0012_dashboards",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1778400000000,
|
||||
"tag": "0013_push_notify_reminders",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1778600000000,
|
||||
"tag": "0014_paper_ink_theme",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1748736000000,
|
||||
"tag": "0015_garden_schema",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1748822400000,
|
||||
"tag": "0016_garden_improvements",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,8 +8,11 @@ export default tseslint.config(
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
".claude/**",
|
||||
".design-tmp/**",
|
||||
"dist/**",
|
||||
"drizzle/**",
|
||||
"public/sw.js",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
|
||||
+176
@@ -1,8 +1,184 @@
|
||||
import type { NextConfig } from "next";
|
||||
import { writeFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
// Stable in dev to avoid cache churn on hot-reload; unique per production build.
|
||||
const BUILD_TIME = process.env.NODE_ENV === "development" ? "dev" : String(Date.now());
|
||||
|
||||
writeFileSync(resolve(process.cwd(), "public/sw.js"), buildSwContent(BUILD_TIME));
|
||||
|
||||
function buildSwContent(version: string): string {
|
||||
return `// famapp service worker — v${version}
|
||||
// Generated at build time. Do not edit directly.
|
||||
const CACHE_VERSION = "${version}";
|
||||
const SHELL = "famapp-shell-" + CACHE_VERSION;
|
||||
const API = "famapp-api-" + CACHE_VERSION;
|
||||
const OFFLINE = "/offline.html";
|
||||
|
||||
// --- install: precache the offline fallback ---
|
||||
|
||||
self.addEventListener("install", (e) => {
|
||||
e.waitUntil(caches.open(SHELL).then((c) => c.add(OFFLINE)));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// --- activate: evict old-version caches, claim clients ---
|
||||
|
||||
self.addEventListener("activate", (e) => {
|
||||
e.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter(
|
||||
(k) =>
|
||||
k.startsWith("famapp-") && !k.endsWith("-" + CACHE_VERSION)
|
||||
)
|
||||
.map((k) => caches.delete(k))
|
||||
)
|
||||
)
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// --- fetch: strategy dispatch ---
|
||||
|
||||
self.addEventListener("fetch", (e) => {
|
||||
const { request } = e;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Mutations — network-only; notify clients if the network is unreachable.
|
||||
if (request.method !== "GET") {
|
||||
e.respondWith(
|
||||
fetch(request).catch(() => {
|
||||
self.clients
|
||||
.matchAll({ includeUncontrolled: true })
|
||||
.then((cs) =>
|
||||
cs.forEach((c) => c.postMessage({ type: "OFFLINE_MUTATION" }))
|
||||
);
|
||||
return new Response(JSON.stringify({ error: "offline" }), {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Auth endpoints — always network-only.
|
||||
if (url.pathname.startsWith("/api/auth/")) return;
|
||||
|
||||
// Next.js immutable static chunks — stale-while-revalidate.
|
||||
if (url.pathname.startsWith("/_next/static/")) {
|
||||
e.respondWith(staleWhileRevalidate(request, SHELL));
|
||||
return;
|
||||
}
|
||||
|
||||
// API GETs — network-first with 2-second timeout, fall back to cache.
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
e.respondWith(networkFirstWithTimeout(request, API, 2000));
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation — stale-while-revalidate; fall back to offline page.
|
||||
if (request.mode === "navigate") {
|
||||
e.respondWith(navigateWithFallback(request));
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// --- push notifications ---
|
||||
|
||||
self.addEventListener("push", (e) => {
|
||||
if (!e.data) return;
|
||||
const data = e.data.json();
|
||||
e.waitUntil(
|
||||
self.registration.showNotification(data.title || "famapp", {
|
||||
body: data.body || "",
|
||||
data: { url: data.url || "/" },
|
||||
icon: "/icon-192.png",
|
||||
badge: "/icon-192.png",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (e) => {
|
||||
e.notification.close();
|
||||
const url = e.notification.data?.url || "/";
|
||||
e.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: "window", includeUncontrolled: true })
|
||||
.then((cs) => {
|
||||
const match = cs.find((c) => c.url.includes(self.location.origin));
|
||||
if (match) {
|
||||
match.focus();
|
||||
return match.navigate(url);
|
||||
}
|
||||
return self.clients.openWindow(url);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// --- strategy helpers ---
|
||||
|
||||
async function staleWhileRevalidate(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
const update = fetch(request)
|
||||
.then((res) => {
|
||||
if (res.ok) cache.put(request, res.clone());
|
||||
return res;
|
||||
})
|
||||
.catch(() => null);
|
||||
// Serve cached immediately; let update happen in the background.
|
||||
return cached ?? (await update);
|
||||
}
|
||||
|
||||
async function networkFirstWithTimeout(request, cacheName, ms) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const ac = new AbortController();
|
||||
const timer = setTimeout(() => ac.abort(), ms);
|
||||
try {
|
||||
const res = await fetch(request, { signal: ac.signal });
|
||||
clearTimeout(timer);
|
||||
if (res.ok) cache.put(request, res.clone());
|
||||
return res;
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
return (await cache.match(request)) ?? new Response(null, { status: 503 });
|
||||
}
|
||||
}
|
||||
|
||||
async function navigateWithFallback(request) {
|
||||
const cache = await caches.open(SHELL);
|
||||
const cached = await cache.match(request);
|
||||
const networkFetch = fetch(request)
|
||||
.then((res) => {
|
||||
if (res.ok) cache.put(request, res.clone());
|
||||
return res;
|
||||
})
|
||||
.catch(() => null);
|
||||
if (cached) {
|
||||
// Return cached immediately; revalidate in the background.
|
||||
networkFetch;
|
||||
return cached;
|
||||
}
|
||||
const net = await networkFetch;
|
||||
if (net) return net;
|
||||
return (
|
||||
(await cache.match(OFFLINE)) ?? new Response("Offline", { status: 503 })
|
||||
);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: "standalone",
|
||||
// Keep pino and pino-pretty as native Node.js requires so their worker-thread
|
||||
// transport and stream internals work correctly inside the standalone bundle.
|
||||
serverExternalPackages: ["pino", "pino-pretty", "drizzle-orm", "postgres"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
+40
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "famapp",
|
||||
"version": "0.0.0",
|
||||
"version": "0.4.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.3",
|
||||
@@ -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 .",
|
||||
@@ -20,21 +23,51 @@
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:seed": "tsx --env-file=.env scripts/seed.ts",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"gen:icons": "node scripts/generate-icons.mjs",
|
||||
"vapid:generate": "node scripts/vapid-generate.mjs",
|
||||
"prepare": "husky",
|
||||
"release": "dotenv -e .env -- release-it",
|
||||
"release:patch": "dotenv -e .env -- release-it patch",
|
||||
"release:minor": "dotenv -e .env -- release-it minor",
|
||||
"release:major": "dotenv -e .env -- release-it major",
|
||||
"release:dry": "dotenv -e .env -- release-it --dry-run"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{js,mjs,cjs}": [
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{json,md,css,yaml,yml}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^21.0.2",
|
||||
"@commitlint/config-conventional": "^21.0.2",
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@release-it/conventional-changelog": "^11.0.1",
|
||||
"@tailwindcss/postcss": "^4.2.4",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-grid-layout": "^2.1.0",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-config-next": "^16.2.4",
|
||||
"globals": "^15.12.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.7",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prettier": "^3.3.3",
|
||||
"release-it": "^20.2.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.6.3",
|
||||
@@ -50,16 +83,21 @@
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"lucide-react": "^1.14.0",
|
||||
"minio": "^8.0.7",
|
||||
"next": "^15.5.15",
|
||||
"next-auth": "5.0.0-beta.31",
|
||||
"pino": "^10.3.1",
|
||||
"postgres": "^3.4.9",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-grid-layout": "^2.2.3",
|
||||
"shadcn": "^4.7.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^4.4.3",
|
||||
"zod-to-json-schema": "^3.25.2"
|
||||
}
|
||||
|
||||
Generated
+2750
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 496 B |
Binary file not shown.
|
After Width: | Height: | Size: 547 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<!-- Background -->
|
||||
<rect width="512" height="512" rx="80" fill="#4F46E5"/>
|
||||
<!-- House body -->
|
||||
<polygon points="256,108 396,234 364,234 364,392 148,392 148,234 116,234" fill="white"/>
|
||||
<!-- Door -->
|
||||
<rect x="212" y="296" width="88" height="96" rx="6" fill="#4F46E5"/>
|
||||
<!-- Left window -->
|
||||
<rect x="164" y="254" width="66" height="54" rx="6" fill="#4F46E5" opacity="0.55"/>
|
||||
<!-- Right window -->
|
||||
<rect x="282" y="254" width="66" height="54" rx="6" fill="#4F46E5" opacity="0.55"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 594 B |
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "famapp",
|
||||
"short_name": "famapp",
|
||||
"description": "Family coordination app",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#4F46E5",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#4F46E5" />
|
||||
<title>famapp — offline</title>
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--indigo: #4f46e5;
|
||||
--indigo-light: #e0e7ff;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.25rem;
|
||||
padding: 2rem;
|
||||
background: #f9fafb;
|
||||
color: #111827;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 16px;
|
||||
background: var(--indigo);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.9375rem;
|
||||
color: #6b7280;
|
||||
max-width: 28ch;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.625rem 1.5rem;
|
||||
background: var(--indigo);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="icon">
|
||||
<!-- House silhouette -->
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="20,5 34,17 31,17 31,35 9,35 9,17 6,17" fill="white" />
|
||||
<rect x="15" y="22" width="10" height="13" rx="1" fill="#4F46E5" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1>You're offline</h1>
|
||||
<p>famapp needs a connection — check your network and try again.</p>
|
||||
<button onclick="location.reload()">Retry</button>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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();
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Generates placeholder PNG icons for the famapp PWA.
|
||||
*
|
||||
* Usage: node scripts/generate-icons.mjs
|
||||
*
|
||||
* Produces:
|
||||
* public/icon-192.png
|
||||
* public/icon-384.png
|
||||
* public/icon-512.png
|
||||
* public/icon-512-maskable.png (same image; OS applies its own mask)
|
||||
* public/icon-180.png (apple-touch-icon)
|
||||
*
|
||||
* All images are solid indigo (#4F46E5) squares — replace with a real
|
||||
* branded export from icon.svg when assets are finalised.
|
||||
*/
|
||||
|
||||
import { deflateSync } from "zlib";
|
||||
import { writeFileSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const publicDir = resolve(__dir, "../public");
|
||||
|
||||
// Build CRC-32 lookup table once.
|
||||
const CRC_TABLE = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
CRC_TABLE[i] = c;
|
||||
}
|
||||
|
||||
function crc32(buf) {
|
||||
let crc = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function u32(n) {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32BE(n, 0);
|
||||
return b;
|
||||
}
|
||||
|
||||
function pngChunk(type, data) {
|
||||
const typeBytes = Buffer.from(type, "ascii");
|
||||
const crc = u32(crc32(Buffer.concat([typeBytes, data])));
|
||||
return Buffer.concat([u32(data.length), typeBytes, data, crc]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Buffer containing a valid PNG of size×size filled with (r, g, b).
|
||||
*/
|
||||
function solidPNG(size, r, g, b) {
|
||||
const PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
|
||||
const ihdr = pngChunk(
|
||||
"IHDR",
|
||||
Buffer.concat([
|
||||
u32(size),
|
||||
u32(size),
|
||||
Buffer.from([8, 2, 0, 0, 0]), // 8-bit RGB, no interlace
|
||||
]),
|
||||
);
|
||||
|
||||
// One filter byte (0 = None) followed by size×3 RGB bytes per row.
|
||||
const rowLen = 1 + size * 3;
|
||||
const raw = Buffer.alloc(size * rowLen);
|
||||
for (let y = 0; y < size; y++) {
|
||||
const base = y * rowLen;
|
||||
raw[base] = 0;
|
||||
for (let x = 0; x < size; x++) {
|
||||
raw[base + 1 + x * 3] = r;
|
||||
raw[base + 2 + x * 3] = g;
|
||||
raw[base + 3 + x * 3] = b;
|
||||
}
|
||||
}
|
||||
|
||||
const idat = pngChunk("IDAT", deflateSync(raw));
|
||||
const iend = pngChunk("IEND", Buffer.alloc(0));
|
||||
|
||||
return Buffer.concat([PNG_SIG, ihdr, idat, iend]);
|
||||
}
|
||||
|
||||
// Indigo-600 (#4F46E5)
|
||||
const [R, G, B] = [0x4f, 0x46, 0xe5];
|
||||
|
||||
const icons = [
|
||||
{ name: "icon-192.png", size: 192 },
|
||||
{ name: "icon-384.png", size: 384 },
|
||||
{ name: "icon-512.png", size: 512 },
|
||||
{ name: "icon-512-maskable.png", size: 512 },
|
||||
{ name: "icon-180.png", size: 180 },
|
||||
];
|
||||
|
||||
for (const { name, size } of icons) {
|
||||
const out = resolve(publicDir, name);
|
||||
writeFileSync(out, solidPNG(size, R, G, B));
|
||||
console.log(` ✓ public/${name} (${size}×${size})`);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||
import postgres from "postgres";
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("DATABASE_URL is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const migrationsFolder = process.env.MIGRATIONS_FOLDER ?? "./drizzle";
|
||||
const sql = postgres(url, { max: 1 });
|
||||
const db = drizzle(sql);
|
||||
|
||||
try {
|
||||
await migrate(db, { migrationsFolder });
|
||||
console.log(
|
||||
JSON.stringify({ level: "info", msg: "migrations applied", folder: migrationsFolder }),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(JSON.stringify({ level: "error", msg: "migrations failed", err: String(err) }));
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import postgres from "postgres";
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error(JSON.stringify({ level: "error", msg: "DATABASE_URL is required" }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sql = postgres(url, { max: 1 });
|
||||
|
||||
try {
|
||||
const [existing] = await sql`SELECT id, name FROM households LIMIT 1`;
|
||||
if (existing) {
|
||||
console.log(JSON.stringify({ level: "info", msg: "household exists", name: existing.name }));
|
||||
} else {
|
||||
await sql`INSERT INTO households (name) VALUES ('Home')`;
|
||||
console.log(JSON.stringify({ level: "info", msg: "seeded household Home" }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(JSON.stringify({ level: "error", msg: "seed failed", err: String(err) }));
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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();
|
||||
@@ -0,0 +1,5 @@
|
||||
import webPush from "web-push";
|
||||
|
||||
const { publicKey, privateKey } = webPush.generateVAPIDKeys();
|
||||
console.log(`VAPID_PUBLIC_KEY=${publicKey}`);
|
||||
console.log(`VAPID_PRIVATE_KEY=${privateKey}`);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { searchSpecies } from "@/modules/garden/server/species-lookup";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await getCurrentSession();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const q = searchParams.get("q") ?? "";
|
||||
|
||||
const results = await searchSpecies(q);
|
||||
return NextResponse.json(results);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { minioClient, MINIO_BUCKET } from "@/lib/minio";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ key: string[] }> }) {
|
||||
const { key } = await params;
|
||||
const objectKey = key.join("/");
|
||||
|
||||
try {
|
||||
const stat = await minioClient.statObject(MINIO_BUCKET, objectKey);
|
||||
const stream = await minioClient.getObject(MINIO_BUCKET, objectKey);
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
|
||||
const metaData = stat.metaData as Record<string, string> | undefined;
|
||||
const contentType =
|
||||
metaData?.["content-type"] ?? metaData?.["Content-Type"] ?? "application/octet-stream";
|
||||
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"Content-Length": String(buffer.length),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { ensureBucket, minioClient, MINIO_BUCKET } from "@/lib/minio";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 60;
|
||||
|
||||
export const config = {
|
||||
api: { bodyParser: { sizeLimit: "100mb" } },
|
||||
};
|
||||
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let session: Awaited<ReturnType<typeof getCurrentSession>>;
|
||||
try {
|
||||
session = await getCurrentSession();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid multipart body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "No file field in request" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return NextResponse.json({ error: "Only image files are allowed" }, { status: 415 });
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json({ error: "File exceeds 100 MB limit" }, { status: 413 });
|
||||
}
|
||||
|
||||
const rawExt = file.name.split(".").pop() ?? "bin";
|
||||
const safeExt = rawExt
|
||||
.replace(/[^a-z0-9]/gi, "")
|
||||
.toLowerCase()
|
||||
.slice(0, 8);
|
||||
const key = `garden/${session.household.id}/${randomUUID()}.${safeExt}`;
|
||||
|
||||
try {
|
||||
await ensureBucket();
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await minioClient.putObject(MINIO_BUCKET, key, buffer, buffer.length, {
|
||||
"Content-Type": file.type,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Upload failed", err);
|
||||
return NextResponse.json({ error: "Upload service unavailable" }, { status: 503 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ url: `/api/uploads/${key}` });
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { CalendarShell } from "@/modules/calendar/components/calendar-shell";
|
||||
import { listCalendars, listEvents } from "@/modules/calendar/server/queries";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import type { CalView } from "@/modules/_core/themes";
|
||||
|
||||
export default async function CalendarPage() {
|
||||
const now = new Date();
|
||||
@@ -8,10 +10,17 @@ export default async function CalendarPage() {
|
||||
const to = new Date(now);
|
||||
to.setMonth(to.getMonth() + 10);
|
||||
|
||||
const [calendars, events] = await Promise.all([
|
||||
const [{ user }, calendars, events] = await Promise.all([
|
||||
getCurrentSession(),
|
||||
listCalendars(),
|
||||
listEvents({ from, to, calendarIds: "all" }),
|
||||
]);
|
||||
|
||||
return <CalendarShell calendars={calendars} events={events} />;
|
||||
return (
|
||||
<CalendarShell
|
||||
calendars={calendars}
|
||||
events={events}
|
||||
defaultView={user.themeCalView as CalView}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { parseDashboardLayout } from "@/lib/dashboard";
|
||||
import { computeDefaultLayout } from "@/lib/dashboard.server";
|
||||
import { getWidget, getWidgetMetas } from "@/modules/_core";
|
||||
import { getDashboardBySlug } from "@/app/d/actions";
|
||||
import { DashboardEditor } from "@/components/dashboard-editor";
|
||||
import { EditDashboardButton } from "@/components/edit-dashboard-button";
|
||||
import { DashboardSwitcher } from "@/components/dashboard-switcher";
|
||||
import { DashboardTab } from "@/components/dashboard-tab";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { dashboards } from "@/modules/_core/schema";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { DashLayout } from "@/modules/_core/themes";
|
||||
|
||||
const smColSpan: Record<number, string> = {
|
||||
1: "sm:col-span-1",
|
||||
2: "sm:col-span-2",
|
||||
3: "sm:col-span-3",
|
||||
4: "sm:col-span-4",
|
||||
5: "sm:col-span-5",
|
||||
6: "sm:col-span-6",
|
||||
7: "sm:col-span-7",
|
||||
8: "sm:col-span-8",
|
||||
9: "sm:col-span-9",
|
||||
10: "sm:col-span-10",
|
||||
11: "sm:col-span-11",
|
||||
12: "sm:col-span-12",
|
||||
};
|
||||
|
||||
const greetingForHour = (h: number) =>
|
||||
h < 5 ? "Up early" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
|
||||
|
||||
export default async function DashboardPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ edit?: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const { edit } = await searchParams;
|
||||
const isEditing = edit === "1";
|
||||
|
||||
const { user, household } = await getCurrentSession();
|
||||
const dashboard = await getDashboardBySlug(slug);
|
||||
if (!dashboard) notFound();
|
||||
|
||||
const layout = parseDashboardLayout(dashboard.layout) ?? computeDefaultLayout();
|
||||
const widgetMetas = getWidgetMetas();
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<DashboardEditor
|
||||
dashboard={{ id: dashboard.id, name: dashboard.name, slug: dashboard.slug }}
|
||||
layout={layout}
|
||||
widgetMetas={widgetMetas}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// For dashboard tabs sub-header, pull the user's dashboards (small list).
|
||||
const session = await auth();
|
||||
const userDashboards = session?.user?.id
|
||||
? await db
|
||||
.select({
|
||||
id: dashboards.id,
|
||||
name: dashboards.name,
|
||||
slug: dashboards.slug,
|
||||
isDefault: dashboards.isDefault,
|
||||
position: dashboards.position,
|
||||
})
|
||||
.from(dashboards)
|
||||
.where(eq(dashboards.userId, session.user.id))
|
||||
.orderBy(asc(dashboards.position), asc(dashboards.createdAt))
|
||||
: [];
|
||||
|
||||
const ctx = { userId: user.id, householdId: household.id };
|
||||
const placements = [...layout.widgets].sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
const dashLayout = (user.themeDashLayout as DashLayout) ?? "classic";
|
||||
const containerCls =
|
||||
dashLayout === "glance" ? "max-w-[760px] mx-auto" : dashLayout === "split" ? "" : "";
|
||||
const greeting = greetingForHour(new Date().getHours());
|
||||
const today = new Date().toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
const firstName = (user.name ?? "").split(" ")[0] ?? "";
|
||||
|
||||
return (
|
||||
<div className={containerCls}>
|
||||
{userDashboards.length > 1 && (
|
||||
<div className="flex items-center gap-1 mb-4 overflow-x-auto">
|
||||
{userDashboards.map((d) => (
|
||||
<DashboardTab key={d.id} slug={d.slug} name={d.name} />
|
||||
))}
|
||||
<DashboardSwitcher dashboards={userDashboards} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6 flex items-end justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="serif text-[26px] sm:text-[30px] leading-tight tracking-tight">
|
||||
{greeting}
|
||||
{firstName && `, ${firstName}`}.
|
||||
</h1>
|
||||
<p className="muted mt-1 text-[13px]">{today}</p>
|
||||
</div>
|
||||
<EditDashboardButton />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12">
|
||||
{placements.map((placement, i) => {
|
||||
const widget = getWidget(placement.widgetId);
|
||||
if (!widget) return null;
|
||||
const colClass = smColSpan[placement.w] ?? "sm:col-span-12";
|
||||
return (
|
||||
<div key={i} className={`col-span-1 ${colClass}`}>
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>{widget.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-3 w-3/4 rounded bg-muted" />
|
||||
<div className="h-3 w-1/2 rounded bg-muted" />
|
||||
<div className="h-3 w-2/3 rounded bg-muted" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{widget.render({ config: placement.config, ctx })}
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
"use server";
|
||||
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { getWidget } from "@/modules/_core";
|
||||
import { dashboards } from "@/modules/_core/schema";
|
||||
import { type DashboardLayout } from "@/lib/dashboard";
|
||||
import { computeDefaultLayout } from "@/lib/dashboard.server";
|
||||
|
||||
export type DashboardMeta = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
isDefault: boolean;
|
||||
position: number;
|
||||
};
|
||||
|
||||
export async function listDashboards(): Promise<DashboardMeta[]> {
|
||||
const { user } = await getCurrentSession();
|
||||
const rows = await db
|
||||
.select({
|
||||
id: dashboards.id,
|
||||
name: dashboards.name,
|
||||
slug: dashboards.slug,
|
||||
isDefault: dashboards.isDefault,
|
||||
position: dashboards.position,
|
||||
})
|
||||
.from(dashboards)
|
||||
.where(eq(dashboards.userId, user.id))
|
||||
.orderBy(asc(dashboards.position), asc(dashboards.createdAt));
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getDefaultDashboardSlug(): Promise<string> {
|
||||
const { user } = await getCurrentSession();
|
||||
const rows = await db
|
||||
.select({ slug: dashboards.slug })
|
||||
.from(dashboards)
|
||||
.where(and(eq(dashboards.userId, user.id), eq(dashboards.isDefault, true)))
|
||||
.limit(1);
|
||||
if (rows[0]) return rows[0].slug;
|
||||
// Fallback: first dashboard by position
|
||||
const [first] = await db
|
||||
.select({ slug: dashboards.slug })
|
||||
.from(dashboards)
|
||||
.where(eq(dashboards.userId, user.id))
|
||||
.orderBy(asc(dashboards.position), asc(dashboards.createdAt))
|
||||
.limit(1);
|
||||
if (first) return first.slug;
|
||||
// No dashboards yet — insert one directly without revalidatePath (we're in a
|
||||
// render path; the redirect that follows is a fresh request anyway).
|
||||
const slug = await uniqueSlug(user.id, "home");
|
||||
await db
|
||||
.insert(dashboards)
|
||||
.values({ userId: user.id, name: "Home", slug, isDefault: false, position: 0 });
|
||||
return slug;
|
||||
}
|
||||
|
||||
export async function getDashboardBySlug(slug: string) {
|
||||
const { user } = await getCurrentSession();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(dashboards)
|
||||
.where(and(eq(dashboards.userId, user.id), eq(dashboards.slug, slug)))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
function toSlug(name: string): string {
|
||||
return (
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "") || "dashboard"
|
||||
);
|
||||
}
|
||||
|
||||
async function uniqueSlug(userId: string, base: string): Promise<string> {
|
||||
let slug = base;
|
||||
let attempt = 1;
|
||||
for (;;) {
|
||||
const [existing] = await db
|
||||
.select({ id: dashboards.id })
|
||||
.from(dashboards)
|
||||
.where(and(eq(dashboards.userId, userId), eq(dashboards.slug, slug)))
|
||||
.limit(1);
|
||||
if (!existing) return slug;
|
||||
attempt++;
|
||||
slug = `${base}-${attempt}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createDashboard(name: string): Promise<DashboardMeta> {
|
||||
const parsed = z.string().trim().min(1).max(80).parse(name);
|
||||
const { user } = await getCurrentSession();
|
||||
const slug = await uniqueSlug(user.id, toSlug(parsed));
|
||||
const rows = await db
|
||||
.insert(dashboards)
|
||||
.values({ userId: user.id, name: parsed, slug, isDefault: false, position: 9999 })
|
||||
.returning();
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error("Insert failed");
|
||||
revalidatePath("/");
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
isDefault: row.isDefault,
|
||||
position: row.position,
|
||||
};
|
||||
}
|
||||
|
||||
export async function renameDashboard(id: string, name: string): Promise<void> {
|
||||
const parsedName = z.string().trim().min(1).max(80).parse(name);
|
||||
const { user } = await getCurrentSession();
|
||||
await db
|
||||
.update(dashboards)
|
||||
.set({ name: parsedName, updatedAt: new Date() })
|
||||
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function deleteDashboard(id: string): Promise<void> {
|
||||
const { user } = await getCurrentSession();
|
||||
const all = await db
|
||||
.select({ id: dashboards.id, isDefault: dashboards.isDefault, slug: dashboards.slug })
|
||||
.from(dashboards)
|
||||
.where(eq(dashboards.userId, user.id));
|
||||
if (all.length <= 1) throw new Error("Cannot delete your only dashboard");
|
||||
const target = all.find((d) => d.id === id);
|
||||
if (!target) throw new Error("Dashboard not found");
|
||||
|
||||
await db.delete(dashboards).where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
||||
|
||||
// If we deleted the default, promote another
|
||||
if (target.isDefault) {
|
||||
const next = all.find((d) => d.id !== id);
|
||||
if (next) {
|
||||
await db
|
||||
.update(dashboards)
|
||||
.set({ isDefault: true })
|
||||
.where(and(eq(dashboards.id, next.id), eq(dashboards.userId, user.id)));
|
||||
}
|
||||
}
|
||||
revalidatePath("/");
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
export async function setDefaultDashboard(id: string): Promise<void> {
|
||||
const { user } = await getCurrentSession();
|
||||
await db.update(dashboards).set({ isDefault: false }).where(eq(dashboards.userId, user.id));
|
||||
await db
|
||||
.update(dashboards)
|
||||
.set({ isDefault: true })
|
||||
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function reorderDashboards(orderedIds: string[]): Promise<void> {
|
||||
const { user } = await getCurrentSession();
|
||||
await Promise.all(
|
||||
orderedIds.map((id, i) =>
|
||||
db
|
||||
.update(dashboards)
|
||||
.set({ position: i })
|
||||
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))),
|
||||
),
|
||||
);
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function saveDashboardLayout(id: string, layout: DashboardLayout): Promise<void> {
|
||||
const { user } = await getCurrentSession();
|
||||
// Validate each widget's config against its registered schema
|
||||
for (const placement of layout.widgets) {
|
||||
const widget = getWidget(placement.widgetId);
|
||||
if (!widget) continue;
|
||||
widget.configSchema.parse(placement.config);
|
||||
}
|
||||
await db
|
||||
.update(dashboards)
|
||||
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
|
||||
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function resetDashboardLayout(id: string): Promise<void> {
|
||||
const { user } = await getCurrentSession();
|
||||
const layout = computeDefaultLayout();
|
||||
await db
|
||||
.update(dashboards)
|
||||
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
|
||||
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function resolveWidgetConfigOptions(widgetId: string): Promise<unknown> {
|
||||
const { user, household } = await getCurrentSession();
|
||||
const widget = getWidget(widgetId);
|
||||
if (!widget?.resolveConfigOptions) return null;
|
||||
return widget.resolveConfigOptions({ userId: user.id, householdId: household.id });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getContainer } from "@/modules/garden/server/queries";
|
||||
import { ContainerDetail } from "@/modules/garden/components/container-detail";
|
||||
|
||||
export default async function ContainerPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const container = await getContainer(id);
|
||||
if (!container) notFound();
|
||||
return (
|
||||
<div className="page-content">
|
||||
<ContainerDetail container={container} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createContainer } from "@/modules/garden/server/actions";
|
||||
|
||||
export default function NewContainerPage() {
|
||||
async function handleCreate(formData: FormData) {
|
||||
"use server";
|
||||
await createContainer({
|
||||
name: formData.get("name") as string,
|
||||
type: (formData.get("type") as string) || "other",
|
||||
locationNotes: (formData.get("locationNotes") as string) || null,
|
||||
});
|
||||
redirect("/garden");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-content max-w-lg">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<a href="/garden" className="btn btn-ghost btn-icon" aria-label="Back to garden">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</a>
|
||||
<h1 className="page-title">New container</h1>
|
||||
</div>
|
||||
<form action={handleCreate} className="flex flex-col gap-4 mt-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="name" className="text-sm font-medium">
|
||||
Name
|
||||
</label>
|
||||
<input id="name" name="name" required maxLength={120} className="input" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="type" className="text-sm font-medium">
|
||||
Type
|
||||
</label>
|
||||
<select id="type" name="type" defaultValue="other">
|
||||
{(
|
||||
[
|
||||
["shelf", "Shelf"],
|
||||
["terrarium", "Terrarium"],
|
||||
["raised-bed", "Raised bed"],
|
||||
["window-box", "Window box"],
|
||||
["single-pot", "Single pot"],
|
||||
["outdoor", "Outdoor"],
|
||||
["other", "Other"],
|
||||
] as const
|
||||
).map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="locationNotes" className="text-sm font-medium">
|
||||
Location notes
|
||||
</label>
|
||||
<textarea
|
||||
id="locationNotes"
|
||||
name="locationNotes"
|
||||
maxLength={500}
|
||||
rows={2}
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<a href="/garden" className="btn btn-ghost">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Link from "next/link";
|
||||
import { listContainers, listPlants } from "@/modules/garden/server/queries";
|
||||
import { ContainerList } from "@/modules/garden/components/container-list";
|
||||
import { PlantList } from "@/modules/garden/components/plant-list";
|
||||
import { PushOverdueButton } from "@/modules/garden/components/push-overdue-button";
|
||||
|
||||
type Props = {
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
};
|
||||
|
||||
export default async function GardenPage({ searchParams }: Props) {
|
||||
const { tab = "containers" } = await searchParams;
|
||||
const isPlants = tab === "plants";
|
||||
|
||||
const [containers, plants] = await Promise.all([
|
||||
listContainers(),
|
||||
isPlants ? listPlants() : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="page-content">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h1 className="page-title mb-0">Garden</h1>
|
||||
<PushOverdueButton />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6 border-b border-[var(--ink-faint)] mb-6">
|
||||
<Link
|
||||
href="/garden?tab=containers"
|
||||
className={`pb-2 text-sm font-medium transition-colors ${
|
||||
!isPlants
|
||||
? "border-b-2 border-[var(--ink)] text-[var(--ink)]"
|
||||
: "text-[var(--ink-mute)]"
|
||||
}`}
|
||||
>
|
||||
Containers
|
||||
</Link>
|
||||
<Link
|
||||
href="/garden?tab=plants"
|
||||
className={`pb-2 text-sm font-medium transition-colors ${
|
||||
isPlants ? "border-b-2 border-[var(--ink)] text-[var(--ink)]" : "text-[var(--ink-mute)]"
|
||||
}`}
|
||||
>
|
||||
Plants
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isPlants ? <PlantList plants={plants} /> : <ContainerList containers={containers} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getPlant, listContainers } from "@/modules/garden/server/queries";
|
||||
import { PlantForm } from "@/modules/garden/components/plant-form";
|
||||
|
||||
export default async function EditPlantPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const [plant, containers] = await Promise.all([getPlant(id), listContainers()]);
|
||||
if (!plant) notFound();
|
||||
|
||||
return (
|
||||
<div className="page-content max-w-xl">
|
||||
<h1 className="page-title">Edit Plant</h1>
|
||||
<PlantForm existingPlant={plant} containers={containers} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { listCalendars } from "@/modules/garden/server/calendar-bridge";
|
||||
import { getCareLogs, getCareSchedules, getPlant } from "@/modules/garden/server/queries";
|
||||
import { PlantDetail } from "@/modules/garden/components/plant-detail";
|
||||
|
||||
export default async function PlantPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const [plant, careLogs, careSchedules, calendars] = await Promise.all([
|
||||
getPlant(id),
|
||||
getCareLogs(id),
|
||||
getCareSchedules(id),
|
||||
listCalendars(),
|
||||
]);
|
||||
if (!plant) notFound();
|
||||
|
||||
return (
|
||||
<div className="page-content">
|
||||
<PlantDetail
|
||||
plant={plant}
|
||||
careLogs={careLogs}
|
||||
careSchedules={careSchedules}
|
||||
calendars={calendars}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { listContainers } from "@/modules/garden/server/queries";
|
||||
import { PlantForm } from "@/modules/garden/components/plant-form";
|
||||
|
||||
export default async function NewPlantPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ containerId?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const containers = await listContainers();
|
||||
|
||||
return (
|
||||
<div className="page-content max-w-xl">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<a href="/garden" className="btn btn-ghost btn-icon" aria-label="Back to garden">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</a>
|
||||
<h1 className="page-title">Add Plant</h1>
|
||||
</div>
|
||||
<PlantForm containers={containers} defaultContainerId={params.containerId ?? null} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1316
-205
File diff suppressed because it is too large
Load Diff
+130
-32
@@ -1,71 +1,169 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
import { Geist } from "next/font/google";
|
||||
import { Inter, Source_Serif_4, Newsreader, Fraunces, JetBrains_Mono } from "next/font/google";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AppNav } from "@/components/app-nav";
|
||||
import "@/modules"; // registers all module manifests
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { users } from "@/modules/_core/schema";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { dashboards, users } from "@/modules/_core/schema";
|
||||
import type { DashboardMeta } from "@/app/d/actions";
|
||||
import { getQuickAdds } from "@/modules/_core";
|
||||
import { QuickAddProvider } from "@/components/quick-add-provider";
|
||||
import { QuickAddSheet } from "@/components/quick-add-sheet";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { PwaRegister } from "@/components/pwa-register";
|
||||
import { InstallPrompt } from "@/components/install-prompt";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { DEFAULT_THEME, navStyleToDataNav } from "@/modules/_core/themes";
|
||||
import type { Palette, ThemeMode, FontPair, Density, NavStyle } from "@/modules/_core/themes";
|
||||
|
||||
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--sans-inter", display: "swap" });
|
||||
const sourceSerif = Source_Serif_4({
|
||||
subsets: ["latin"],
|
||||
variable: "--serif-source",
|
||||
display: "swap",
|
||||
});
|
||||
const newsreader = Newsreader({
|
||||
subsets: ["latin"],
|
||||
variable: "--serif-newsreader",
|
||||
display: "swap",
|
||||
});
|
||||
const fraunces = Fraunces({
|
||||
subsets: ["latin"],
|
||||
variable: "--serif-fraunces",
|
||||
display: "swap",
|
||||
});
|
||||
const jetbrains = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--mono-jb",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#1F1B16",
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "famapp",
|
||||
description: "Family coordination app",
|
||||
manifest: "/manifest.webmanifest",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: "famapp",
|
||||
},
|
||||
icons: {
|
||||
apple: "/icon-180.png",
|
||||
},
|
||||
};
|
||||
|
||||
// Runs before paint — reads localStorage / prefers-color-scheme and applies
|
||||
// data-theme + dark class to <html> so signed-out pages also get the right theme.
|
||||
// Pre-paint: read user's theme prefs from localStorage and apply data-* + .dark.
|
||||
// Falls back to clay/serif-sans/regular/sidebar/system if nothing is stored.
|
||||
const prePaintScript = `(function(){
|
||||
try {
|
||||
var t = localStorage.getItem('theme') || 'default';
|
||||
var m = localStorage.getItem('themeMode') || 'system';
|
||||
var dark = m === 'dark' || (m === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
if (dark) document.documentElement.classList.add('dark');
|
||||
else document.documentElement.classList.remove('dark');
|
||||
var palette = localStorage.getItem('themePalette') || localStorage.getItem('theme') || 'clay';
|
||||
var mode = localStorage.getItem('themeMode') || 'system';
|
||||
var fontPair = localStorage.getItem('themeFontPair') || 'serif-sans';
|
||||
var density = localStorage.getItem('themeDensity') || 'regular';
|
||||
var navStyle = localStorage.getItem('themeNavStyle') || 'rail-desktop';
|
||||
var dataNav = navStyle === 'compact-rail' ? 'rail'
|
||||
: navStyle === 'top-nav' ? 'top'
|
||||
: 'sidebar';
|
||||
if (window.matchMedia && window.matchMedia('(max-width: 759px)').matches) {
|
||||
dataNav = navStyle === 'fab-only' ? 'fab' : 'bottom';
|
||||
}
|
||||
var dark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
var html = document.documentElement;
|
||||
html.setAttribute('data-theme', palette);
|
||||
html.setAttribute('data-font-pair', fontPair);
|
||||
html.setAttribute('data-density', density);
|
||||
html.setAttribute('data-nav', dataNav);
|
||||
if (dark) html.classList.add('dark'); else html.classList.remove('dark');
|
||||
} catch(e) {}
|
||||
})();`;
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
let theme = "default";
|
||||
let themeMode = "system";
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
let palette: Palette = DEFAULT_THEME.palette;
|
||||
let mode: ThemeMode = DEFAULT_THEME.mode;
|
||||
let fontPair: FontPair = DEFAULT_THEME.fontPair;
|
||||
let density: Density = DEFAULT_THEME.density;
|
||||
let navStyle: NavStyle = DEFAULT_THEME.navStyle;
|
||||
let userDashboards: DashboardMeta[] = [];
|
||||
let signedIn = false;
|
||||
|
||||
const session = await auth();
|
||||
if (session?.user?.id) {
|
||||
signedIn = true;
|
||||
const [row] = await db
|
||||
.select({ theme: users.theme, themeMode: users.themeMode })
|
||||
.select({
|
||||
themePalette: users.themePalette,
|
||||
themeMode: users.themeMode,
|
||||
themeFontPair: users.themeFontPair,
|
||||
themeDensity: users.themeDensity,
|
||||
themeNavStyle: users.themeNavStyle,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, session.user.id))
|
||||
.limit(1);
|
||||
if (row) {
|
||||
theme = row.theme;
|
||||
themeMode = row.themeMode;
|
||||
palette = row.themePalette as Palette;
|
||||
mode = row.themeMode as ThemeMode;
|
||||
fontPair = row.themeFontPair as FontPair;
|
||||
density = row.themeDensity as Density;
|
||||
navStyle = row.themeNavStyle as NavStyle;
|
||||
}
|
||||
userDashboards = await db
|
||||
.select({
|
||||
id: dashboards.id,
|
||||
name: dashboards.name,
|
||||
slug: dashboards.slug,
|
||||
isDefault: dashboards.isDefault,
|
||||
position: dashboards.position,
|
||||
})
|
||||
.from(dashboards)
|
||||
.where(eq(dashboards.userId, session.user.id))
|
||||
.orderBy(asc(dashboards.position), asc(dashboards.createdAt));
|
||||
}
|
||||
|
||||
// For system mode we can't know the preference on the server — the inline
|
||||
// script will correct it before paint. We optimistically render light here.
|
||||
const isDark = themeMode === "dark";
|
||||
// Server-side initial dark guess: only for `dark` mode (system mode is corrected
|
||||
// before paint by the inline script). Avoids a flash on signed-in users.
|
||||
const isDark = mode === "dark";
|
||||
const initialDataNav = navStyleToDataNav(navStyle);
|
||||
|
||||
const quickAdds = getQuickAdds();
|
||||
|
||||
const fontVars = cn(
|
||||
inter.variable,
|
||||
sourceSerif.variable,
|
||||
newsreader.variable,
|
||||
fraunces.variable,
|
||||
jetbrains.variable,
|
||||
);
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
data-theme={theme}
|
||||
className={cn("font-sans", geist.variable, isDark ? "dark" : "")}
|
||||
data-theme={palette}
|
||||
data-font-pair={fontPair}
|
||||
data-density={density}
|
||||
data-nav={initialDataNav}
|
||||
className={cn(fontVars, isDark ? "dark" : "")}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: prePaintScript }} />
|
||||
</head>
|
||||
<body className="min-h-screen">
|
||||
<AppNav />
|
||||
<main>{children}</main>
|
||||
<body>
|
||||
<QuickAddProvider actions={quickAdds}>
|
||||
<AppShell signedIn={signedIn} navStyle={navStyle} dashboards={userDashboards}>
|
||||
{children}
|
||||
</AppShell>
|
||||
<QuickAddSheet />
|
||||
<CommandPalette />
|
||||
<InstallPrompt />
|
||||
<PwaRegister />
|
||||
</QuickAddProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ListsIndex } from "@/modules/lists/components/lists-index";
|
||||
import { listLists } from "@/modules/lists/server/queries";
|
||||
import { listListsWithItems } from "@/modules/lists/server/queries";
|
||||
|
||||
export default async function ListsPage() {
|
||||
const lists = await listLists();
|
||||
const lists = await listListsWithItems();
|
||||
return <ListsIndex lists={lists} />;
|
||||
}
|
||||
|
||||
+27
-11
@@ -2,23 +2,34 @@ import { signIn } from "@/lib/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cookies } from "next/headers";
|
||||
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 { BrandMark } from "@/components/brand-mark";
|
||||
|
||||
export default function LoginPage() {
|
||||
const devLoginEnabled = isDevLoginEnabled();
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-8">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<h1 className="text-4xl font-bold">famapp</h1>
|
||||
<main
|
||||
className="flex min-h-screen flex-col items-center justify-center p-8"
|
||||
style={{ background: "var(--paper)" }}
|
||||
>
|
||||
<div
|
||||
className="rounded-[var(--r-lg)] shadow-[var(--shadow-2)] p-10 max-w-sm w-full text-center"
|
||||
style={{ background: "var(--card)", border: "0.5px solid var(--hair)" }}
|
||||
>
|
||||
<div className="flex justify-center mb-3">
|
||||
<BrandMark />
|
||||
</div>
|
||||
<h1 className="serif text-[28px] font-medium tracking-tight mb-2">famapp</h1>
|
||||
<p className="muted text-[13.5px] mb-6">Sign in to your household.</p>
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signIn("authentik", { redirectTo: "/" });
|
||||
}}
|
||||
>
|
||||
<Button type="submit" size="lg">
|
||||
<Button type="submit" size="lg" className="w-full">
|
||||
Sign in with SSO
|
||||
</Button>
|
||||
</form>
|
||||
@@ -26,18 +37,23 @@ export default function LoginPage() {
|
||||
<form
|
||||
action={async () => {
|
||||
"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 cookieStore = await cookies();
|
||||
cookieStore.set(DEV_LOGIN_COOKIE, sessionToken, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
expires,
|
||||
const base = { httpOnly: true, sameSite: "lax" as const, path: "/", expires };
|
||||
cookieStore.set("authjs.session-token", sessionToken, base);
|
||||
cookieStore.set("__Secure-authjs.session-token", sessionToken, {
|
||||
...base,
|
||||
secure: true,
|
||||
});
|
||||
redirect("/");
|
||||
}}
|
||||
className="mt-3"
|
||||
>
|
||||
<Button type="submit" variant="outline" size="lg">
|
||||
<Button type="submit" variant="outline" size="lg" className="w-full">
|
||||
Dev login
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
+6
-7
@@ -1,8 +1,7 @@
|
||||
export default function Page() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-8">
|
||||
<h1 className="text-4xl font-bold">famapp</h1>
|
||||
<p className="mt-4 text-muted-foreground">Dashboard coming soon</p>
|
||||
</main>
|
||||
);
|
||||
import { redirect } from "next/navigation";
|
||||
import { getDefaultDashboardSlug } from "@/app/d/actions";
|
||||
|
||||
export default async function RootPage() {
|
||||
const slug = await getDefaultDashboardSlug();
|
||||
redirect(`/d/${slug}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { resolveShareToken } from "@/modules/_core/share";
|
||||
import { getEntityType } from "@/modules/_core/registry";
|
||||
import { isRateLimited, recordFailure } from "@/lib/rate-limit";
|
||||
import { db } from "@/lib/db";
|
||||
import { users } from "@/modules/_core/schema";
|
||||
import { ShareFrame } from "@/components/share/share-frame";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
const RL_PREFIX_LEN = 8;
|
||||
|
||||
export default async function SharePage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = await params;
|
||||
const headersList = await headers();
|
||||
const ip =
|
||||
headersList.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
headersList.get("x-real-ip") ??
|
||||
"0.0.0.0";
|
||||
const rlKey = `${ip}:${token.slice(0, RL_PREFIX_LEN)}`;
|
||||
|
||||
if (isRateLimited(rlKey)) {
|
||||
return <ShareRateLimitError />;
|
||||
}
|
||||
|
||||
const resolved = await resolveShareToken(token);
|
||||
if (!resolved) {
|
||||
recordFailure(rlKey);
|
||||
return <ShareError />;
|
||||
}
|
||||
|
||||
const entityReg = getEntityType(resolved.entityType);
|
||||
if (!entityReg?.loadForShare || !entityReg.renderSharedView) {
|
||||
return <ShareError message="This content type cannot be shared." />;
|
||||
}
|
||||
|
||||
const data = await entityReg.loadForShare(resolved.entityId);
|
||||
if (!data) {
|
||||
recordFailure(rlKey);
|
||||
return <ShareError />;
|
||||
}
|
||||
|
||||
// Best-effort lookup of the creator's display name. Doesn't reveal email.
|
||||
const [creator] = resolved.createdBy
|
||||
? await db
|
||||
.select({ name: users.name })
|
||||
.from(users)
|
||||
.where(eq(users.id, resolved.createdBy))
|
||||
.limit(1)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<ShareFrame
|
||||
expiresAt={resolved.expiresAt}
|
||||
capabilities={resolved.capabilities}
|
||||
token={token}
|
||||
sharedByName={creator?.name ?? null}
|
||||
>
|
||||
{entityReg.renderSharedView({ data, capabilities: resolved.capabilities, token })}
|
||||
</ShareFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareRateLimitError() {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<h1 className="serif text-[28px] font-medium tracking-tight">Too many requests</h1>
|
||||
<p className="max-w-sm text-[13.5px] muted">
|
||||
You have made too many requests in a short period. Please wait a minute and try again.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareError({ message }: { message?: string }) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<h1 className="serif text-[28px] font-medium tracking-tight">Link not found</h1>
|
||||
<p className="max-w-sm text-[13.5px] muted">
|
||||
{message ??
|
||||
"This share link may have expired or been revoked. Ask the sender for a new link."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+80
-15
@@ -1,25 +1,90 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "@/lib/db";
|
||||
import { users } from "@/modules/_core/schema";
|
||||
import { VALID_THEME_IDS, VALID_THEME_MODES } from "@/modules/_core/themes";
|
||||
import type { ThemeId, ThemeMode } from "@/modules/_core/themes";
|
||||
import {
|
||||
VALID_PALETTES,
|
||||
VALID_THEME_MODES,
|
||||
VALID_FONT_PAIRS,
|
||||
VALID_DENSITIES,
|
||||
VALID_DASH_LAYOUTS,
|
||||
VALID_CAL_VIEWS,
|
||||
VALID_NAV_STYLES,
|
||||
} from "@/modules/_core/themes";
|
||||
import type {
|
||||
Palette,
|
||||
ThemeMode,
|
||||
FontPair,
|
||||
Density,
|
||||
DashLayout,
|
||||
CalView,
|
||||
NavStyle,
|
||||
} from "@/modules/_core/themes";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { revokeShareLink } from "@/modules/_core/share";
|
||||
|
||||
export async function setUserTheme({
|
||||
theme,
|
||||
mode,
|
||||
}: {
|
||||
theme: ThemeId;
|
||||
mode: ThemeMode;
|
||||
}) {
|
||||
if (!VALID_THEME_IDS.has(theme)) throw new Error("Invalid theme");
|
||||
if (!VALID_THEME_MODES.has(mode)) throw new Error("Invalid theme mode");
|
||||
export interface ThemePatch {
|
||||
palette?: Palette;
|
||||
mode?: ThemeMode;
|
||||
fontPair?: FontPair;
|
||||
density?: Density;
|
||||
dashLayout?: DashLayout;
|
||||
calView?: CalView;
|
||||
navStyle?: NavStyle;
|
||||
}
|
||||
|
||||
export async function setUserTheme(patch: ThemePatch) {
|
||||
const update: Record<string, string> = {};
|
||||
|
||||
if (patch.palette !== undefined) {
|
||||
if (!VALID_PALETTES.has(patch.palette)) throw new Error("Invalid palette");
|
||||
update["themePalette"] = patch.palette;
|
||||
}
|
||||
if (patch.mode !== undefined) {
|
||||
if (!VALID_THEME_MODES.has(patch.mode)) throw new Error("Invalid theme mode");
|
||||
update["themeMode"] = patch.mode;
|
||||
}
|
||||
if (patch.fontPair !== undefined) {
|
||||
if (!VALID_FONT_PAIRS.has(patch.fontPair)) throw new Error("Invalid font pair");
|
||||
update["themeFontPair"] = patch.fontPair;
|
||||
}
|
||||
if (patch.density !== undefined) {
|
||||
if (!VALID_DENSITIES.has(patch.density)) throw new Error("Invalid density");
|
||||
update["themeDensity"] = patch.density;
|
||||
}
|
||||
if (patch.dashLayout !== undefined) {
|
||||
if (!VALID_DASH_LAYOUTS.has(patch.dashLayout)) throw new Error("Invalid dashboard layout");
|
||||
update["themeDashLayout"] = patch.dashLayout;
|
||||
}
|
||||
if (patch.calView !== undefined) {
|
||||
if (!VALID_CAL_VIEWS.has(patch.calView)) throw new Error("Invalid calendar view");
|
||||
update["themeCalView"] = patch.calView;
|
||||
}
|
||||
if (patch.navStyle !== undefined) {
|
||||
if (!VALID_NAV_STYLES.has(patch.navStyle)) throw new Error("Invalid nav style");
|
||||
update["themeNavStyle"] = patch.navStyle;
|
||||
}
|
||||
|
||||
if (Object.keys(update).length === 0) return;
|
||||
|
||||
const { user } = await getCurrentSession();
|
||||
await db
|
||||
.update(users)
|
||||
.set({ theme, themeMode: mode })
|
||||
.where(eq(users.id, user.id));
|
||||
await db.update(users).set(update).where(eq(users.id, user.id));
|
||||
}
|
||||
|
||||
export async function setCompletionVisibilityHours(hours: number): Promise<void> {
|
||||
const parsed = Number(hours);
|
||||
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 8760)
|
||||
throw new Error("Invalid hours value");
|
||||
const { user } = await getCurrentSession();
|
||||
await db.update(users).set({ completionVisibilityHours: parsed }).where(eq(users.id, user.id));
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
export async function revokeShareLinkAction(formData: FormData): Promise<void> {
|
||||
const id = formData.get("id");
|
||||
if (typeof id !== "string") throw new Error("Missing id");
|
||||
await revokeShareLink(id);
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
@@ -13,10 +13,7 @@ export async function renameHousehold(formData: FormData) {
|
||||
const name = formData.get("name");
|
||||
if (typeof name !== "string" || !name.trim()) throw new Error("Invalid name");
|
||||
|
||||
await db
|
||||
.update(households)
|
||||
.set({ name: name.trim() })
|
||||
.where(eq(households.id, household.id));
|
||||
await db.update(households).set({ name: name.trim() }).where(eq(households.id, household.id));
|
||||
|
||||
revalidatePath("/settings/household");
|
||||
}
|
||||
|
||||
@@ -4,12 +4,7 @@ import { getCurrentSession } from "@/lib/session";
|
||||
import { householdMembers, users } from "@/modules/_core/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { renameHousehold } from "./actions";
|
||||
|
||||
export default async function HouseholdSettingsPage() {
|
||||
@@ -34,9 +29,7 @@ export default async function HouseholdSettingsPage() {
|
||||
{members.map(({ user, role: memberRole }) => (
|
||||
<li key={user.id} className="flex items-center justify-between">
|
||||
<span>{user.name ?? user.email}</span>
|
||||
<span className="text-sm text-muted-foreground capitalize">
|
||||
{memberRole}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground capitalize">{memberRole}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "@/lib/db";
|
||||
import { notifications, users } from "@/modules/_core/schema";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
|
||||
export async function markNotificationRead(id: string): Promise<void> {
|
||||
const { user } = await getCurrentSession();
|
||||
await db
|
||||
.update(notifications)
|
||||
.set({ readAt: new Date() })
|
||||
.where(and(eq(notifications.id, id), eq(notifications.userId, user.id)));
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(): Promise<void> {
|
||||
const { user } = await getCurrentSession();
|
||||
await db
|
||||
.update(notifications)
|
||||
.set({ readAt: new Date() })
|
||||
.where(and(eq(notifications.userId, user.id), isNull(notifications.readAt)));
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function setNotifChannel(
|
||||
channel: "push" | "inapp" | "ntfy",
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
const { user } = await getCurrentSession();
|
||||
const col =
|
||||
channel === "push"
|
||||
? { notifPush: enabled }
|
||||
: channel === "inapp"
|
||||
? { notifInApp: enabled }
|
||||
: { notifNtfy: enabled };
|
||||
await db.update(users).set(col).where(eq(users.id, user.id));
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
+341
-24
@@ -1,34 +1,351 @@
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { getActiveShareLinks } from "@/modules/_core/share";
|
||||
import { getEntityType } from "@/modules/_core/registry";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThemePicker } from "@/components/theme-picker";
|
||||
import { CompletionDelaySetting } from "@/components/completion-delay-setting";
|
||||
import { PushOptIn } from "@/components/push-opt-in";
|
||||
import { NotifyChannelToggles } from "@/components/notify-channel-toggles";
|
||||
import { ResponsiveSidebar } from "@/components/settings-section";
|
||||
import type { SectionId } from "@/components/settings-section";
|
||||
import { revokeShareLinkAction } from "./actions";
|
||||
import { listCalendars } from "@/modules/calendar/server/queries";
|
||||
import { listLists } from "@/modules/lists/server/queries";
|
||||
import Link from "next/link";
|
||||
import { NavIcon } from "@/components/nav-icon";
|
||||
import { Mail, Globe, History, Sun, Bell, Pencil, Lock, Plus } from "lucide-react";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const { user } = await getCurrentSession();
|
||||
const VALID_SECTIONS = new Set<SectionId>([
|
||||
"household",
|
||||
"sharing",
|
||||
"notifications",
|
||||
"calendars",
|
||||
"appearance",
|
||||
"data",
|
||||
]);
|
||||
|
||||
export default async function SettingsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ s?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const section: SectionId = VALID_SECTIONS.has(sp.s as SectionId)
|
||||
? (sp.s as SectionId)
|
||||
: "household";
|
||||
|
||||
const { user, household } = await getCurrentSession();
|
||||
const ntfyConfigured = !!(process.env["NTFY_URL"] && process.env["NTFY_TOPIC"]);
|
||||
const vapidKey = process.env["VAPID_PUBLIC_KEY"] ?? "";
|
||||
|
||||
return (
|
||||
<div className="container max-w-2xl py-8 space-y-6">
|
||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ThemePicker
|
||||
initialTheme={user.theme as "default" | "warm"}
|
||||
initialMode={user.themeMode as "light" | "dark" | "system"}
|
||||
signedIn
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Link
|
||||
href="/settings/household"
|
||||
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
Household Settings
|
||||
</Link>
|
||||
<div className="grid gap-5 sm:grid-cols-[220px_1fr]">
|
||||
<ResponsiveSidebar active={section} />
|
||||
<div className="space-y-4">
|
||||
{section === "household" && <HouseholdSection household={household} userName={user.name} />}
|
||||
{section === "sharing" && <SharingSection />}
|
||||
{section === "notifications" && (
|
||||
<NotificationsSection user={user} vapidKey={vapidKey} ntfyConfigured={ntfyConfigured} />
|
||||
)}
|
||||
{section === "calendars" && <CalendarsAndListsSection />}
|
||||
{section === "appearance" && <AppearanceSection user={user} />}
|
||||
{section === "data" && <DataSection />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HouseholdSection({
|
||||
household,
|
||||
userName,
|
||||
}: {
|
||||
household: { id: string; name: string };
|
||||
userName: string | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{household.name}</CardTitle>
|
||||
<span className="meta">Self-hosted</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="muted text-[13px] mb-3">
|
||||
Household name shown on share links and the iOS PWA.
|
||||
</p>
|
||||
<Link
|
||||
href="/settings/household"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-[13px] font-medium border-[0.5px] hover:bg-[var(--shade)]"
|
||||
style={{ borderColor: "var(--hair-2)" }}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
Edit household & members
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>You</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="set-row" style={{ borderBottom: "0", padding: 0 }}>
|
||||
<span className="avatar avatar-lg" style={{ background: "var(--c-household)" }}>
|
||||
{(userName ?? "?").trim()[0]?.toUpperCase()}
|
||||
</span>
|
||||
<div className="label">
|
||||
<div className="t">{userName ?? "Anonymous"}</div>
|
||||
<div className="d">Signed in via Authentik</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function SharingSection() {
|
||||
const shareLinks = await getActiveShareLinks();
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Public share links</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="muted text-[12.5px] mb-3">
|
||||
Share any calendar, list, note, or event with people outside the household. Links expire
|
||||
on their own — no logins needed.
|
||||
</p>
|
||||
{shareLinks.length === 0 ? (
|
||||
<p className="muted text-[13px]">No active share links.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{shareLinks.map((link) => {
|
||||
const registration = getEntityType(link.entityType);
|
||||
const label = registration?.label.singular ?? link.entityType;
|
||||
const iconName = link.entityType.startsWith("calendar.")
|
||||
? "calendar"
|
||||
: link.entityType.startsWith("notes.")
|
||||
? "note"
|
||||
: link.entityType.startsWith("lists.")
|
||||
? "list"
|
||||
: "link";
|
||||
return (
|
||||
<div
|
||||
key={link.id}
|
||||
className="flex items-center gap-3 rounded-[var(--r-md)] border-[0.5px] p-3"
|
||||
style={{ borderColor: "var(--hair)" }}
|
||||
>
|
||||
<div
|
||||
className="size-9 rounded-md flex items-center justify-center shrink-0"
|
||||
style={{ background: "var(--paper-2)" }}
|
||||
>
|
||||
<NavIcon name={iconName} className="size-4 text-[var(--ink-soft)]" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13.5px] font-medium">{label}</div>
|
||||
<div className="flex flex-wrap gap-2 items-center text-[11.5px] muted mt-0.5">
|
||||
<code style={{ fontFamily: "var(--mono)" }}>
|
||||
{link.capabilities.write ? "edit" : "view-only"}
|
||||
</code>
|
||||
{link.expiresAt && (
|
||||
<span>· expires {link.expiresAt.toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<form action={revokeShareLinkAction}>
|
||||
<input type="hidden" name="id" value={link.id} />
|
||||
<Button variant="destructive" size="sm" type="submit">
|
||||
Revoke
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationsSection({
|
||||
user,
|
||||
vapidKey,
|
||||
ntfyConfigured,
|
||||
}: {
|
||||
user: { notifPush: boolean; notifInApp: boolean; notifNtfy: boolean };
|
||||
vapidKey: string;
|
||||
ntfyConfigured: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Push notifications</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PushOptIn vapidKey={vapidKey} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification channels</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<NotifyChannelToggles
|
||||
push={user.notifPush}
|
||||
inapp={user.notifInApp}
|
||||
ntfy={user.notifNtfy}
|
||||
ntfyConfigured={ntfyConfigured}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function CalendarsAndListsSection() {
|
||||
const [calendars, lists] = await Promise.all([listCalendars(), listLists()]);
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Calendars</CardTitle>
|
||||
<Link
|
||||
href="/calendar"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[12.5px] hover:bg-[var(--shade)]"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
New
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<div>
|
||||
{calendars.length === 0 ? (
|
||||
<div className="muted px-[14px] py-4 text-[13px]">No calendars yet.</div>
|
||||
) : (
|
||||
calendars.map((c) => (
|
||||
<div key={c.id} className="set-row">
|
||||
<span
|
||||
className="dot"
|
||||
style={{ background: c.color ?? "var(--c-household)", width: 12, height: 12 }}
|
||||
/>
|
||||
<div className="label">
|
||||
<div className="t">{c.name}</div>
|
||||
<div className="d">
|
||||
{c.visibility === "private" ? "Private" : "Household · everyone sees it"}
|
||||
</div>
|
||||
</div>
|
||||
{c.visibility === "private" && <Lock className="size-3.5 text-[var(--ink-mute)]" />}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lists</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CompletionDelaySetting initialHours={24} />
|
||||
</CardContent>
|
||||
<div>
|
||||
{lists.length === 0 ? (
|
||||
<div className="muted px-[14px] py-4 text-[13px]">No lists yet.</div>
|
||||
) : (
|
||||
lists.map((l) => (
|
||||
<div key={l.id} className="set-row">
|
||||
<NavIcon
|
||||
name={l.type === "shopping" ? "cart" : "check-square"}
|
||||
className="size-4 text-[var(--ink-soft)]"
|
||||
/>
|
||||
<div className="label">
|
||||
<div className="t">{l.name}</div>
|
||||
<div className="d">
|
||||
{l.type} · {l.openCount} open
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceSection({
|
||||
user,
|
||||
}: {
|
||||
user: {
|
||||
themePalette: string;
|
||||
themeMode: string;
|
||||
themeFontPair: string;
|
||||
themeDensity: string;
|
||||
themeDashLayout: string;
|
||||
themeCalView: string;
|
||||
themeNavStyle: string;
|
||||
};
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Appearance</CardTitle>
|
||||
<Sun className="size-4 text-[var(--ink-mute)]" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ThemePicker
|
||||
initialPalette={user.themePalette as "clay" | "indigo" | "sage" | "plum" | "ink"}
|
||||
initialMode={user.themeMode as "light" | "dark" | "system"}
|
||||
initialFontPair={
|
||||
user.themeFontPair as "serif-sans" | "newsreader" | "fraunces" | "sans-only"
|
||||
}
|
||||
initialDensity={user.themeDensity as "compact" | "regular" | "comfy"}
|
||||
initialDashLayout={user.themeDashLayout as "classic" | "split" | "glance"}
|
||||
initialCalView={user.themeCalView as "month" | "week" | "day"}
|
||||
initialNavStyle={
|
||||
user.themeNavStyle as "rail-desktop" | "compact-rail" | "top-nav" | "fab-only"
|
||||
}
|
||||
signedIn
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DataSection() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Data & backups</CardTitle>
|
||||
</CardHeader>
|
||||
<div>
|
||||
<div className="set-row">
|
||||
<History className="size-4 text-[var(--ink-soft)]" />
|
||||
<div className="label">
|
||||
<div className="t">Auto-backup</div>
|
||||
<div className="d">Daily 03:00 → /var/backups/famapp/. Configured via host cron.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="set-row">
|
||||
<Globe className="size-4 text-[var(--ink-soft)]" />
|
||||
<div className="label">
|
||||
<div className="t">Server</div>
|
||||
<div className="d">Self-hosted via Docker Compose</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="set-row" style={{ borderBottom: "0" }}>
|
||||
<Mail className="size-4 text-[var(--ink-soft)]" />
|
||||
<div className="label">
|
||||
<div className="t">Export</div>
|
||||
<div className="d">Not yet implemented — coming in v0.5</div>
|
||||
</div>
|
||||
<Bell className="size-4 text-[var(--ink-faint)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user