Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c60b58947 | ||
|
|
4084c3af91 | ||
|
|
4f92a98921 | ||
|
|
a312d4ce39 | ||
|
|
e1774c802d | ||
|
|
55b0e68b35 | ||
|
|
615d9c9377 | ||
|
|
944dcdf48f | ||
|
|
d4ac20f511 | ||
|
|
fd97eb306d | ||
|
|
a12208d2fe | ||
|
|
14175d3faa | ||
|
|
c3e78932fe | ||
|
|
a332935ba7 | ||
|
|
51c8d3c92d | ||
|
|
62c3d33350 | ||
|
|
2408d37f8e | ||
|
|
f47c1c3a23 | ||
|
|
6e92648a41 | ||
|
|
6802ae08db | ||
|
|
b935ff209c | ||
|
|
3fb5ddc5dd | ||
|
|
ca536b5c33 | ||
|
|
ec2f3930ad | ||
|
|
10cad3df17 | ||
|
|
77db935329 | ||
|
|
c6a34f7471 | ||
|
|
076e35aced | ||
|
|
8bf00c326d | ||
|
|
6a1ec8bb7b | ||
|
|
fe4673f1cd | ||
|
|
4ec1f34150 | ||
|
|
7938425850 | ||
|
|
13d94b3751 | ||
|
|
aae3e30843 | ||
|
|
23a14e1650 | ||
|
|
cce3bac5b2 | ||
|
|
31c5805b34 | ||
|
|
989bae8f1e | ||
|
|
090366422d | ||
|
|
4049a73c6b | ||
|
|
272d3cbbe6 | ||
|
|
b5a966e3a9 | ||
|
|
48063e4b7f | ||
|
|
6eab68560e | ||
|
|
7d0f02996f | ||
|
|
32e97b0677 | ||
|
|
e0b423aad5 | ||
|
|
389ff6040b | ||
|
|
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 |
@@ -2,7 +2,6 @@ node_modules
|
||||
.next
|
||||
.git
|
||||
deploy
|
||||
drizzle
|
||||
docs
|
||||
*.md
|
||||
.env*
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Public app URL (used for OIDC redirect URIs, share links, etc.)
|
||||
NEXT_PUBLIC_APP_URL=https://fam.ginnoir.com
|
||||
NEXT_PUBLIC_APP_URL=https://fam.yourdomain.com
|
||||
|
||||
# Postgres
|
||||
DATABASE_URL=postgres://famapp:famapp@localhost:5432/famapp
|
||||
@@ -15,11 +15,11 @@ DEV_LOGIN_NAME=Dev User
|
||||
DEV_HOUSEHOLD_NAME=Home
|
||||
|
||||
# OIDC (Authentik)
|
||||
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
|
||||
AUTH_OIDC_ISSUER=https://auth.yourdomain.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,16 @@ 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
|
||||
|
||||
# OpenPlantBook plant species API (https://open.plantbook.io — free account required)
|
||||
OPENPLANTBOOK_CLIENT_ID=
|
||||
OPENPLANTBOOK_CLIENT_SECRET=
|
||||
|
||||
@@ -1,40 +1,57 @@
|
||||
# ── famapp ────────────────────────────────────────────────────────────────────
|
||||
# These vars are consumed by the famapp service in docker-compose.yml.
|
||||
|
||||
# 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
|
||||
FAMAPP_PULL_POLICY=always
|
||||
AUTHENTIK_IMAGE_TAG=2024.12.3
|
||||
RUN_MIGRATIONS=true
|
||||
|
||||
# famapp Postgres credentials (used to build DATABASE_URL inside compose.yaml)
|
||||
# Public URL for the app
|
||||
AUTH_URL=https://fam.yourdomain.com
|
||||
|
||||
# famapp Postgres
|
||||
FAMAPP_DB_USER=famapp
|
||||
FAMAPP_DB_PASSWORD=replace-with-strong-password
|
||||
FAMAPP_DB_NAME=famapp
|
||||
|
||||
# Auth.js session secret — generate with: openssl rand -base64 32
|
||||
# NOTE: Reactive Resume uses RESUME_AUTH_SECRET; AUTH_SECRET is famapp-only.
|
||||
|
||||
AUTH_SECRET=replace-with-openssl-rand-base64-32
|
||||
|
||||
# OIDC provider (Authentik) — task 06 will fill these in after bootstrapping
|
||||
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
|
||||
# OIDC provider (Authentik) — fill in after bootstrapping Authentik
|
||||
AUTH_OIDC_ISSUER=https://auth.yourdomain.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
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
# Must be "mailto:<address>" or a URL
|
||||
VAPID_SUBJECT=mailto:you@example.com
|
||||
VAPID_SUBJECT=mailto:your-email@example.com
|
||||
|
||||
# ntfy (optional push fallback — leave blank to disable)
|
||||
NTFY_URL=
|
||||
NTFY_TOPIC=
|
||||
# ntfy push fallback
|
||||
NTFY_URL=https://ntfy.yourdomain.com
|
||||
NTFY_TOPIC=famapp
|
||||
|
||||
# Log level: error | warn | info | debug
|
||||
LOG_LEVEL=info
|
||||
|
||||
# ── Authentik ─────────────────────────────────────────────────────────────────
|
||||
# MinIO object storage (garden image uploads)
|
||||
famapp_MINIO_ROOT_USER=famapp
|
||||
famapp_MINIO_ROOT_PASSWORD=replace-with-strong-password
|
||||
famapp_MINIO_BUCKET=garden
|
||||
|
||||
# Authentik Postgres credentials (separate DB per Matt's rule)
|
||||
# OpenPlantBook API (optional — used for plant species lookup)
|
||||
famapp_OPENPLANTBOOK_CLIENT_ID=
|
||||
famapp_OPENPLANTBOOK_CLIENT_SECRET=
|
||||
|
||||
# ── Authentik ─────────────────────────────────────────────────────────────────
|
||||
AUTHENTIK_DB_USER=authentik
|
||||
AUTHENTIK_DB_PASSWORD=replace-with-strong-password
|
||||
AUTHENTIK_DB_NAME=authentik
|
||||
|
||||
# Authentik secret key — generate with: openssl rand -base64 60
|
||||
AUTHENTIK_SECRET_KEY=replace-with-openssl-rand-base64-60
|
||||
|
||||
# ── Cloudflare (Caddy DNS-01 TLS) ─────────────────────────────────────────────
|
||||
CF_API_TOKEN=replace-with-cloudflare-api-token
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Enable pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.33.3 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Format check
|
||||
run: pnpm format:check
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Enable pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.33.3 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
env:
|
||||
DATABASE_URL: postgres://ci_user:ci_password@localhost:5432/ci_database
|
||||
AUTH_SECRET: ci-auth-secret-for-build
|
||||
NEXT_PUBLIC_APP_URL: http://localhost:3000
|
||||
@@ -0,0 +1,48 @@
|
||||
name: Release Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Derive image tags
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
MAJOR_MINOR="$(printf '%s' "$VERSION" | awk -F. '{print $1"."$2}')"
|
||||
{
|
||||
echo "version=$VERSION"
|
||||
echo "major_minor=$MAJOR_MINOR"
|
||||
echo "image=registry.ginnoir.com/ginnoir/famapp"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install docker
|
||||
run: apt-get update -qq && apt-get install -y -qq docker.io
|
||||
|
||||
- name: Login to registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PUSH_PASSWORD }}" | docker login registry.ginnoir.com \
|
||||
--username "${{ secrets.REGISTRY_PUSH_USERNAME }}" \
|
||||
--password-stdin
|
||||
|
||||
- name: Build image
|
||||
run: |
|
||||
docker build \
|
||||
-t "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}" \
|
||||
-t "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.major_minor }}" \
|
||||
-t "${{ steps.meta.outputs.image }}:latest" \
|
||||
.
|
||||
|
||||
- name: Push image
|
||||
run: |
|
||||
docker push "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}"
|
||||
docker push "${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.major_minor }}"
|
||||
docker push "${{ steps.meta.outputs.image }}:latest"
|
||||
@@ -0,0 +1,59 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
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: 24
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- run: pnpm typecheck
|
||||
|
||||
- run: pnpm lint
|
||||
|
||||
- run: pnpm format:check
|
||||
|
||||
build:
|
||||
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: 24
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: .next/cache
|
||||
key: ${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx', 'src/**/*.css') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nextjs-${{ hashFiles('pnpm-lock.yaml') }}-
|
||||
${{ runner.os }}-nextjs-
|
||||
|
||||
- run: pnpm build
|
||||
env:
|
||||
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
|
||||
@@ -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,18 @@ 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/
|
||||
|
||||
# Production configs and deploy scripts — maintainer-specific, never commit
|
||||
deploy/.prod/
|
||||
deploy/compose.yaml
|
||||
deploy/Caddyfile
|
||||
deploy/Caddyfile.dev.snippet
|
||||
scripts/apply-compose.ps1
|
||||
scripts/sync-prod.ps1
|
||||
|
||||
# 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,34 @@
|
||||
{
|
||||
"npm": {
|
||||
"publish": false
|
||||
},
|
||||
"git": {
|
||||
"commitMessage": "chore: release v${version}",
|
||||
"tagName": "v${version}",
|
||||
"requireBranch": "main"
|
||||
},
|
||||
"github": {
|
||||
"release": false
|
||||
},
|
||||
"hooks": {
|
||||
"before:git:release": "echo 'Check: README.md reflects current modules and env vars before tagging'"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,11 @@ This file is the canonical brief. Read it at the start of every session before m
|
||||
|
||||
Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`, `STATUS.md`, task briefs, and dev notes synchronized so either agent can pick up the next task without relying on agent-specific memory.
|
||||
|
||||
## Regular agent skills
|
||||
|
||||
- Use the React best-practices skill for any React or Next.js page/component work, data-loading changes, bundle/performance work, or review of those areas.
|
||||
- Use the shadcn skill for any UI work involving shadcn components, Tailwind styling, overlays, forms, icons, component composition, or updates to `components.json` / `src/components/ui`.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
@@ -31,7 +36,7 @@ Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`,
|
||||
| Realtime | Postgres `LISTEN/NOTIFY` → SSE | Simpler than WebSockets, fine for two users |
|
||||
| Reverse proxy | Caddy (existing) | `fam.ginnoir.com` → famapp:3000, `auth.ginnoir.com` → authentik |
|
||||
| Package manager | pnpm | Fast, content-addressed store, strict deps |
|
||||
| Runtime | Node 22 LTS | In `node:22-alpine` container |
|
||||
| Runtime | Node 24 LTS | In `node:24-alpine` container |
|
||||
| Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack |
|
||||
|
||||
Domain: `fam.ginnoir.com`. SSO: `auth.ginnoir.com`.
|
||||
@@ -106,7 +111,7 @@ All household-scoped entity tables include `household_id`. Sub-entities (`calend
|
||||
```yaml
|
||||
# compose.yaml (sketch — final lives in /deploy)
|
||||
services:
|
||||
famapp: # node:22-alpine, Next.js
|
||||
famapp: # node:24-alpine, Next.js
|
||||
famapp-db: # postgres:16
|
||||
authentik-server:
|
||||
authentik-worker:
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Changelog
|
||||
|
||||
## [0.5.3](https://github.com/ginnoir/famapp/compare/v0.5.2...v0.5.3) (2026-06-04)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- use next_public_app_url for share link base url instead of localhost fallback ([14175d3](https://github.com/ginnoir/famapp/commit/14175d3faa146ebff7c825848837c75047a8eb71))
|
||||
|
||||
## [0.5.2](https://github.com/ginnoir/famapp/compare/v0.5.1...v0.5.2) (2026-06-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **dashboard:** render greeting and date client-side so local timezone is used instead of server UTC
|
||||
|
||||
## [0.5.1](https://github.com/ginnoir/famapp/compare/v0.5.0...v0.5.1) (2026-06-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **dashboard:** always show tab strip and add-dashboard button regardless of dashboard count
|
||||
- **search:** show Ctrl+K on Windows/Linux instead of ⌘K
|
||||
- **mobile:** add search icon button in topbar for mobile viewports
|
||||
- **mobile:** lock html/body overflow and use 100dvh to prevent topbar scrolling off-screen
|
||||
|
||||
## [0.5.0](https://github.com/ginnoir/famapp/compare/v0.4.10...v0.5.0) (2026-06-03)
|
||||
|
||||
### Features
|
||||
|
||||
- **mobile:** full mobile experience overhaul — bottom nav, touch targets, calendar mobile layout with day/week/agenda views and calendars bottom sheet, bidirectional swipe gestures on list items (right to complete, left to delete), iOS safe-area and keyboard support, input zoom prevention
|
||||
- **mobile:** settings navigation replaced with 2-column icon grid (no horizontal scroll)
|
||||
- **mobile:** remove floating action button in favour of persistent topbar quick-add button
|
||||
|
||||
## [0.4.10](https://github.com/ginnoir/famapp/compare/v0.4.9...v0.4.10) (2026-06-03)
|
||||
|
||||
## [0.4.8](https://github.com/ginnoir/famapp/compare/v0.4.6...v0.4.8) (2026-06-03)
|
||||
|
||||
### Features
|
||||
|
||||
- **garden:** add share button to plant and container detail views ([989bae8](https://github.com/ginnoir/famapp/commit/989bae8f1e2d5577da3e8777b6a35320c6da274c))
|
||||
- share links visible on plants/containers; container image gallery ([c6a34f7](https://github.com/ginnoir/famapp/commit/c6a34f74717505e39ac4a88077728efa9fc50478))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **deploy:** separate reactive resume auth_secret from famapp ([aae3e30](https://github.com/ginnoir/famapp/commit/aae3e30843f54406244b637073aaf5e9ccc6b1d3))
|
||||
|
||||
### Documentation
|
||||
|
||||
- add contributing guide, env reference, and operations runbook ([076e35a](https://github.com/ginnoir/famapp/commit/076e35aced9303973da71ddd0ebded13f6c98833))
|
||||
- add professional readme with full feature coverage and release pipeline hook ([6a1ec8b](https://github.com/ginnoir/famapp/commit/6a1ec8bb7be7ce64e82e5a4cee6b9f4ef92c7af8))
|
||||
- update decisions index and task index for garden module and adr backfill ([8bf00c3](https://github.com/ginnoir/famapp/commit/8bf00c326db591851bdf8eee2fcedbfbed53d076))
|
||||
|
||||
## [0.4.6](https://github.com/ginnoir/famapp/compare/v0.4.5...v0.4.6) (2026-06-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **garden:** accept relative urls for plant image uploads ([6ca2e52](https://github.com/ginnoir/famapp/commit/6ca2e52e0a63d94826d9b1e208bcdded9c75fb07))
|
||||
|
||||
## [0.4.5](https://github.com/ginnoir/famapp/compare/v0.4.4...v0.4.5) (2026-06-02)
|
||||
|
||||
### Features
|
||||
|
||||
- **bangs:** add bang counter dashboard widget ([c850521](https://github.com/ginnoir/famapp/commit/c850521f3e993a5410f37cf29eb635cbc9540fd1))
|
||||
|
||||
## [0.4.4](https://github.com/ginnoir/famapp/compare/v0.4.3...v0.4.4) (2026-06-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **garden:** remove trailing slashes from openplantbook api urls and update label ([c744921](https://github.com/ginnoir/famapp/commit/c744921e6cfcf0269992cfd9afea47cc55082602))
|
||||
|
||||
## [0.4.3](https://github.com/ginnoir/famapp/compare/v0.4.2...v0.4.3) (2026-06-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **garden:** replace perenual with openplantbook for species lookup ([8722c20](https://github.com/ginnoir/famapp/commit/8722c205e6257a0590b48d0a858ad71d56ab07ac))
|
||||
|
||||
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.
|
||||
@@ -6,6 +6,12 @@ This file is the canonical brief. Read it at the start of every session before m
|
||||
|
||||
Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`, `STATUS.md`, task briefs, and dev notes synchronized so either agent can pick up the next task without relying on agent-specific memory.
|
||||
|
||||
## Regular agent skills
|
||||
|
||||
- Use the React best-practices skill for any React or Next.js page/component work, data-loading changes, bundle/performance work, or review of those areas.
|
||||
- Use the shadcn skill for any UI work involving shadcn components, Tailwind styling, overlays, forms, icons, component composition, or updates to `components.json` / `src/components/ui`.
|
||||
- Use the drizzle-best-practices skill for any database schema changes, migrations, queries, or ORM usage.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
@@ -31,7 +37,7 @@ Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`,
|
||||
| Realtime | Postgres `LISTEN/NOTIFY` → SSE | Simpler than WebSockets, fine for two users |
|
||||
| Reverse proxy | Caddy (existing) | `fam.ginnoir.com` → famapp:3000, `auth.ginnoir.com` → authentik |
|
||||
| Package manager | pnpm | Fast, content-addressed store, strict deps |
|
||||
| Runtime | Node 22 LTS | In `node:22-alpine` container |
|
||||
| Runtime | Node 24 LTS | In `node:24-alpine` container |
|
||||
| Host | Ubuntu Server 24.04 x64, Docker + Compose | Existing self-host stack |
|
||||
|
||||
Domain: `fam.ginnoir.com`. SSO: `auth.ginnoir.com`.
|
||||
@@ -106,7 +112,7 @@ All household-scoped entity tables include `household_id`. Sub-entities (`calend
|
||||
```yaml
|
||||
# compose.yaml (sketch — final lives in /deploy)
|
||||
services:
|
||||
famapp: # node:22-alpine, Next.js
|
||||
famapp: # node:24-alpine, Next.js
|
||||
famapp-db: # postgres:16
|
||||
authentik-server:
|
||||
authentik-worker:
|
||||
@@ -159,6 +165,27 @@ Tasks for each phase live in [`docs/tasks/`](docs/tasks/). Sub-sessions should p
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure repo
|
||||
|
||||
Compose stack, Caddyfile, and `.env` for the whole homelab (including famapp) live at:
|
||||
|
||||
```
|
||||
C:\Users\MattC\Documents\homelabstack
|
||||
```
|
||||
|
||||
GitHub: `ginnoir/homelabstack` (private). This is the source of truth for all infra files — **not** `deploy/` in this repo.
|
||||
|
||||
When famapp development requires an infra change (new env var, new service, Caddyfile route, etc.):
|
||||
|
||||
1. `cd C:\Users\MattC\Documents\homelabstack` and run `sync-prod.ps1` first (pulls current prod state)
|
||||
2. Edit `docker-compose.yml`, `.env`, and/or `Caddyfile` as needed
|
||||
3. Run `apply-compose.ps1` (flags: `-Compose`, `-Caddy`, `-EnvFile`) to push to valhalla
|
||||
4. Commit and push the homelabstack repo
|
||||
|
||||
The homelabstack `CLAUDE.md` is the authoritative brief for that repo — read it before editing infra files.
|
||||
|
||||
---
|
||||
|
||||
## Where things live
|
||||
|
||||
- **This brief:** `CLAUDE.md`
|
||||
@@ -166,5 +193,5 @@ Tasks for each phase live in [`docs/tasks/`](docs/tasks/). Sub-sessions should p
|
||||
- **Architecture decisions worth preserving:** `docs/decisions/NNNN-title.md` (lightweight ADR — only when a non-obvious choice is made)
|
||||
- **App code:** `src/`
|
||||
- **Drizzle migrations:** `drizzle/`
|
||||
- **Deploy:** `deploy/` (compose.yaml, Caddyfile snippet, Authentik bootstrap)
|
||||
- **Infra (compose/Caddyfile/.env):** `C:\Users\MattC\Documents\homelabstack`
|
||||
- **Memory (cross-session):** `C:\Users\MattC\.claude\projects\C--Users-MattC-Documents-famapp\memory\`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ── Stage 1: fetch dependencies ───────────────────────────────────────────────
|
||||
FROM node:22-alpine AS deps
|
||||
FROM node:24-alpine AS deps
|
||||
RUN corepack enable && corepack prepare pnpm@10.33.3 --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml .npmrc ./
|
||||
@@ -16,10 +16,11 @@ ENV CI=true
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY . .
|
||||
RUN pnpm install --offline --frozen-lockfile
|
||||
RUN pnpm build
|
||||
RUN --mount=type=cache,id=famapp-nextjs,target=/app/.next/cache \
|
||||
pnpm build
|
||||
|
||||
# ── Stage 3: production runtime ───────────────────────────────────────────────
|
||||
FROM node:22-alpine AS runner
|
||||
FROM node:24-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
@@ -32,6 +33,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"]
|
||||
|
||||
@@ -1,8 +1,158 @@
|
||||
# famapp
|
||||
|
||||
Self-hosted family coordination: shared calendar, lists, notes.
|
||||
Self-hosted family coordination web app. Shared calendar, lists, notes, and garden tracker — installable as a PWA, protected by OIDC single sign-on.
|
||||
|
||||
- **Plan & architecture:** [`CLAUDE.md`](CLAUDE.md)
|
||||
- **Current progress:** [`STATUS.md`](STATUS.md)
|
||||
- **Task briefs (for sub-sessions):** [`docs/tasks/`](docs/tasks/)
|
||||
- **Architecture decisions:** [`docs/decisions/`](docs/decisions/)
|
||||
[](https://github.com/ginnoir/famapp/actions/workflows/ci.yml)
|
||||
[](https://github.com/ginnoir/famapp/releases)
|
||||
[](https://github.com/ginnoir/famapp/pkgs/container/famapp)
|
||||
[](https://nodejs.org)
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Modules
|
||||
|
||||
| Module | What it does |
|
||||
| ------------ | ------------------------------------------------------------------------------------------ |
|
||||
| **Calendar** | Month / week / day views, drag-to-create, household and private calendars, event reminders |
|
||||
| **Lists** | Shopping and task lists with real-time sync, reorder, keyboard-first item entry |
|
||||
| **Notes** | Markdown notes with pinning, safe preview, and optional remind-at scheduling |
|
||||
| **Garden** | Plant and container tracking, care schedules, care logs, species lookup via OpenPlantBook |
|
||||
|
||||
### Platform capabilities
|
||||
|
||||
| Capability | Detail |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| **Dashboard** | Per-user named dashboards, drag-resize widget grid, configurable widget instances |
|
||||
| **Quick-add** | `Cmd/Ctrl+K` command palette and `+` FAB — every module registers its own actions |
|
||||
| **Share links** | Any entity produces a temporary public link (`/s/<token>`), scoped read or read-write |
|
||||
| **Activity log** | Household-scoped feed of every create / update / delete across all modules |
|
||||
| **Reminders** | Generic reminder engine ticked every 30 s; notes and calendar events both use it |
|
||||
| **Push notifications** | VAPID web push + in-app inbox + optional ntfy fallback |
|
||||
| **Themes** | Two palettes (`default` / `warm`) × light / dark / system, zero flash on load |
|
||||
| **PWA / offline** | Installable on iOS and Android, offline shell, stale-while-revalidate caching |
|
||||
| **Structured logging** | JSON logs via pino in production, pretty-printed in dev, `LOG_LEVEL` configurable |
|
||||
| **Nightly backups** | `pg_dump` cron for both databases, 14-day daily / 8-week weekly / 6-month monthly retention |
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Docker** with the Compose plugin (v2)
|
||||
- **Caddy** (or any reverse proxy that handles HTTPS)
|
||||
- **Authentik** — famapp delegates all authentication to an Authentik OIDC provider; see the [bootstrap guide](deploy/authentik/README.md)
|
||||
- A domain with DNS pointing to your host
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
Full details are in [`deploy/README.md`](deploy/README.md). The short version:
|
||||
|
||||
```bash
|
||||
# 1. Clone and enter the repo
|
||||
git clone https://github.com/ginnoir/famapp.git /srv/famapp
|
||||
cd /srv/famapp
|
||||
|
||||
# 2. Create your environment file
|
||||
cp .env.production.example deploy/.env
|
||||
# Edit deploy/.env — see Configuration below for required values
|
||||
|
||||
# 3. Configure your reverse proxy
|
||||
# Add deploy/Caddyfile.snippet to your Caddyfile, then reload Caddy
|
||||
|
||||
# 4. Bootstrap Authentik and record the OIDC client id/secret in deploy/.env
|
||||
# See deploy/authentik/README.md
|
||||
|
||||
# 5. Start the stack
|
||||
docker compose -f deploy/compose.example.yaml up -d
|
||||
```
|
||||
|
||||
The container runs database migrations automatically on start. The first user to sign in becomes the household owner; a second sign-in joins the same household as a member.
|
||||
|
||||
Pin `FAMAPP_IMAGE` in `deploy/.env` after the first deploy:
|
||||
|
||||
```
|
||||
FAMAPP_IMAGE=registry.ginnoir.com/ginnoir/famapp:v0.4.7
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development setup
|
||||
|
||||
Requires Node ≥ 22 and pnpm 10.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ginnoir/famapp.git
|
||||
cd famapp
|
||||
pnpm install
|
||||
|
||||
# Copy the example env and fill in dev values (set ENABLE_DEV_LOGIN=true)
|
||||
cp .env.example .env
|
||||
|
||||
# Start the local database, run migrations, seed, and launch the dev server
|
||||
pnpm dev:local
|
||||
```
|
||||
|
||||
Then open `http://localhost:3000/login` and click **Dev login**.
|
||||
|
||||
Additional dev commands:
|
||||
|
||||
| Command | Purpose |
|
||||
| --------------------- | ---------------------------------------------- |
|
||||
| `pnpm dev:reset` | Drop and reseed the local database |
|
||||
| `pnpm db:studio` | Open Drizzle Studio against the local database |
|
||||
| `pnpm db:generate` | Generate a new migration after schema changes |
|
||||
| `pnpm typecheck` | Run `tsc --noEmit` |
|
||||
| `pnpm lint` | ESLint |
|
||||
| `pnpm test:e2e` | Playwright E2E suite |
|
||||
| `pnpm vapid:generate` | Print VAPID key env vars to stdout |
|
||||
| `pnpm gen:icons` | Regenerate PWA icons from `public/icon.svg` |
|
||||
|
||||
See [`docs/dev-login.md`](docs/dev-login.md) for push notification and E2E testing setup.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy `.env.production.example` to `deploy/.env` and fill in values. Key variables:
|
||||
|
||||
| Variable | Required | How to generate |
|
||||
| -------------------------------- | -------- | -------------------------------------------------------------------- |
|
||||
| `AUTH_URL` | Yes | Public HTTPS URL for the app (e.g. `https://fam.yourdomain.com`) |
|
||||
| `AUTH_SECRET` | Yes | `openssl rand -base64 32` |
|
||||
| `AUTH_OIDC_ISSUER` | Yes | From Authentik — `https://auth.yourdomain.com/application/o/famapp/` |
|
||||
| `AUTH_OIDC_CLIENT_ID` | Yes | From Authentik application |
|
||||
| `AUTH_OIDC_CLIENT_SECRET` | Yes | From Authentik application |
|
||||
| `FAMAPP_DB_PASSWORD` | Yes | Strong random password |
|
||||
| `AUTHENTIK_DB_PASSWORD` | Yes | Strong random password |
|
||||
| `AUTHENTIK_SECRET_KEY` | Yes | `openssl rand -base64 60` |
|
||||
| `VAPID_PUBLIC_KEY` | Push | `pnpm vapid:generate` |
|
||||
| `VAPID_PRIVATE_KEY` | Push | `pnpm vapid:generate` |
|
||||
| `VAPID_SUBJECT` | Push | `mailto:your-email@example.com` |
|
||||
| `NTFY_URL` / `NTFY_TOPIC` | Optional | ntfy push fallback |
|
||||
| `famapp_OPENPLANTBOOK_CLIENT_ID` | Optional | OpenPlantBook API — plant species lookup |
|
||||
| `LOG_LEVEL` | Optional | `error` / `warn` / `info` / `debug` (default: `info`) |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
famapp uses an extensibility-first module system: every feature lives under `src/modules/<name>/` and declares its tables, routes, dashboard widgets, quick-add actions, and share/reminder/search behaviors via a manifest. Core services (sharing, push, reminders, activity log, search) operate generically — adding a new module does not require touching core code.
|
||||
|
||||
See [`CLAUDE.md`](CLAUDE.md) for the full architecture brief and [`docs/decisions/`](docs/decisions/) for ADRs.
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
| Resource | Link |
|
||||
| -------------------- | -------------------------------------------------------------------------------- |
|
||||
| Full host setup | [`deploy/README.md`](deploy/README.md) |
|
||||
| Authentik bootstrap | [`deploy/authentik/README.md`](deploy/authentik/README.md) |
|
||||
| Backup / restore | [`deploy/backups/README.md`](deploy/backups/README.md) |
|
||||
| Pre-deploy checklist | [`docs/tasks/09-pre-deploy-checklist.md`](docs/tasks/09-pre-deploy-checklist.md) |
|
||||
| Changelog | [`CHANGELOG.md`](CHANGELOG.md) |
|
||||
|
||||
Cutting a release: tag `vX.Y.Z` on `main` and push — Gitea Actions builds and pushes `registry.ginnoir.com/ginnoir/famapp:vX.Y.Z` automatically.
|
||||
|
||||
@@ -20,10 +20,31 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b
|
||||
- **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,11 @@
|
||||
# Include from main Caddyfile or paste into the existing one.
|
||||
# Add these blocks to your existing Caddyfile.
|
||||
# Assumes famapp and authentik-server are reachable by Caddy
|
||||
# (e.g. on a shared Docker network).
|
||||
|
||||
fam.ginnoir.com {
|
||||
reverse_proxy famapp:3000
|
||||
fam.yourdomain.com {
|
||||
reverse_proxy famapp:3000
|
||||
}
|
||||
|
||||
auth.ginnoir.com {
|
||||
reverse_proxy authentik-server:9000
|
||||
auth.yourdomain.com {
|
||||
reverse_proxy authentik-server:9000
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# 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.
|
||||
|
||||
## Files in this directory
|
||||
|
||||
| File | Purpose |
|
||||
| ----------------------- | -------------------------------------------------------------------------- |
|
||||
| `compose.example.yaml` | **Start here.** Standalone famapp + Authentik stack for new deployments. |
|
||||
| `compose.yaml` | Maintainer's production compose (full homelab monolith — not a template). |
|
||||
| `Caddyfile.snippet` | Reverse proxy blocks to add to your Caddyfile. |
|
||||
| `Caddyfile.dev.snippet` | Dev machine proxy block (maintainer-specific). |
|
||||
| `Caddyfile` | Maintainer's full production Caddyfile (not a template). |
|
||||
| `authentik/README.md` | Authentik bootstrap guide. |
|
||||
| `backups/` | Backup container scripts (used by `famapp-backup` in the example compose). |
|
||||
|
||||
## One-time host setup
|
||||
|
||||
1. Install Docker + Compose plugin on the host.
|
||||
2. `git clone` this repo to e.g. `/srv/famapp`.
|
||||
3. Copy `deploy/compose.example.yaml` → `/srv/famapp/deploy/compose.yaml`.
|
||||
4. 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`.
|
||||
- `openssl rand -hex 64` for the MinIO passwords.
|
||||
- `pnpm vapid:generate` (locally, from the repo) for the three VAPID lines.
|
||||
5. Bootstrap Authentik per `deploy/authentik/README.md`. Save the OIDC client id/secret into `.env`.
|
||||
6. Wire Caddy (or any reverse proxy) using `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
|
||||
```
|
||||
|
||||
`.gitea/workflows/release.yml` builds + pushes `registry.ginnoir.com/ginnoir/famapp:v0.1.0`, `:0.1`, and `:latest` to the self-hosted registry.
|
||||
|
||||
## Deploying a release on the host
|
||||
|
||||
```bash
|
||||
cd /srv/famapp/deploy
|
||||
# pin to the tag you just cut
|
||||
sed -i 's|FAMAPP_IMAGE=.*|FAMAPP_IMAGE=registry.ginnoir.com/ginnoir/famapp:v0.1.0|' .env
|
||||
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.
|
||||
@@ -6,13 +6,13 @@ Run these steps once after the first `docker compose up -d` in the `deploy/` dir
|
||||
|
||||
## 1. Set the admin password
|
||||
|
||||
Visit `https://auth.ginnoir.com/if/flow/initial-setup/` and set the **akadmin** password.
|
||||
Visit `https://auth.yourdomain.com/if/flow/initial-setup/` and set the **akadmin** password.
|
||||
|
||||
---
|
||||
|
||||
## 2. Create the OIDC provider
|
||||
|
||||
1. Log in to the Authentik Admin UI at `https://auth.ginnoir.com/if/admin/`.
|
||||
1. Log in to the Authentik Admin UI at `https://auth.yourdomain.com/if/admin/`.
|
||||
2. Go to **Applications → Providers → Create**.
|
||||
3. Choose **OAuth2/OpenID Provider**.
|
||||
4. Configure:
|
||||
@@ -21,7 +21,7 @@ Visit `https://auth.ginnoir.com/if/flow/initial-setup/` and set the **akadmin**
|
||||
- **Client type:** `Confidential`
|
||||
- **Client ID:** (auto-generated — copy this)
|
||||
- **Client Secret:** (auto-generated — copy this)
|
||||
- **Redirect URIs:** `https://fam.ginnoir.com/api/auth/callback/authentik`
|
||||
- **Redirect URIs:** `https://fam.yourdomain.com/api/auth/callback/authentik`
|
||||
- **Signing Key:** `authentik Self-signed Certificate`
|
||||
- **Token validity:** 24 hours (or your preference)
|
||||
5. Save and note the **Issuer URL** shown on the provider detail page.
|
||||
@@ -29,13 +29,13 @@ Visit `https://auth.ginnoir.com/if/flow/initial-setup/` and set the **akadmin**
|
||||
The issuer URL will look like:
|
||||
|
||||
```
|
||||
https://auth.ginnoir.com/application/o/famapp/
|
||||
https://auth.yourdomain.com/application/o/famapp/
|
||||
```
|
||||
|
||||
Set this (and the client ID/secret) in famapp's `.env` / production secrets:
|
||||
|
||||
```env
|
||||
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
|
||||
AUTH_OIDC_ISSUER=https://auth.yourdomain.com/application/o/famapp/
|
||||
AUTH_OIDC_CLIENT_ID=<client-id>
|
||||
AUTH_OIDC_CLIENT_SECRET=<client-secret>
|
||||
```
|
||||
@@ -49,7 +49,7 @@ AUTH_OIDC_CLIENT_SECRET=<client-secret>
|
||||
- **Name:** `famapp`
|
||||
- **Slug:** `famapp`
|
||||
- **Provider:** select the `famapp` provider created above
|
||||
- **Launch URL:** `https://fam.ginnoir.com`
|
||||
- **Launch URL:** `https://fam.yourdomain.com`
|
||||
3. Save.
|
||||
|
||||
---
|
||||
@@ -68,7 +68,7 @@ AUTH_OIDC_CLIENT_SECRET=<client-secret>
|
||||
|
||||
Each user can enroll a passkey from their Authentik profile:
|
||||
|
||||
1. Sign in as the user at `https://auth.ginnoir.com`.
|
||||
1. Sign in as the user at `https://auth.yourdomain.com`.
|
||||
2. Go to **Settings → MFA Devices → Add → WebAuthn Device**.
|
||||
3. Follow the browser prompt to register a Touch ID / Face ID / hardware key.
|
||||
|
||||
|
||||
@@ -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,196 @@
|
||||
# Standalone famapp deployment template.
|
||||
# Copy this to compose.yaml (or reference with -f), fill in .env from
|
||||
# .env.production.example, then: docker compose up -d
|
||||
#
|
||||
# Caddy (or any reverse proxy) should sit in front — see Caddyfile.snippet.
|
||||
# If Caddy runs in a separate compose stack, add famapp to that stack's
|
||||
# external network instead of exposing ports directly.
|
||||
|
||||
name: famapp
|
||||
|
||||
networks:
|
||||
famapp_net:
|
||||
|
||||
volumes:
|
||||
famapp_db_data:
|
||||
authentik_db_data:
|
||||
authentik_redis_data:
|
||||
garden_uploads:
|
||||
backups:
|
||||
|
||||
services:
|
||||
famapp:
|
||||
image: ${FAMAPP_IMAGE:-registry.ginnoir.com/ginnoir/famapp:latest}
|
||||
pull_policy: ${FAMAPP_PULL_POLICY:-always}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
AUTH_URL: ${AUTH_URL}
|
||||
DATABASE_URL: postgres://${FAMAPP_DB_USER}:${FAMAPP_DB_PASSWORD}@famapp-db:5432/${FAMAPP_DB_NAME}
|
||||
AUTH_SECRET: ${AUTH_SECRET}
|
||||
AUTH_OIDC_ISSUER: ${AUTH_OIDC_ISSUER}
|
||||
AUTH_OIDC_CLIENT_ID: ${AUTH_OIDC_CLIENT_ID}
|
||||
AUTH_OIDC_CLIENT_SECRET: ${AUTH_OIDC_CLIENT_SECRET}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||
VAPID_SUBJECT: ${VAPID_SUBJECT}
|
||||
NTFY_URL: ${NTFY_URL:-}
|
||||
NTFY_TOPIC: ${NTFY_TOPIC:-}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-true}
|
||||
MINIO_ENDPOINT: http://famapp-minio:9000
|
||||
MINIO_ROOT_USER: ${famapp_MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD: ${famapp_MINIO_ROOT_PASSWORD}
|
||||
MINIO_BUCKET: ${famapp_MINIO_BUCKET:-garden}
|
||||
OPENPLANTBOOK_CLIENT_ID: ${famapp_OPENPLANTBOOK_CLIENT_ID:-}
|
||||
OPENPLANTBOOK_CLIENT_SECRET: ${famapp_OPENPLANTBOOK_CLIENT_SECRET:-}
|
||||
ports:
|
||||
- "${FAMAPP_PORT:-3000}:3000"
|
||||
depends_on:
|
||||
famapp-db:
|
||||
condition: service_healthy
|
||||
famapp-minio:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- famapp_net
|
||||
|
||||
famapp-db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${FAMAPP_DB_USER}
|
||||
POSTGRES_PASSWORD: ${FAMAPP_DB_PASSWORD}
|
||||
POSTGRES_DB: ${FAMAPP_DB_NAME}
|
||||
volumes:
|
||||
- famapp_db_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${FAMAPP_DB_USER} -d ${FAMAPP_DB_NAME}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- famapp_net
|
||||
|
||||
famapp-minio:
|
||||
image: minio/minio:latest
|
||||
command: server /data --console-address ":9001"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${famapp_MINIO_ROOT_USER}
|
||||
MINIO_ROOT_PASSWORD: ${famapp_MINIO_ROOT_PASSWORD}
|
||||
volumes:
|
||||
- garden_uploads:/data
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:9000/minio/health/live || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 20s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
networks:
|
||||
- famapp_net
|
||||
|
||||
famapp-backup:
|
||||
image: alpine:3.20
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
FAMAPP_DB_HOST: famapp-db
|
||||
FAMAPP_DB_PORT: "5432"
|
||||
FAMAPP_DB_USER: ${FAMAPP_DB_USER}
|
||||
FAMAPP_DB_PASSWORD: ${FAMAPP_DB_PASSWORD}
|
||||
FAMAPP_DB_NAME: ${FAMAPP_DB_NAME}
|
||||
AUTHENTIK_DB_HOST: authentik-db
|
||||
AUTHENTIK_DB_PORT: "5432"
|
||||
AUTHENTIK_DB_USER: ${AUTHENTIK_DB_USER:-authentik}
|
||||
AUTHENTIK_DB_PASSWORD: ${AUTHENTIK_DB_PASSWORD}
|
||||
AUTHENTIK_DB_NAME: ${AUTHENTIK_DB_NAME:-authentik}
|
||||
volumes:
|
||||
- backups:/backups
|
||||
- ./backups:/scripts:ro
|
||||
depends_on:
|
||||
famapp-db:
|
||||
condition: service_healthy
|
||||
authentik-db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- famapp_net
|
||||
entrypoint: ["sh", "/scripts/entrypoint.sh"]
|
||||
|
||||
authentik-server:
|
||||
image: ghcr.io/goauthentik/server:${AUTHENTIK_IMAGE_TAG:-2024.12.3}
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
||||
AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_DB_USER:-authentik}
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD}
|
||||
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_DB_NAME:-authentik}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
|
||||
ports:
|
||||
- "9200:9000"
|
||||
depends_on:
|
||||
authentik-db:
|
||||
condition: service_healthy
|
||||
authentik-redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- famapp_net
|
||||
|
||||
authentik-worker:
|
||||
image: ghcr.io/goauthentik/server:${AUTHENTIK_IMAGE_TAG:-2024.12.3}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
AUTHENTIK_REDIS__HOST: authentik-redis
|
||||
AUTHENTIK_POSTGRESQL__HOST: authentik-db
|
||||
AUTHENTIK_POSTGRESQL__USER: ${AUTHENTIK_DB_USER:-authentik}
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD}
|
||||
AUTHENTIK_POSTGRESQL__NAME: ${AUTHENTIK_DB_NAME:-authentik}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
|
||||
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
|
||||
depends_on:
|
||||
authentik-db:
|
||||
condition: service_healthy
|
||||
authentik-redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- famapp_net
|
||||
|
||||
authentik-db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${AUTHENTIK_DB_USER:-authentik}
|
||||
POSTGRES_PASSWORD: ${AUTHENTIK_DB_PASSWORD}
|
||||
POSTGRES_DB: ${AUTHENTIK_DB_NAME:-authentik}
|
||||
volumes:
|
||||
- authentik_db_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${AUTHENTIK_DB_USER:-authentik} -d ${AUTHENTIK_DB_NAME:-authentik}",
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- famapp_net
|
||||
|
||||
authentik-redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: --save 60 1 --loglevel warning
|
||||
volumes:
|
||||
- authentik_redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- famapp_net
|
||||
@@ -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,158 @@
|
||||
# Contributing
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js** 24 LTS (`node --version` should print `v24.x.x`)
|
||||
- **pnpm** 10 (`pnpm --version` should print `10.x.x`)
|
||||
- **Docker** with the Compose plugin (used for the local Postgres + MinIO containers)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ginnoir/famapp.git
|
||||
cd famapp
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
# Edit .env — minimum required for local dev:
|
||||
# NEXT_PUBLIC_APP_URL=http://127.0.0.1:3000
|
||||
# DATABASE_URL=postgres://famapp:famapp@localhost:5432/famapp
|
||||
# ENABLE_DEV_LOGIN=true
|
||||
# AUTH_SECRET=<any-32-char-string-for-local>
|
||||
```
|
||||
|
||||
Start everything (database + migrations + seed + dev server):
|
||||
|
||||
```bash
|
||||
pnpm dev:local
|
||||
```
|
||||
|
||||
Then open `http://localhost:3000/login` and click **Dev login**. See `docs/dev-login.md` for the full local setup including HTTPS/PWA testing.
|
||||
|
||||
## Available scripts
|
||||
|
||||
<!-- AUTO-GENERATED from package.json scripts -->
|
||||
|
||||
| Command | Description |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `pnpm dev` | Next.js dev server (localhost only) |
|
||||
| `pnpm dev:network` | Dev server bound to `0.0.0.0` (LAN access for phone testing) |
|
||||
| `pnpm dev:local` | Full local stack — starts DB container, runs migrations, seeds, launches dev server |
|
||||
| `pnpm dev:reset` | Tear down local DB and start fresh (destructive — deletes all local data) |
|
||||
| `pnpm build` | Production Next.js build |
|
||||
| `pnpm start` | Start the production build locally |
|
||||
| `pnpm lint` | ESLint check |
|
||||
| `pnpm lint:fix` | ESLint with auto-fix |
|
||||
| `pnpm format` | Prettier — format all files |
|
||||
| `pnpm format:check` | Prettier — check only (used in CI) |
|
||||
| `pnpm typecheck` | TypeScript type check (`tsc --noEmit`) |
|
||||
| `pnpm test:e2e` | Playwright end-to-end tests |
|
||||
| `pnpm db:generate` | Generate a new Drizzle migration from schema changes |
|
||||
| `pnpm db:migrate` | Apply pending Drizzle migrations |
|
||||
| `pnpm db:seed` | Seed the database with dev fixtures |
|
||||
| `pnpm db:studio` | Open Drizzle Studio (local DB browser) |
|
||||
| `pnpm gen:icons` | Regenerate PWA icon set from source |
|
||||
| `pnpm vapid:generate` | Generate VAPID key pair for Web Push |
|
||||
| `pnpm release` | Interactive release (prompts for semver bump, tags, publishes changelog + GitHub Release) |
|
||||
| `pnpm release:patch` | Non-interactive patch release |
|
||||
| `pnpm release:minor` | Non-interactive minor release |
|
||||
| `pnpm release:major` | Non-interactive major release |
|
||||
| `pnpm release:dry` | Dry-run release — preview without writing |
|
||||
|
||||
<!-- END AUTO-GENERATED -->
|
||||
|
||||
## Running tests
|
||||
|
||||
### Type check + lint (CI equivalent)
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm format:check
|
||||
```
|
||||
|
||||
### End-to-end tests
|
||||
|
||||
The app must be running first (`pnpm dev:local`). Generate a Playwright auth state file, then run tests:
|
||||
|
||||
```powershell
|
||||
# Generate auth state (run once after starting the app)
|
||||
New-Item -ItemType Directory -Force tests\.auth | Out-Null
|
||||
@'
|
||||
const { chromium } = require('@playwright/test');
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('http://127.0.0.1:3000/login');
|
||||
await page.getByRole('button', { name: 'Dev login' }).click();
|
||||
await page.waitForURL('http://127.0.0.1:3000/');
|
||||
await page.context().storageState({ path: 'tests/.auth/dev-user.json' });
|
||||
await browser.close();
|
||||
})();
|
||||
'@ | node -
|
||||
|
||||
# Run tests
|
||||
$env:PLAYWRIGHT_STORAGE_STATE='tests/.auth/dev-user.json'
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
### Writing tests
|
||||
|
||||
- Unit tests: Vitest under `tests/unit/` — only where it pays off (utilities, pure logic).
|
||||
- E2E tests: Playwright under `tests/e2e/` — one happy-path test per module. Do not write brittle selector-heavy tests for trivial CRUD.
|
||||
|
||||
## Code style
|
||||
|
||||
- **TypeScript strict** — no `any` without a written reason.
|
||||
- **No comments** unless the _why_ is non-obvious. Names carry intent.
|
||||
- **Immutable** — always return new objects; never mutate in place.
|
||||
- **Module isolation** — a module imports from `_core` and `lib/` only; never from a sibling module.
|
||||
- **File size** — 200–400 lines typical, 800 hard cap.
|
||||
|
||||
Formatting and lint run automatically on staged files via `lint-staged` at commit time. You can also run them manually with `pnpm lint:fix` and `pnpm format`.
|
||||
|
||||
## Commit format
|
||||
|
||||
Commits must follow [Conventional Commits](https://www.conventionalcommits.org/). `commitlint` enforces this at the `commit-msg` hook.
|
||||
|
||||
```
|
||||
<type>: <short description>
|
||||
|
||||
[optional body]
|
||||
```
|
||||
|
||||
Allowed types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `perf`, `ci`, `revert`
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
feat: add plant species search to garden module
|
||||
fix: calendar event end time off by one day
|
||||
chore: bump next to 15.6
|
||||
```
|
||||
|
||||
## Adding a module
|
||||
|
||||
Every feature lives under `src/modules/<name>/`. A new module needs:
|
||||
|
||||
- `schema.ts` — Drizzle tables
|
||||
- `server/` — server actions and queries
|
||||
- `components/` — React components
|
||||
- `manifest.ts` — registers the module with the core registry (nav, entity types, dashboard widget, quick-add actions)
|
||||
|
||||
See `CLAUDE.md` for the full architectural brief. The module loader in `src/modules/_core/` discovers manifests automatically — no changes to core code required for a new module.
|
||||
|
||||
## Schema changes
|
||||
|
||||
1. Edit the relevant `schema.ts`.
|
||||
2. Run `pnpm db:generate` to create a new migration file under `drizzle/`.
|
||||
3. Commit the migration alongside the schema change.
|
||||
4. Never edit a shipped migration — always add a new one.
|
||||
|
||||
## PR checklist
|
||||
|
||||
- [ ] `pnpm typecheck` passes
|
||||
- [ ] `pnpm lint` passes
|
||||
- [ ] `pnpm build` succeeds
|
||||
- [ ] New Drizzle migration committed if schema changed
|
||||
- [ ] E2E test added or updated if a user-visible flow changed
|
||||
- [ ] `docs/tasks/09-pre-deploy-checklist.md` reviewed if touching auth or env
|
||||
@@ -0,0 +1,112 @@
|
||||
# Environment Variables
|
||||
|
||||
<!-- AUTO-GENERATED from .env.example and deploy/compose.example.yaml -->
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Fill in required values, then:
|
||||
pnpm dev:local
|
||||
```
|
||||
|
||||
## Core
|
||||
|
||||
| Variable | Required | Description | Example / Default |
|
||||
| --------------------- | -------- | ------------------------------------------------------------------ | ------------------------------------------------ |
|
||||
| `NEXT_PUBLIC_APP_URL` | Yes | Public URL of the app — used in OIDC redirect URIs and share links | `https://fam.yourdomain.com` |
|
||||
| `DATABASE_URL` | Yes | Postgres connection string | `postgres://famapp:famapp@localhost:5432/famapp` |
|
||||
| `AUTH_SECRET` | Yes | Auth.js signing secret — generate with `openssl rand -base64 32` | — |
|
||||
|
||||
## OIDC (Authentik)
|
||||
|
||||
Required in production. Obtain from the Authentik admin panel after bootstrapping the provider — see `deploy/authentik/README.md`.
|
||||
|
||||
| Variable | Required | Description | Example |
|
||||
| ------------------------- | ---------- | --------------------------------- | --------------------------------------------------- |
|
||||
| `AUTH_OIDC_ISSUER` | Yes (prod) | Authentik OIDC issuer URL | `https://auth.yourdomain.com/application/o/famapp/` |
|
||||
| `AUTH_OIDC_CLIENT_ID` | Yes (prod) | OIDC client ID from Authentik | — |
|
||||
| `AUTH_OIDC_CLIENT_SECRET` | Yes (prod) | OIDC client secret from Authentik | — |
|
||||
|
||||
## Dev login
|
||||
|
||||
Local development only. Never set `ENABLE_DEV_LOGIN=true` in production — a startup assertion in `src/lib/dev-login-config.ts` will crash the container if you do.
|
||||
|
||||
| Variable | Required | Description | Default |
|
||||
| -------------------- | -------- | ------------------------------------------------- | ------------------ |
|
||||
| `ENABLE_DEV_LOGIN` | No | Show a one-click **Dev login** button on `/login` | `false` |
|
||||
| `DEV_LOGIN_EMAIL` | No | Email for the auto-created dev session | `dev@famapp.local` |
|
||||
| `DEV_LOGIN_NAME` | No | Display name for the dev user | `Dev User` |
|
||||
| `DEV_HOUSEHOLD_NAME` | No | Household created for the dev user | `Home` |
|
||||
|
||||
See `docs/dev-login.md` for the full local run procedure.
|
||||
|
||||
## Web Push (VAPID)
|
||||
|
||||
All three are required together. Generate them once with `pnpm vapid:generate` and copy all three lines into `.env`.
|
||||
|
||||
| Variable | Required | Description |
|
||||
| ------------------- | -------- | --------------------------------------------- | ------------------------ |
|
||||
| `VAPID_PUBLIC_KEY` | Yes | VAPID public key — also passed to the browser |
|
||||
| `VAPID_PRIVATE_KEY` | Yes | VAPID private key — server only |
|
||||
| `VAPID_SUBJECT` | Yes | Contact URI for push servers | `mailto:you@example.com` |
|
||||
|
||||
## ntfy (optional)
|
||||
|
||||
Leave both blank to disable the ntfy notification channel. Web Push is the primary channel.
|
||||
|
||||
| Variable | Required | Description | Example |
|
||||
| ------------ | -------- | ------------------------ | ----------------- |
|
||||
| `NTFY_URL` | No | ntfy server base URL | `https://ntfy.sh` |
|
||||
| `NTFY_TOPIC` | No | ntfy topic to publish to | `famapp-alerts` |
|
||||
|
||||
## Logging
|
||||
|
||||
| Variable | Required | Description | Values |
|
||||
| ----------- | -------- | ------------------ | ----------------------------------------------------------- |
|
||||
| `LOG_LEVEL` | No | Pino log verbosity | `trace`, `debug`, `info`, `warn`, `error` (default: `info`) |
|
||||
|
||||
## MinIO object storage
|
||||
|
||||
Required when the garden module is enabled. The dev compose stack starts a local MinIO instance automatically via `pnpm dev:local`.
|
||||
|
||||
| Variable | Required | Description | Default |
|
||||
| --------------------- | -------- | ------------------------------------ | ----------------------- |
|
||||
| `MINIO_ENDPOINT` | Yes | MinIO server base URL | `http://localhost:9000` |
|
||||
| `MINIO_ROOT_USER` | Yes | MinIO root access key | `famapp` |
|
||||
| `MINIO_ROOT_PASSWORD` | Yes | MinIO root secret key | `changeme` |
|
||||
| `MINIO_BUCKET` | No | Bucket used for garden image uploads | `garden` |
|
||||
|
||||
## OpenPlantBook
|
||||
|
||||
Optional. Enables plant species lookup in the garden module. Free account at <https://open.plantbook.io>.
|
||||
|
||||
| Variable | Required | Description |
|
||||
| ----------------------------- | -------- | -------------------- |
|
||||
| `OPENPLANTBOOK_CLIENT_ID` | No | OAuth2 client ID |
|
||||
| `OPENPLANTBOOK_CLIENT_SECRET` | No | OAuth2 client secret |
|
||||
|
||||
## Release tooling
|
||||
|
||||
Only needed on the machine that cuts releases (`pnpm release`).
|
||||
|
||||
| Variable | Required | Description |
|
||||
| -------------- | ------------- | ------------------------------------------------ |
|
||||
| `GITHUB_TOKEN` | Yes (release) | Personal access token — creates a GitHub Release |
|
||||
|
||||
## Production-only compose variables
|
||||
|
||||
Used by `deploy/compose.example.yaml`. Set in `deploy/.env` on the server — not in the local `.env`.
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `FAMAPP_IMAGE` | Docker image tag to deploy (e.g. `registry.ginnoir.com/ginnoir/famapp:v0.4.7`) |
|
||||
| `FAMAPP_PORT` | Host port to bind (default: `3000`) |
|
||||
| `FAMAPP_PULL_POLICY` | Docker pull policy (default: `always`) |
|
||||
| `FAMAPP_DB_USER` / `FAMAPP_DB_PASSWORD` / `FAMAPP_DB_NAME` | Postgres credentials for the famapp database |
|
||||
| `AUTHENTIK_DB_USER` / `AUTHENTIK_DB_PASSWORD` / `AUTHENTIK_DB_NAME` | Postgres credentials for the Authentik database |
|
||||
| `AUTHENTIK_SECRET_KEY` | Authentik signing key — generate with `openssl rand -base64 60` |
|
||||
| `AUTHENTIK_IMAGE_TAG` | Authentik server image tag (default: `2024.12.3`) |
|
||||
| `RUN_MIGRATIONS` | Set `false` to skip auto-migration on start (default: `true`) |
|
||||
|
||||
<!-- END AUTO-GENERATED -->
|
||||
@@ -0,0 +1,158 @@
|
||||
# Runbook
|
||||
|
||||
Operational reference for famapp in production. See `deploy/README.md` for the one-time host setup and `docs/tasks/09-pre-deploy-checklist.md` for the pre-deploy checklist.
|
||||
|
||||
## Cutting a release
|
||||
|
||||
From `main` on the dev machine, with a clean working tree:
|
||||
|
||||
```bash
|
||||
pnpm release:patch # or :minor / :major
|
||||
# — bumps package.json version
|
||||
# — prepends to CHANGELOG.md
|
||||
# — creates and pushes a signed git tag (v0.x.y)
|
||||
# — creates a GitHub Release with generated notes
|
||||
# Requires GITHUB_TOKEN in .env
|
||||
```
|
||||
|
||||
Gitea Actions (`release.yml`) then builds and pushes the Docker image to the self-hosted registry:
|
||||
|
||||
- `registry.ginnoir.com/ginnoir/famapp:v0.x.y`
|
||||
- `registry.ginnoir.com/ginnoir/famapp:0.x` (minor alias)
|
||||
- `registry.ginnoir.com/ginnoir/famapp:latest`
|
||||
|
||||
## Deploying a release
|
||||
|
||||
On the home server, in `/srv/famapp/deploy/`:
|
||||
|
||||
```bash
|
||||
# Pin the new tag
|
||||
sed -i 's|FAMAPP_IMAGE=.*|FAMAPP_IMAGE=registry.ginnoir.com/ginnoir/famapp:v0.x.y|' .env
|
||||
|
||||
# Pull and restart only the app container
|
||||
docker compose pull famapp
|
||||
docker compose up -d famapp
|
||||
|
||||
# Watch the boot log — migrations run before the server starts
|
||||
docker compose logs -f famapp
|
||||
```
|
||||
|
||||
The container entrypoint runs `node scripts/migrate.mjs` automatically. A healthy boot ends with a log line like `ready on http://0.0.0.0:3000`.
|
||||
|
||||
## Health check
|
||||
|
||||
```bash
|
||||
# Container status
|
||||
docker compose ps
|
||||
|
||||
# App response (200 = healthy)
|
||||
curl -sf https://fam.yourdomain.com/ -o /dev/null -w "%{http_code}\n"
|
||||
|
||||
# Recent app logs
|
||||
docker compose logs --tail=100 famapp
|
||||
|
||||
# Database connectivity
|
||||
docker compose exec famapp-db pg_isready -U famapp -d famapp
|
||||
```
|
||||
|
||||
## Rollback
|
||||
|
||||
1. Find the previous working tag in `CHANGELOG.md` or `docker images`.
|
||||
2. Pin it in `deploy/.env`:
|
||||
```bash
|
||||
sed -i 's|FAMAPP_IMAGE=.*|FAMAPP_IMAGE=registry.ginnoir.com/ginnoir/famapp:v0.x.y|' .env
|
||||
```
|
||||
3. Restart the container:
|
||||
```bash
|
||||
docker compose up -d famapp
|
||||
```
|
||||
|
||||
If the rollback target predates a migration that has already been applied to the database, restore from backup first — see **Backups** below. To skip auto-migration on a given start (rarely needed):
|
||||
|
||||
```bash
|
||||
RUN_MIGRATIONS=false docker compose up -d famapp
|
||||
```
|
||||
|
||||
## Backups
|
||||
|
||||
The `famapp-backup` container runs nightly `pg_dump` for both `famapp` and `authentik` databases into the `backups` volume.
|
||||
|
||||
```bash
|
||||
# Check last backup run
|
||||
docker compose logs famapp-backup | tail -20
|
||||
|
||||
# List backup files
|
||||
docker compose exec famapp-backup ls -lh /backups
|
||||
|
||||
# Manual backup now
|
||||
docker compose exec famapp-backup sh /scripts/backup.sh
|
||||
|
||||
# Restore from a backup file
|
||||
docker compose exec famapp-backup sh /scripts/restore.sh famapp_2026-06-01.sql.gz
|
||||
```
|
||||
|
||||
See `deploy/backups/README.md` for retention policy and full restore details.
|
||||
|
||||
## Common issues
|
||||
|
||||
### App container exits immediately
|
||||
|
||||
```bash
|
||||
docker compose logs famapp
|
||||
```
|
||||
|
||||
Likely causes:
|
||||
|
||||
- **Missing required env var** — the app throws on startup if `AUTH_SECRET`, `DATABASE_URL`, or `AUTH_OIDC_*` are absent.
|
||||
- **`ENABLE_DEV_LOGIN=true` in production** — `src/lib/dev-login-config.ts` throws an assertion error on import. Remove the variable from the production env.
|
||||
- **Database not ready** — Postgres healthcheck should prevent this, but if the DB is slow to start, increase `start_period` in `compose.yaml`.
|
||||
|
||||
### Migrations fail on boot
|
||||
|
||||
```bash
|
||||
docker compose logs famapp | grep -i migration
|
||||
```
|
||||
|
||||
- Schema is ahead of code: roll back the image or forward-migrate manually.
|
||||
- Database unreachable: confirm `famapp-db` is healthy (`docker compose ps`).
|
||||
|
||||
### OIDC login fails
|
||||
|
||||
1. Confirm `AUTH_OIDC_ISSUER` matches the Authentik provider URL exactly (trailing slash matters).
|
||||
2. Confirm the OIDC client redirect URI in Authentik includes `https://fam.yourdomain.com/api/auth/callback/oidc`.
|
||||
3. Check Authentik logs: `docker compose logs authentik-server | tail -50`.
|
||||
|
||||
### Push notifications not arriving
|
||||
|
||||
1. Confirm `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, and `VAPID_SUBJECT` are all set in the production env.
|
||||
2. Verify the browser's push subscription is still valid (Settings → notifications → re-enable).
|
||||
3. If using ntfy as a secondary channel, confirm `NTFY_URL` and `NTFY_TOPIC` are set.
|
||||
|
||||
### MinIO / garden image uploads failing
|
||||
|
||||
```bash
|
||||
docker compose logs famapp-minio | tail -30
|
||||
```
|
||||
|
||||
- Confirm `MINIO_ENDPOINT` is `http://famapp-minio:9000` in the compose env (not `localhost`).
|
||||
- Confirm the `garden` bucket exists — create it manually via the MinIO console at port `9001` if it is missing.
|
||||
|
||||
### Out of disk space
|
||||
|
||||
```bash
|
||||
df -h /var/lib/docker
|
||||
docker system prune --volumes # removes stopped containers, dangling images, unused volumes
|
||||
```
|
||||
|
||||
Old backup files accumulate in the `backups` volume. The retention script (`deploy/backups/retain.sh`) runs nightly — check its logs if the volume keeps growing.
|
||||
|
||||
## Authentik admin access
|
||||
|
||||
The Authentik admin UI is at `https://auth.yourdomain.com`. Log in with the superuser credentials set during bootstrap — see `deploy/authentik/README.md`.
|
||||
|
||||
To reset the Authentik admin password from the CLI:
|
||||
|
||||
```bash
|
||||
docker compose exec authentik-server ak create_recovery_key 1 akadmin
|
||||
# prints a one-time recovery URL
|
||||
```
|
||||
@@ -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
|
||||
@@ -22,4 +22,8 @@ What this costs us, what it buys us.
|
||||
|
||||
## Index
|
||||
|
||||
- (none yet — add as decisions are made)
|
||||
<!-- AUTO-GENERATED -->
|
||||
|
||||
- [0002 — List realtime uses Postgres NOTIFY and SSE](0002-list-sse-notify.md)
|
||||
- [0003 — Release workflow (commitlint + release-it)](0003-release-workflow.md)
|
||||
<!-- END AUTO-GENERATED -->
|
||||
|
||||
@@ -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.x.y:3000` | Phone on the same WiFi (general UI testing) |
|
||||
| `https://dev.yourdomain.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.x.y`.
|
||||
|
||||
**Step 2 — DNS record**
|
||||
|
||||
Add a `dev.yourdomain.com` A record pointing to the same public IP as `fam.yourdomain.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.yourdomain.com` proxies to the dev machine with a real Let's Encrypt cert.
|
||||
|
||||
## Current Local E2E Procedure
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# React and shadcn Fixes Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix the security-sensitive share-link authorization gap and the highest-confidence shadcn Base UI composition issues from the audit.
|
||||
|
||||
**Architecture:** Add registry-level share authorization callbacks and require them in the generic share service before token creation and shared data loading. Update current modules to provide household/user-scoped authorization and share loaders, then align selected UI call sites with shadcn Base API.
|
||||
|
||||
**Tech Stack:** Next.js 15 App Router, TypeScript, Drizzle, shadcn/ui Base UI, Tailwind v4, Node test runner via `tsx --test`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Share Authorization Helper
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `tests/unit/share-authorization.test.ts`
|
||||
- Create: `src/modules/_core/share-authorization.ts`
|
||||
- Modify: `src/modules/_core/module.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create tests for missing callbacks, denied callbacks, and allowed callbacks.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `pnpm exec tsx --test tests/unit/share-authorization.test.ts`
|
||||
Expected: failure because `share-authorization.ts` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the helper and registry types**
|
||||
|
||||
Add `ShareContext`, `ensureEntityShareAuthorized`, `EntityTypeRegistration.canShareEntity`, and scoped `loadForShare` context typing.
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `pnpm exec tsx --test tests/unit/share-authorization.test.ts`
|
||||
Expected: all tests pass.
|
||||
|
||||
### Task 2: Wire Authorization Into Share Service
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/modules/_core/share.ts`
|
||||
- Modify: `src/app/s/[token]/page.tsx`
|
||||
|
||||
- [ ] **Step 1: Call `ensureEntityShareAuthorized` in `createShareLink`**
|
||||
|
||||
Require the active user and household to be authorized before token insertion.
|
||||
|
||||
- [ ] **Step 2: Pass household context into public share loading**
|
||||
|
||||
Call `entityReg.loadForShare(resolved.entityId, { householdId: resolved.householdId })`.
|
||||
|
||||
- [ ] **Step 3: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: type errors until module adapters are updated.
|
||||
|
||||
### Task 3: Add Module Share Adapters
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/modules/calendar/manifest.tsx`
|
||||
- Modify: `src/modules/calendar/server/share-queries.ts`
|
||||
- Modify: `src/modules/lists/manifest.tsx`
|
||||
- Modify: `src/modules/lists/server/share-queries.ts`
|
||||
- Modify: `src/modules/notes/manifest.tsx`
|
||||
- Modify: `src/modules/notes/server/share-queries.ts`
|
||||
- Modify: `src/modules/garden/manifest.tsx`
|
||||
- Modify: `src/modules/garden/server/share-queries.ts`
|
||||
|
||||
- [ ] **Step 1: Implement `canShareEntity` for every shareable entity**
|
||||
|
||||
Use household scoping and existing private-calendar rules.
|
||||
|
||||
- [ ] **Step 2: Scope `loadForShare` queries by token household**
|
||||
|
||||
Require the public token household to match the entity household.
|
||||
|
||||
- [ ] **Step 3: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: pass.
|
||||
|
||||
### Task 4: Fix Base UI Select Composition
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/modules/calendar/components/calendar-shell.tsx`
|
||||
- Modify: `src/components/theme-picker.tsx`
|
||||
|
||||
- [ ] **Step 1: Add `items` arrays and `SelectGroup` wrappers**
|
||||
|
||||
Keep existing labels and values; do not redesign layout.
|
||||
|
||||
- [ ] **Step 2: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: pass.
|
||||
|
||||
### Task 5: Targeted shadcn Menu and Icon Cleanup
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/components/dashboard-switcher.tsx`
|
||||
- Modify: `src/components/dashboard-editor.tsx`
|
||||
- Modify: `src/components/share-button.tsx`
|
||||
|
||||
- [ ] **Step 1: Wrap dropdown items in `DropdownMenuGroup`**
|
||||
|
||||
Preserve menu behavior.
|
||||
|
||||
- [ ] **Step 2: Replace icon sizing inside shadcn buttons/menu items**
|
||||
|
||||
Use `data-icon` where icons sit in `Button`; remove explicit `size-4 mr-*` from dropdown item icons.
|
||||
|
||||
- [ ] **Step 3: Run lint**
|
||||
|
||||
Run: `pnpm lint`
|
||||
Expected: no new errors; existing warnings may remain.
|
||||
|
||||
### Task 6: Final Verification
|
||||
|
||||
**Files:**
|
||||
|
||||
- No direct edits.
|
||||
|
||||
- [ ] **Step 1: Run targeted test**
|
||||
|
||||
Run: `pnpm exec tsx --test tests/unit/share-authorization.test.ts`
|
||||
Expected: pass.
|
||||
|
||||
- [ ] **Step 2: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: pass.
|
||||
|
||||
- [ ] **Step 3: Run lint**
|
||||
|
||||
Run: `pnpm lint`
|
||||
Expected: exit 0; warnings only if pre-existing.
|
||||
@@ -0,0 +1,35 @@
|
||||
# React and shadcn Fixes Design
|
||||
|
||||
## Goal
|
||||
|
||||
Fix the audit findings that have security or correctness impact, and make a small targeted pass on shadcn Base UI composition without turning this into a broad visual rewrite.
|
||||
|
||||
## Approaches Considered
|
||||
|
||||
1. **Security-first, targeted UI cleanup.** Add a registry authorization hook for share links, implement it for current modules, fix Base `Select` usage, and clean up the compact dropdown/button issues found in the audit.
|
||||
2. **Full shadcn migration sweep.** Replace all `.btn`, raw forms, raw colors, and hand-rolled overlays in one pass. This would touch many dirty files and blur behavior changes with style churn.
|
||||
3. **Security only.** Fix share authorization and defer UI. This leaves known Base UI composition drift in place.
|
||||
|
||||
Chosen approach: **Option 1**. It fixes the exploitable path, handles the highest-confidence shadcn correctness issue, and leaves broad styling convergence to the existing shadcn-tier task docs.
|
||||
|
||||
## Architecture
|
||||
|
||||
Share authorization belongs in the entity registry, not in `_core` switch statements. Each shareable entity registration will expose `canShareEntity(id, ctx)` and the generic `createShareLink` action will require it to return true before inserting a token.
|
||||
|
||||
Public share rendering will also pass the token household into `loadForShare(id, ctx)` so loaders can scope database reads. This prevents a bad token row from loading an unrelated entity by id alone.
|
||||
|
||||
## UI Composition
|
||||
|
||||
The project uses shadcn `base-nova`, so `Select` roots need an `items` prop and `SelectItem` children should be wrapped in `SelectGroup`. The calendar and theme picker selects will be updated to that shape.
|
||||
|
||||
Compact menu/button drift will be cleaned where it does not require redesign: dropdown items should sit in `DropdownMenuGroup`, menu icons should rely on component icon sizing, and button icons should use `data-icon`.
|
||||
|
||||
## Testing
|
||||
|
||||
Add a small Node test around the new share authorization helper first. The test will prove that missing or false registry authorization rejects share creation and that approved entities pass through. Typecheck and lint will cover module hook signatures and Base UI prop usage.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Full replacement of `.btn` and raw garden forms.
|
||||
- Reworking command palette and quick-add sheet into shadcn dialogs.
|
||||
- Measuring bundle deltas or adding dynamic imports for global overlays.
|
||||
@@ -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,40 @@
|
||||
# 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.
|
||||
|
||||
## README
|
||||
|
||||
- [ ] `README.md` reflects the current module list and any env vars added since the last release.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Tier 1: Garden badges → Badge, topbar avatar → Avatar, + fallback, share-green → semantic token
|
||||
|
||||
## Goal
|
||||
|
||||
Replace ad-hoc badge and avatar styling across the app with shadcn/ui component primitives. Standardize color tokens for green success/warning indicators. Create a reusable avatar fallback pattern.
|
||||
|
||||
## Affected files (6)
|
||||
|
||||
### Task 1a: Garden health badges → Badge variant
|
||||
|
||||
**File:** `src/modules/garden/components/plant-detail.tsx`
|
||||
|
||||
- **Current state (line ~44-48):** `healthBadgeClass(status)` returns hand-crafted class names (`badge-success`, `badge-danger`, `badge-warning`) not from shadcn.
|
||||
- **Line 119-123:** Badge rendered as a span with those classes.
|
||||
- **Change:** Replace with `<Badge>` component:
|
||||
- "healthy" → `variant="default"` (primary color)
|
||||
- "sick" → `variant="destructive"`
|
||||
- "sick/other" → `variant="secondary"`
|
||||
- Delete `healthBadgeClass()` function. Remove the span and replace with Badge import + usage.
|
||||
|
||||
### Task 1b: Topbar avatar → `<Avatar>`
|
||||
|
||||
**File:** `src/components/topbar.tsx`
|
||||
|
||||
- **Current state (line ~60-75):** Manual `<span className="avatar">` with inline width/height/background styles, plus conditional img tag or initial text fallback.
|
||||
- **Change:** Replace with shadcn `<Avatar>`:
|
||||
|
||||
```tsx
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
|
||||
<Avatar style={{ width: 28, height: 28 }}>
|
||||
{userRow?.image ? (
|
||||
<AvatarImage src={userRow.image} alt="" />
|
||||
) : (
|
||||
<AvatarFallback>{initial}</AvatarFallback>
|
||||
)}
|
||||
</Avatar>;
|
||||
```
|
||||
|
||||
- Remove the `getUserAvatar` function — already fetches name/email/image. Just pass the image directly.
|
||||
|
||||
### Task 1c: HouseholdPill avatars → Avatar with fallback
|
||||
|
||||
**File:** `src/components/sidebar.tsx`
|
||||
|
||||
- **Current state (line ~106-120):** Manual span-based avatar circles for household members. Uses `avatarColor()` hash function.
|
||||
- **Change:** Replace inner spans with `<Avatar>`:
|
||||
```tsx
|
||||
<Avatar
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
marginLeft: i > 0 ? -6 : 0,
|
||||
boxShadow: "0 0 0 1.5px var(--card)",
|
||||
}}
|
||||
>
|
||||
{m.image ? (
|
||||
<AvatarImage src={m.image} alt={m.name ?? m.email ?? ""} />
|
||||
) : (
|
||||
<AvatarFallback style={{ background: avatarColor(m.id) }}>{initial}</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
```
|
||||
- The `avatarColor()` function stays as-is — it produces the color variable for the fallback background.
|
||||
|
||||
### Task 1d: Create a generic Avatar fallback wrapper (reusable component)
|
||||
|
||||
**File:** `src/components/avatar-fallback.tsx` (new)
|
||||
|
||||
- **Purpose:** A thin wrapper that takes `(name|initial, image?)` and renders the right Avatar/AvatarImage/Fallback pattern with proper fallback initials.
|
||||
- **Export:** `AvatarFallbackWithName` — accepts `{ name?: string; initial?: string; image?: string | null; size?: "sm" | "default" | "lg" }`
|
||||
- Used by both topbar and sidebar avatars, removing code duplication.
|
||||
|
||||
### Task 1e: share-green → semantic token
|
||||
|
||||
**Files affected:**
|
||||
|
||||
1. `src/components/push-opt-in.tsx` line ~104: `text-green-600 dark:text-green-400` → `text-[var(--c-success)] dark:text-[var(--c-success)]`
|
||||
2. `src/modules/garden/components/care-schedule-editor.tsx` line ~185: green borders for "Active" button → use a semantic border class like `border-[var(--c-success)] text-[var(--c-success)]`
|
||||
3. `src/modules/garden/components/plant-widget.tsx` urgency colors: ensure overdue/due-today colors use semantic CSS vars rather than hardcoded greens/red/amber
|
||||
|
||||
**Changes:**
|
||||
|
||||
- In `src/lib/themes.ts` or the global CSS root, add: `--c-success: #16a34a; dark: --c-success: #4ade80;` (or pick from existing palette)
|
||||
- Replace all `text-green-600`/`dark:text-green-400` → `text-[var(--c-success)] dark:text-[var(--c-success)]`
|
||||
- Similarly for border-green-400 → `border-[var(--c-success)]`
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. No hardcoded badge-success/badge-danger/badge-warning class names remain in plant-detail.tsx
|
||||
2. Topbar avatar renders via shadcn Avatar components (verified by DOM inspection)
|
||||
3. HouseholdPill avatars use shadcn Avatar components
|
||||
4. AvatarFallbackWithName component exists and is imported by both topbar.tsx and sidebar.tsx
|
||||
5. All green-600/green-500 references replaced with --c-success semantic variable
|
||||
6. Zero TypeScript errors, zero lint errors
|
||||
|
||||
## Steps
|
||||
|
||||
1. Edit plant-detail.tsx: replace healthBadgeClass usage with <Badge> component
|
||||
2. Create src/components/avatar-fallback.tsx
|
||||
3. Edit topbar.tsx: use Avatar from shadcn + AvatarFallbackWithName
|
||||
4. Edit sidebar.tsx: update HouseholdPill to use Avatar components
|
||||
5. Search for remaining green-600/green-500/border-green references and replace with --c-success
|
||||
6. Run `pnpm lint` and `pnpm typecheck` to verify no errors
|
||||
@@ -0,0 +1,183 @@
|
||||
# Tier 2: Skeleton loading states, settings Tabs navigation, toast notifications via sonner, form FieldGroup audit, manual separators → <Separator>
|
||||
|
||||
## Goal
|
||||
|
||||
Standardize loading UX, navigation, and toast patterns across the app using shadcn components. Audit form field consistency. Replace hand-crafted separator borders with the shadcn Separator component.
|
||||
|
||||
---
|
||||
|
||||
### Task 2a: Skeleton loading states
|
||||
|
||||
**Files affected:**
|
||||
|
||||
1. `src/app/d/[slug]/page.tsx` line ~120 — inline `<Suspense fallback>` with animate-pulse divs (3 lines). Convert to `<Skeleton>` components.
|
||||
2. `src/modules/garden/components/plant-detail.tsx` line 55 — `uploadingImage` state during image upload, no visual feedback other than button text. Add a skeleton overlay or shimmer on the gallery card area.
|
||||
3. `src/modules/garden/components/container-detail.tsx` line 30 — same pattern: `uploadingImage` state without skeleton.
|
||||
4. Any garden page with loading states that use animate-pulse instead of Skeleton.
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. **d/[slug]/page.tsx:** Replace inline fallback divs:
|
||||
```tsx
|
||||
<Suspense fallback={
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
}>
|
||||
```
|
||||
2. **plant-detail.tsx / container-detail.tsx:** When `uploadingImage` is true, wrap the image area with a Skeleton overlay (absolute positioned, full-width). Or add a shimmer effect using the existing skeleton component.
|
||||
|
||||
---
|
||||
|
||||
### Task 2b: Settings Tabs navigation → `<Tabs>`
|
||||
|
||||
**File:** `src/app/settings/page.tsx` + `src/components/settings-section.tsx`
|
||||
|
||||
- **Current state:** Uses a sidebar nav (vertical list with URL hash sync). This is fine for desktop but doesn't use `<Tabs>`.
|
||||
- **What needs to change:** The **garden detail pages** that have manual tab buttons need to use shadcn Tabs, NOT the settings page. The tier says "settings Tabs navigation" — checking the context: plant-detail.tsx line 164 and container-detail.tsx line 139 both have hand-crafted tab buttons with border-bottom active state. These should become `<Tabs>` + `<TabsList>` + `<TabsTrigger>` + `<TabsContent>`.
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. **plant-detail.tsx (lines 162-180):** Replace manual tab buttons:
|
||||
|
||||
```tsx
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="mt-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="info">Info</TabsTrigger>
|
||||
<TabsTrigger value="gallery">Gallery</TabsTrigger>
|
||||
<TabsTrigger value="care">Care</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="info">{/* existing info section */}</TabsContent>
|
||||
<TabsContent value="gallery">{/* existing gallery section */}</TabsContent>
|
||||
<TabsContent value="care">{/* existing care section */}</TabsContent>
|
||||
</Tabs>;
|
||||
```
|
||||
|
||||
2. **container-detail.tsx (lines 138-155):** Same pattern — info/gallery tabs:
|
||||
```tsx
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as Tab)} className="mt-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="info">Info</TabsTrigger>
|
||||
<TabsTrigger value="gallery">Gallery</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="info">{/* ... */}</TabsContent>
|
||||
<TabsContent value="gallery">{/* ... */}</TabsContent>
|
||||
</Tabs>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2c: Toast notifications via sonner
|
||||
|
||||
**Files affected:** Need to find all manual error/success messages and convert to toast calls.
|
||||
|
||||
1. **`src/app/layout.tsx`:** Add `<Toaster />` component to the app layout root so toasts are globally available.
|
||||
2. **Places that need toast conversion (search for inline `setError` / success messages):**
|
||||
- `src/modules/garden/components/plant-detail.tsx` — delete confirmation success/error → `toast.success()` / `toast.error()`
|
||||
- `src/modules/garden/components/container-detail.tsx` — same pattern
|
||||
- `src/modules/garden/components/care-schedule-editor.tsx` line 192 (`calendarSuccess`) and error states → toast
|
||||
- `src/components/share-button.tsx` — "Share link created" dialog could be a toast instead (or keep as dialog but add toast on copy)
|
||||
|
||||
**Changes:**
|
||||
|
||||
```tsx
|
||||
// In any component that needs toasts:
|
||||
import { toast } from "sonner";
|
||||
|
||||
// On success:
|
||||
toast.success("Event added to calendar");
|
||||
|
||||
// On error:
|
||||
toast.error("Failed to create share link");
|
||||
|
||||
// In layout.tsx (client component or client wrapper):
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
return <Toaster position="bottom-right" />;
|
||||
```
|
||||
|
||||
**Acceptance for 2c:**
|
||||
|
||||
- `<Toaster>` is present in the app shell layout
|
||||
- All delete confirmations show toasts on success/error
|
||||
- The share dialog can remain as-is (it's a distinct UX), but copy action adds toast confirmation
|
||||
|
||||
---
|
||||
|
||||
### Task 2d: Form FieldGroup audit
|
||||
|
||||
**Scope:** Audit all form field components across the app. Check for consistency in label-input-error patterns.
|
||||
|
||||
**Files to audit:**
|
||||
|
||||
- `src/modules/garden/components/container-form.tsx` — garden container form fields
|
||||
- `src/modules/garden/components/plant-form.tsx` — garden plant form fields
|
||||
- `src/app/settings/household/*` — household edit forms
|
||||
- Any other server/action-driven forms
|
||||
|
||||
**Audit checklist:**
|
||||
|
||||
1. Do all form inputs have `<Label>` from shadcn? (Not just plain text labels)
|
||||
2. Are error messages rendered consistently below each field?
|
||||
3. Are `Input` components from shadcn used everywhere? (No native `<input>` tags with manual styles)
|
||||
4. Is there a FieldGroup wrapper pattern being used, or is it ad-hoc div nesting?
|
||||
|
||||
**Deliverable:** A list of inconsistencies found and fixes applied. If no major issues, document the findings. Do not introduce a FieldGroup abstraction unless one already exists in the codebase.
|
||||
|
||||
---
|
||||
|
||||
### Task 2e: Manual separators → `<Separator>`
|
||||
|
||||
**Files affected:**
|
||||
|
||||
1. `src/modules/garden/components/plant-detail.tsx` — border-bottom on tabs bar (`border-b border-[var(--ink-faint)]`) → use `<Separator>` instead
|
||||
2. `src/modules/garden/components/container-detail.tsx` — same pattern for info/gallery tab separator
|
||||
3. Any other manual `border-b`, `border-t`, or `border-l` borders used purely as visual separators (not structural layout borders)
|
||||
|
||||
**Changes:**
|
||||
|
||||
```tsx
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
// Replace:
|
||||
<div className="flex gap-6 border-b border-[var(--ink-faint)]">
|
||||
{/* tab buttons */}
|
||||
</div>
|
||||
|
||||
// With:
|
||||
<TabsList>
|
||||
<TabsTrigger value="info">Info</TabsTrigger>
|
||||
<TabsTrigger value="gallery">Gallery</TabsTrigger>
|
||||
</TabsList>
|
||||
<Separator className="my-2" />
|
||||
|
||||
// For the garden tab bar:
|
||||
{tab === "gallery" && (
|
||||
<Separator className="my-4" orientation="horizontal" />
|
||||
)}
|
||||
```
|
||||
|
||||
**Note:** The settings sidebar uses a `.nav-divider` class which is handled by CSS — leave that alone. Only convert inline border divs to Separator components.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. All loading states use `<Skeleton>` (no animate-pulse patterns outside skeleton.tsx)
|
||||
2. Garden detail page tabs use shadcn Tabs/TabsList/TabsTrigger/TabsContent
|
||||
3. Toaster component rendered in app layout, delete/cancel actions show toasts
|
||||
4. Form fields audit complete with findings documented or fixed
|
||||
5. Manual border separators replaced with `<Separator>` where appropriate
|
||||
|
||||
## Steps
|
||||
|
||||
1. Add Skeleton imports and replace inline animation patterns (2a)
|
||||
2. Replace garden detail page tabs with shadcn Tabs (2b)
|
||||
3. Add Toaster to layout, add toast() calls to action handlers (2c)
|
||||
4. Audit form fields for consistency (2d)
|
||||
5. Replace border separators with Separator component (2e)
|
||||
6. Run `pnpm lint` and `pnpm typecheck`
|
||||
@@ -0,0 +1,143 @@
|
||||
# Tier 3: Garden empty-state component, toggle-group for notify channels
|
||||
|
||||
## Goal
|
||||
|
||||
Create a reusable EmptyState component for garden (and potentially other modules), and replace manual checkbox patterns with shadcn ToggleGroup for notification channel selection.
|
||||
|
||||
---
|
||||
|
||||
### Task 3a: Garden empty-state component
|
||||
|
||||
**New file:** `src/modules/garden/components/empty-state.tsx`
|
||||
|
||||
**Purpose:** A shared empty-state component for the garden module — replacing ad-hoc "No plants yet." / "No containers yet." paragraphs scattered across garden list/detail pages.
|
||||
|
||||
**Design (base on shadcn's `<EmptyState>` from `src/components/ui/empty.tsx`):**
|
||||
|
||||
The empty component already exists at `src/components/ui/empty.tsx` — it provides a generic EmptyState wrapper with icon + title + description slots. We should **use it directly**, not create a garden-specific one, unless the garden needs garden-themed defaults.
|
||||
|
||||
**Garden-specific variants needed:**
|
||||
|
||||
- "No plants yet" — icon: leaf/plant, title: "No plants yet", description: "Add your first plant to get started."
|
||||
- "No containers yet" — icon: box/pot, title: "No containers yet", description: "Add a container to group your plants."
|
||||
|
||||
**Files to update:**
|
||||
|
||||
1. `src/modules/garden/components/plant-list.tsx` line 22: `<p className="text-sm text-[var(--ink-mute)]">No plants yet.</p>` → `<EmptyState ... />`
|
||||
2. `src/modules/garden/components/container-list.tsx` line 39: `"No containers yet. Add one to start organising your plants."` → `<EmptyState ... />`
|
||||
3. `src/modules/garden/components/container-detail.tsx` line 169: `<p className="text-sm text-[var(--ink-mute)]">No plants in this container yet.</p>` → inline EmptyState or keep as brief fallback (it's a sub-section, not a full page)
|
||||
|
||||
**Import:**
|
||||
|
||||
```tsx
|
||||
import { EmptyState } from "@/components/ui/empty"
|
||||
import { Sprout, Container } from "lucide-react"
|
||||
|
||||
// In plant-list:
|
||||
<EmptyState
|
||||
icon={Sprout}
|
||||
title="No plants yet"
|
||||
description="Add your first plant to get started."
|
||||
/>
|
||||
|
||||
// In container-list:
|
||||
<EmptyState
|
||||
icon={Container}
|
||||
title="No containers yet"
|
||||
description="Add a container to start organising your plants."
|
||||
/>
|
||||
```
|
||||
|
||||
**Acceptance for 3a:**
|
||||
|
||||
- EmptyState imported from `@/components/ui/empty` in all garden list pages
|
||||
- Zero ad-hoc empty text paragraphs remain in garden component files (check plant-list, container-list, and any other garden page)
|
||||
- Garden empty states are visually consistent (same icon size, title weight, description color)
|
||||
|
||||
---
|
||||
|
||||
### Task 3b: Toggle-group for notify channels
|
||||
|
||||
**File:** `src/components/notify-channel-toggles.tsx`
|
||||
|
||||
**Current state:** Each channel is a native `<input type="checkbox">` wrapped in a label with custom styling. No grouping or visual cohesion.
|
||||
|
||||
**Change to ToggleGroup + ToggleGroupItem:**
|
||||
|
||||
```tsx
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
|
||||
|
||||
export function NotifyChannelToggles({
|
||||
push, inapp, ntfy, ntfyConfigured,
|
||||
}: { ... }) {
|
||||
const channels: { key: Channel; value: boolean; label: string; disabled?: boolean }[] = [
|
||||
{ key: "push", value: push, label: "Web push" },
|
||||
{ key: "inapp", value: inapp, label: "In-app inbox" },
|
||||
{ key: "ntfy", value: ntfy, label: "ntfy", disabled: !ntfyConfigured },
|
||||
];
|
||||
|
||||
const enabledKeys = channels.filter(c => c.value && !c.disabled).map(c => c.key);
|
||||
|
||||
return (
|
||||
<ToggleGroup type="multiple" defaultValue={enabledKeys} onValueChange={(v) => handleToggle(v)}>
|
||||
{channels.map(({ key, value, label, disabled }) => (
|
||||
<ToggleGroupItem
|
||||
key={key}
|
||||
value={key}
|
||||
disabled={disabled || !("PushManager" in window) && key === "push"}
|
||||
aria-label={label}
|
||||
>
|
||||
{label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** The ToggleGroup needs to integrate with the existing server action flow (`setNotifChannel`). The `onValueChange` handler should:
|
||||
|
||||
1. Detect which channels were added/removed from the selection
|
||||
2. Call `setNotifChannel(channel, enabled)` for each change
|
||||
3. Still trigger an optimistic UI update
|
||||
|
||||
**Alternative approach** (safer — use individual ToggleGroupItems as independent switches): Since these are truly independent toggles (enabling push doesn't require disabling inapp), we keep the current per-channel rendering but swap `<input type="checkbox">` to shadcn's `<Switch>` component from `@/components/ui/switch`:
|
||||
|
||||
```tsx
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
// Each channel:
|
||||
<label className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm">{label}</span>
|
||||
<Switch
|
||||
checked={value}
|
||||
disabled={disabled || isPending}
|
||||
onCheckedChange={(v) => toggle(key, v)}
|
||||
/>
|
||||
</label>;
|
||||
```
|
||||
|
||||
**Decision:** The `<Switch>` approach is more appropriate here. ToggleGroup is for mutually-exclusive selection; Switch is for independent on/off toggles — which matches the notification channels use case perfectly. **Use `Switch` from shadcn, not ToggleGroup.**
|
||||
|
||||
**Acceptance for 3b:**
|
||||
|
||||
- Each channel uses shadcn `<Switch>` component (not native checkbox)
|
||||
- Visual appearance matches the app's design system
|
||||
- Server action integration works correctly (toggle enables/disables on the server)
|
||||
- Disabled state handled properly for ntfy when unconfigured
|
||||
|
||||
---
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. EmptyState component from `@/components/ui/empty` used in all garden list pages
|
||||
2. All ad-hoc "No plants yet." / "No containers yet." text replaced with EmptyState or kept as contextual inline text where appropriate
|
||||
3. Notification channels use shadcn `<Switch>` components
|
||||
4. Visual consistency across garden empty states
|
||||
|
||||
## Steps
|
||||
|
||||
1. Use existing `src/components/ui/empty.tsx` EmptyState in plant-list.tsx and container-list.tsx
|
||||
2. Replace Switch component imports in notify-channel-toggles.tsx
|
||||
3. Verify server action integration for toggles
|
||||
4. Run `pnpm lint` and `pnpm typecheck`
|
||||
@@ -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,114 @@
|
||||
# 76 — Shadcn component implementation (Tiers 1–3)
|
||||
|
||||
## Goal
|
||||
|
||||
Implement the three shadcn component adoption tiers that replace ad-hoc UI patterns across the app with shadcn/ui primitives. This is the **execution** phase — components are already installed (see `2026-06-13-shadcn-component-install.md`).
|
||||
|
||||
| Tier | Name | Tasks | Files touched |
|
||||
| ---- | ------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| 1 | Garden badges → Badge, topbar avatar → Avatar, + fallback, semantic token | 5 sub-tasks | topbar.tsx, sidebar.tsx, plant-detail.tsx, push-opt-in.tsx, care-schedule-editor.tsx, plant-widget.tsx |
|
||||
| 2 | Skeleton loading, Tabs nav, Sonner toasts, FieldGroup audit, Separator | 5 sub-tasks | d/[slug]/page.tsx, plant-detail.tsx, container-detail.tsx, layout.tsx, notify-channel-toggles.tsx |
|
||||
| 3 | Garden empty-state, Switch for notify channels | 2 sub-tasks | plant-list.tsx, container-list.tsx, notify-channel-toggles.tsx |
|
||||
|
||||
## Depends on
|
||||
|
||||
- `2026-06-13-shadcn-component-install.md` — components are already in `src/components/ui/`
|
||||
- The shadcn migration map at `2026-06-13-shadcn-migration.md`
|
||||
|
||||
## Scope by tier
|
||||
|
||||
### Tier 1 (detailed brief: `docs/tasks/1a-badges-avatars.md`)
|
||||
|
||||
**Task 1a — Garden health badges → `<Badge>`**
|
||||
|
||||
- File: `src/modules/garden/components/plant-detail.tsx` lines 44-48, 119-123
|
||||
- Replace hand-crafted `badge-success/danger/warning` classes with shadcn `<Badge variant="default|destructive|secondary">`
|
||||
- Delete the `healthBadgeClass()` helper function
|
||||
|
||||
**Task 1b — Topbar avatar → `<Avatar>`**
|
||||
|
||||
- File: `src/components/topbar.tsx` lines 60-75
|
||||
- Replace manual `<span className="avatar">` with `<Avatar><AvatarImage/><AvatarFallback/></Avatar>`
|
||||
|
||||
**Task 1c — HouseholdPill avatars → `<Avatar>`**
|
||||
|
||||
- File: `src/components/sidebar.tsx` lines 106-120
|
||||
- Replace inner span elements with Avatar components (keep avatarColor hash for fallback background)
|
||||
|
||||
**Task 1d — Generic AvatarFallbackWithName component**
|
||||
|
||||
- New file: `src/components/avatar-fallback.tsx`
|
||||
- Thin wrapper that handles the Image/Fallback pattern with name/initial logic
|
||||
- Imported by both topbar.tsx and sidebar.tsx to remove duplication
|
||||
|
||||
**Task 1e — share-green → semantic token**
|
||||
|
||||
- Files: push-opt-in.tsx (line ~104), care-schedule-editor.tsx (line ~185), plant-widget.tsx urgencyLabel, plus any other green-600/green-500 in garden components
|
||||
- Add `--c-success` CSS variable to the global theme
|
||||
- Replace all hardcoded green-600 references with `var(--c-success)`
|
||||
|
||||
### Tier 2 (detailed brief: `docs/tasks/1b-skeleton-tabs-toasts.md`)
|
||||
|
||||
**Task 2a — Skeleton loading states**
|
||||
|
||||
- d/[slug]/page.tsx inline animate-pulse → `<Skeleton>` components
|
||||
- plant-detail.tsx & container-detail.tsx uploadingImage → skeleton overlay
|
||||
- Any other animate-pulse patterns outside skeleton.tsx itself
|
||||
|
||||
**Task 2b — Settings/garden Tabs navigation → `<Tabs>`**
|
||||
|
||||
- File: `src/modules/garden/components/plant-detail.tsx` lines 162-180
|
||||
- File: `src/modules/garden/components/container-detail.tsx` lines 138-155
|
||||
- Replace manual button-based tabs with shadcn Tabs/TabsList/TabsTrigger/TabsContent
|
||||
|
||||
**Task 2c — Toast notifications via sonner**
|
||||
|
||||
- Add `<Toaster>` to app layout (`src/app/layout.tsx`)
|
||||
- Convert delete confirmations, calendar schedule success/error, and share-link actions to `toast.success()`/`toast.error()` calls
|
||||
|
||||
**Task 2d — Form FieldGroup audit**
|
||||
|
||||
- Audit: container-form.tsx, plant-form.tsx, household edit forms
|
||||
- Check consistency of Label + Input + error pattern
|
||||
- Fix any non-shadcn form fields found
|
||||
|
||||
**Task 2e — Manual separators → `<Separator>`**
|
||||
|
||||
- Remove border-bottom on garden detail tab bars, replace with shadcn Separator (usually alongside the Tabs component)
|
||||
|
||||
### Tier 3 (detailed brief: `docs/tasks/1c-emptystate-switch.md`)
|
||||
|
||||
**Task 3a — Garden empty-state component**
|
||||
|
||||
- File: `src/modules/garden/components/plant-list.tsx` line 22 → use EmptyState from ui/empty.tsx
|
||||
- File: `src/modules/garden/components/container-list.tsx` line 39 → use EmptyState
|
||||
- Use appropriate icons (Sprout for plants, Container for containers)
|
||||
|
||||
**Task 3b — Notify channel toggles → `<Switch>`**
|
||||
|
||||
- File: `src/components/notify-channel-toggles.tsx`
|
||||
- Replace native `<input type="checkbox">` with shadcn `<Switch>` component
|
||||
- Keep server action integration (setNotifChannel)
|
||||
- Disabled state for ntfy when unconfigured
|
||||
|
||||
## Acceptance criteria (all tiers)
|
||||
|
||||
1. Zero `badge-success`, `badge-danger`, `badge-warning` class names remain in garden code
|
||||
2. All avatars rendered via shadcn Avatar components (verified by DOM/JSX inspection)
|
||||
3. All loading states use `<Skeleton>` — no animate-pulse patterns outside skeleton.tsx
|
||||
4. Garden detail pages use shadcn Tabs component (not manual buttons)
|
||||
5. Toaster rendered in app layout, delete/cancel actions show toasts
|
||||
6. EmptyState used in all garden list pages (no ad-hoc empty text paragraphs)
|
||||
7. Notification channels use shadcn Switch components
|
||||
8. `pnpm lint` passes with zero errors/warnings
|
||||
9. `pnpm typecheck` passes with zero errors
|
||||
|
||||
## Execution order
|
||||
|
||||
Execute tiers sequentially: 1 → 2 → 3. Each tier builds on the previous one's patterns and fixes foundational issues before adding polish.
|
||||
|
||||
Detailed task briefs are in:
|
||||
|
||||
- Tier 1: `docs/tasks/1a-badges-avatars.md`
|
||||
- Tier 2: `docs/tasks/1b-skeleton-tabs-toasts.md`
|
||||
- Tier 3: `docs/tasks/1c-emptystate-switch.md`
|
||||
@@ -33,6 +33,7 @@ Every task file has these sections:
|
||||
- [06 — Authentik install + OIDC integration](06-authentik-oidc.md)
|
||||
- [07 — Household seeding & session](07-household-seed.md)
|
||||
- [08 — Theming infrastructure (multi-theme + dark mode)](08-theming.md)
|
||||
- [09 — Pre-deploy checklist (recurring)](09-pre-deploy-checklist.md)
|
||||
|
||||
### Phase 2 — Core modules
|
||||
|
||||
@@ -69,3 +70,12 @@ Every task file has these sections:
|
||||
- [60 — Postgres backups (pg_dump cron)](60-backups.md)
|
||||
- [61 — Rate limiting on share links](61-rate-limit.md)
|
||||
- [62 — Structured logging](62-logging.md)
|
||||
|
||||
### Phase 8 — Garden module
|
||||
|
||||
- [70 — Garden infrastructure (MinIO + upload route + schema)](70-garden-infrastructure.md)
|
||||
- [71 — Garden containers](71-garden-containers.md)
|
||||
- [72 — Garden plants](72-garden-plants.md)
|
||||
- [73 — Garden care tracking](73-garden-care-tracking.md)
|
||||
- [74 — Garden integrations](74-garden-integrations.md)
|
||||
- [75 — Garden dashboard](75-garden-dashboard.md)
|
||||
|
||||
@@ -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,13 @@
|
||||
CREATE TABLE "bang_events" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"household_id" uuid NOT NULL,
|
||||
"recorded_by" uuid,
|
||||
"occurred_on" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "bang_events" ADD CONSTRAINT "bang_events_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "bang_events" ADD CONSTRAINT "bang_events_recorded_by_users_id_fk" FOREIGN KEY ("recorded_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "bang_events_household_occurred_idx" ON "bang_events" USING btree ("household_id","occurred_on");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "garden_containers" ADD COLUMN "images" jsonb DEFAULT '[]'::jsonb NOT NULL;
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1780391596552,
|
||||
"tag": "0017_bang_counter",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1748995200000,
|
||||
"tag": "0018_container_images",
|
||||
"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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
{
|
||||
"name": "famapp",
|
||||
"version": "0.0.0",
|
||||
"version": "0.5.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.3",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=24"
|
||||
},
|
||||
"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 .",
|
||||
@@ -19,48 +22,89 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"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:seed": "node --env-file=.env scripts/seed.mjs",
|
||||
"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": {
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@commitlint/cli": "^21.0.2",
|
||||
"@commitlint/config-conventional": "^21.0.2",
|
||||
"@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/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@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",
|
||||
"sharp": "^0.34.5",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.15.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/core": "0.41.2",
|
||||
"@auth/drizzle-adapter": "^1.11.2",
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/list": "^6.1.20",
|
||||
"@fullcalendar/react": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"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",
|
||||
"next-themes": "^0.4.6",
|
||||
"pino": "^10.3.1",
|
||||
"postgres": "^3.4.9",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-grid-layout": "^2.2.3",
|
||||
"react-resizable": "^3.1.3",
|
||||
"server-only": "^0.0.1",
|
||||
"shadcn": "^4.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3",
|
||||
"zod-to-json-schema": "^3.25.2"
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<style>
|
||||
@media (prefers-color-scheme: dark) { .bg { fill: #6366F1; } }
|
||||
</style>
|
||||
<rect class="bg" width="512" height="512" rx="96" fill="#4338CA"/>
|
||||
<rect x="130" y="238" width="252" height="190" fill="white"/>
|
||||
<polygon points="256,82 68,254 444,254" fill="white"/>
|
||||
<rect x="216" y="338" width="80" height="90" rx="8" fill="#4338CA"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 444 B |
|
After Width: | Height: | Size: 335 B |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 546 B |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" rx="96" fill="#4338CA"/>
|
||||
<rect x="130" y="238" width="252" height="190" fill="white"/>
|
||||
<polygon points="256,82 68,254 444,254" fill="white"/>
|
||||
<rect x="216" y="338" width="80" height="90" rx="8" fill="#4338CA"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 345 B |
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "famapp",
|
||||
"short_name": "famapp",
|
||||
"description": "Family coordination app",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#4338CA",
|
||||
"theme_color": "#4338CA",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"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();
|
||||
@@ -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.<your-domain> (push/PWA — needs Caddyfile.dev.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,48 @@
|
||||
/**
|
||||
* Rasterizes public/icon.svg into all required PNG icon sizes.
|
||||
*
|
||||
* Usage: node scripts/generate-icons.mjs
|
||||
*
|
||||
* Produces:
|
||||
* public/icon-16.png
|
||||
* public/icon-32.png
|
||||
* public/icon-180.png (apple-touch-icon)
|
||||
* public/icon-192.png
|
||||
* public/icon-384.png
|
||||
* public/icon-512.png
|
||||
* public/icon-512-maskable.png (square bg, content in safe zone)
|
||||
*/
|
||||
|
||||
import sharp from "sharp";
|
||||
import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const pub = join(__dirname, "..", "public");
|
||||
|
||||
const svg = readFileSync(join(pub, "icon.svg"));
|
||||
|
||||
const sizes = [
|
||||
{ name: "icon-16.png", size: 16 },
|
||||
{ name: "icon-32.png", size: 32 },
|
||||
{ name: "icon-180.png", size: 180 },
|
||||
{ name: "icon-192.png", size: 192 },
|
||||
{ name: "icon-384.png", size: 384 },
|
||||
{ name: "icon-512.png", size: 512 },
|
||||
];
|
||||
|
||||
for (const { name, size } of sizes) {
|
||||
await sharp(svg).resize(size, size).png().toFile(join(pub, name));
|
||||
console.log(` ✓ public/${name} (${size}×${size})`);
|
||||
}
|
||||
|
||||
// Maskable: remove rounded corners so the background fills the full canvas,
|
||||
// allowing the OS to apply any mask shape without clipping the icon corners.
|
||||
const maskableSvg = readFileSync(join(pub, "icon.svg"), "utf-8").replace('rx="96"', "");
|
||||
|
||||
await sharp(Buffer.from(maskableSvg))
|
||||
.resize(512, 512)
|
||||
.png()
|
||||
.toFile(join(pub, "icon-512-maskable.png"));
|
||||
console.log(` ✓ public/icon-512-maskable.png (512×512 maskable)`);
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import postgres from "postgres";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { households } from "@/modules/_core/schema";
|
||||
import { ensureDefaultCalendars } from "@/modules/calendar/server/defaults";
|
||||
import { ensureDefaultLists } from "@/modules/lists/server/defaults";
|
||||
|
||||
const client = postgres(process.env["DATABASE_URL"]!);
|
||||
const db = drizzle(client);
|
||||
|
||||
async function seed() {
|
||||
const [existing] = await db.select().from(households).limit(1);
|
||||
if (existing) {
|
||||
console.log(`Household already exists ("${existing.name}"), skipping.`);
|
||||
} else {
|
||||
await db.insert(households).values({ name: "Home" });
|
||||
console.log('Seeded household "Home".');
|
||||
}
|
||||
|
||||
await ensureDefaultCalendars();
|
||||
console.log("Ensured default calendars.");
|
||||
await ensureDefaultLists();
|
||||
console.log("Ensured default lists.");
|
||||
await client.end();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
seed().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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();
|
||||