Implement lists module and dev login setup
This commit is contained in:
@@ -7,6 +7,13 @@ DATABASE_URL=postgres://famapp:famapp@localhost:5432/famapp
|
|||||||
# Auth.js
|
# Auth.js
|
||||||
AUTH_SECRET=replace-with-openssl-rand-base64-32
|
AUTH_SECRET=replace-with-openssl-rand-base64-32
|
||||||
|
|
||||||
|
# Local development only. Enables a /login "Dev login" button that creates a
|
||||||
|
# database session for DEV_LOGIN_EMAIL without contacting Authentik.
|
||||||
|
ENABLE_DEV_LOGIN=false
|
||||||
|
DEV_LOGIN_EMAIL=dev@famapp.local
|
||||||
|
DEV_LOGIN_NAME=Dev User
|
||||||
|
DEV_HOUSEHOLD_NAME=Home
|
||||||
|
|
||||||
# OIDC (Authentik)
|
# OIDC (Authentik)
|
||||||
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
|
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
|
||||||
AUTH_OIDC_CLIENT_ID=replace-me
|
AUTH_OIDC_CLIENT_ID=replace-me
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ desktop.ini
|
|||||||
coverage/
|
coverage/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
test-results/
|
test-results/
|
||||||
|
tests/.auth/
|
||||||
|
|
||||||
# Drizzle generated artifacts (migrations themselves are committed)
|
# Drizzle generated artifacts (migrations themselves are committed)
|
||||||
drizzle/meta/
|
drizzle/meta/
|
||||||
|
|||||||
@@ -17,10 +17,16 @@ Living progress tracker. Update at the end of each task. The canonical brief is
|
|||||||
- **08 — Theming infrastructure**. CSS-variable multi-theme system (`default` + `warm`) × `{light, dark, system}`. `users.theme` + `users.themeMode` columns in migration `0002_naive_starbolt.sql`. Theme registry in `src/modules/_core/themes.ts` (THEMES/THEME_MODES arrays — adding a third theme is one CSS block + one registry entry). Root layout reads session and sets `data-theme`/`dark` on `<html>` server-side; inline pre-paint `<script>` covers system mode and signed-out pages (no flash). `useTheme()` hook optimistically flips attributes, writes `localStorage`, and calls `setUserTheme` server action. `<ThemePicker />` component mounts on `/settings` (select for theme, segmented buttons for mode). `tsc --noEmit`, `pnpm lint`, `pnpm build` all clean.
|
- **08 — Theming infrastructure**. CSS-variable multi-theme system (`default` + `warm`) × `{light, dark, system}`. `users.theme` + `users.themeMode` columns in migration `0002_naive_starbolt.sql`. Theme registry in `src/modules/_core/themes.ts` (THEMES/THEME_MODES arrays — adding a third theme is one CSS block + one registry entry). Root layout reads session and sets `data-theme`/`dark` on `<html>` server-side; inline pre-paint `<script>` covers system mode and signed-out pages (no flash). `useTheme()` hook optimistically flips attributes, writes `localStorage`, and calls `setUserTheme` server action. `<ThemePicker />` component mounts on `/settings` (select for theme, segmented buttons for mode). `tsc --noEmit`, `pnpm lint`, `pnpm build` all clean.
|
||||||
|
|
||||||
- **10 — Calendar module**. Added `calendars` and `calendar_events` schema + migration `0003_rainy_ravenous.sql`, default Home/Personal calendar seeding, first-login default calendar creation, visibility-safe calendar/event queries, CRUD server actions, FullCalendar-backed `/calendar` UI with sidebar calendar management and event create/edit/delete/drag updates. Calendar manifest now registers share/reminder/search capabilities, two configurable widgets, and quick-add entries. Added Playwright happy-path spec in `tests/e2e/calendar.spec.ts`. `pnpm db:generate`, `pnpm typecheck`, `pnpm lint`, and `pnpm build` pass.
|
- **10 — Calendar module**. Added `calendars` and `calendar_events` schema + migration `0003_rainy_ravenous.sql`, default Home/Personal calendar seeding, first-login default calendar creation, visibility-safe calendar/event queries, CRUD server actions, FullCalendar-backed `/calendar` UI with sidebar calendar management and event create/edit/delete/drag updates. Calendar manifest now registers share/reminder/search capabilities, two configurable widgets, and quick-add entries. Added Playwright happy-path spec in `tests/e2e/calendar.spec.ts`. `pnpm db:generate`, `pnpm typecheck`, `pnpm lint`, and `pnpm build` pass.
|
||||||
|
- **11 — Lists module**. Added `lists` and `list_items` schema + migration `0004_opposite_wraith.sql`, default Shopping/Tasks seeding on first access/sign-in/seed, household-gated list and item CRUD server actions, reorder support, and Postgres `LISTEN/NOTIFY` to SSE bridge documented in ADR `0002`. Added `/lists` grouped index, `/lists/[id]` keyboard-first item entry with checkbox toggles and swipe/delete, manifest entity/search/widget/quick-add registrations, and Playwright happy-path spec in `tests/e2e/lists.spec.ts`. `pnpm typecheck`, `pnpm lint`, and `pnpm build` pass.
|
||||||
|
|
||||||
## Next up
|
## Next up
|
||||||
|
|
||||||
- **11 — Lists module** (or next task in `docs/tasks/`).
|
- **12 — Notes module** (or next task in `docs/tasks/`).
|
||||||
|
|
||||||
|
## Development login/testing notes
|
||||||
|
|
||||||
|
- Local development can use the documented Dev login flow in `docs/dev-login.md`. It creates a database-backed Auth.js session for `DEV_LOGIN_EMAIL` when `ENABLE_DEV_LOGIN=true` and `NODE_ENV !== "production"`.
|
||||||
|
- The production cleanup gate is tracked in `docs/tasks/09-production-dev-login-removal.md`. Complete it before first production deployment.
|
||||||
|
|
||||||
## Phase 1 remaining
|
## Phase 1 remaining
|
||||||
|
|
||||||
@@ -35,7 +41,7 @@ Living progress tracker. Update at the end of each task. The canonical brief is
|
|||||||
## How to resume in a fresh session
|
## How to resume in a fresh session
|
||||||
|
|
||||||
1. Open the repo root in VS Code.
|
1. Open the repo root in VS Code.
|
||||||
2. Tell Claude: *"Read [CLAUDE.md](CLAUDE.md) and [STATUS.md](STATUS.md), then complete [docs/tasks/03-drizzle-postgres.md](docs/tasks/03-drizzle-postgres.md). Stop at the acceptance criteria."*
|
2. Tell Claude: _"Read [CLAUDE.md](CLAUDE.md) and [STATUS.md](STATUS.md), then complete [docs/tasks/03-drizzle-postgres.md](docs/tasks/03-drizzle-postgres.md). Stop at the acceptance criteria."_
|
||||||
3. After it lands, append the result to the **Done** section here, bump **Next up**, and commit.
|
3. After it lands, append the result to the **Done** section here, bump **Next up**, and commit.
|
||||||
|
|
||||||
## Environment notes
|
## Environment notes
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 0002 - List Realtime Uses Postgres Notify And SSE
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The lists module needs updates from another signed-in household member to appear without a refresh. The app already plans to use Postgres `LISTEN/NOTIFY` bridged to Server-Sent Events for small-scale realtime.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Each list has a Postgres notification channel named `list:<listId>`. List mutations call `pg_notify` after the database write. The route handler at `/api/lists/[id]/events` verifies the current household can access the list, listens on that one channel, and streams notifications as SSE messages.
|
||||||
|
|
||||||
|
Clients keep one `EventSource` open for the current list. On any message, they reload the list through the existing household-gated query path.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
This keeps realtime scoped to the module and avoids a separate WebSocket service. The client refreshes the whole list after a notification, which is simple and acceptable for household-sized lists. If future modules need the same pattern, they can use their own entity-scoped channels and SSE route handlers without changing core registries.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Dev Login And Local Test Setup
|
||||||
|
|
||||||
|
This note tracks the development-only work added to make the app easy to run and test without a live Authentik/OIDC setup.
|
||||||
|
|
||||||
|
## What Was Added
|
||||||
|
|
||||||
|
- `.env` was created locally with:
|
||||||
|
- `NEXT_PUBLIC_APP_URL=http://127.0.0.1:3000`
|
||||||
|
- `DATABASE_URL=postgres://famapp:famapp@localhost:5432/famapp`
|
||||||
|
- `ENABLE_DEV_LOGIN=true`
|
||||||
|
- placeholder OIDC values for local-only development
|
||||||
|
- `.env.example` now documents the dev-login variables:
|
||||||
|
- `ENABLE_DEV_LOGIN`
|
||||||
|
- `DEV_LOGIN_EMAIL`
|
||||||
|
- `DEV_LOGIN_NAME`
|
||||||
|
- `DEV_HOUSEHOLD_NAME`
|
||||||
|
- `src/lib/dev-login-config.ts` contains Edge-safe dev-login constants and feature-flag detection.
|
||||||
|
- `src/lib/dev-login.ts` creates a database-backed Auth.js session for a local dev user.
|
||||||
|
- `src/app/login/page.tsx` shows a **Dev login** button only when:
|
||||||
|
- `NODE_ENV !== "production"`
|
||||||
|
- `ENABLE_DEV_LOGIN=true`
|
||||||
|
- `src/middleware.ts` was changed to an Edge-safe cookie gate. It no longer imports Auth.js/Drizzle/Postgres into middleware.
|
||||||
|
- `tests/.auth/` is ignored so generated Playwright session state is never committed.
|
||||||
|
- Playwright Chromium was installed locally.
|
||||||
|
- `tests/.auth/dev-user.json` was generated locally from the Dev login flow.
|
||||||
|
|
||||||
|
## Related Fixes Found While Enabling Testing
|
||||||
|
|
||||||
|
- `drizzle/0005_auth_schema_repair.sql` was added to repair local databases that missed the Auth.js schema migration.
|
||||||
|
- `scripts/seed.ts` now exits cleanly after seeding because imported module default helpers use the shared app database client.
|
||||||
|
- `src/modules/calendar/server/actions.ts` no longer calls `.partial()` on a refined Zod schema.
|
||||||
|
- Calendar/list E2E locators were tightened so the suite runs against the current UI.
|
||||||
|
- Calendar/list create buttons no longer depend on `useTransition` pending state for basic enablement.
|
||||||
|
|
||||||
|
## Current Local Run Procedure
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose -f docker-compose.dev.yaml up -d
|
||||||
|
pnpm db:migrate
|
||||||
|
pnpm db:seed
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:3000/login
|
||||||
|
```
|
||||||
|
|
||||||
|
Click **Dev login**.
|
||||||
|
|
||||||
|
## Current Local E2E Procedure
|
||||||
|
|
||||||
|
Generate auth state after starting the app:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
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:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:PLAYWRIGHT_STORAGE_STATE='tests/.auth/dev-user.json'
|
||||||
|
pnpm test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Removal Plan
|
||||||
|
|
||||||
|
Before production deployment, complete the checklist below.
|
||||||
|
|
||||||
|
- Set `ENABLE_DEV_LOGIN=false` in production secrets.
|
||||||
|
- Do not copy local `.env` to production.
|
||||||
|
- Confirm `deploy/compose.yaml` or production env files do not define:
|
||||||
|
- `ENABLE_DEV_LOGIN=true`
|
||||||
|
- `DEV_LOGIN_EMAIL`
|
||||||
|
- `DEV_LOGIN_NAME`
|
||||||
|
- `DEV_HOUSEHOLD_NAME`
|
||||||
|
- Verify `/login` does not render **Dev login** when built with production env.
|
||||||
|
- Verify direct POSTs to the dev login action fail because `createDevSession()` checks `NODE_ENV !== "production"` and `ENABLE_DEV_LOGIN=true`.
|
||||||
|
- Delete any dev users from the production database if they were accidentally created:
|
||||||
|
- `dev@famapp.local`
|
||||||
|
- any configured `DEV_LOGIN_EMAIL`
|
||||||
|
- Keep `tests/.auth/` ignored and out of production artifacts.
|
||||||
|
- Replace placeholder OIDC variables with real Authentik values:
|
||||||
|
- `AUTH_OIDC_ISSUER`
|
||||||
|
- `AUTH_OIDC_CLIENT_ID`
|
||||||
|
- `AUTH_OIDC_CLIENT_SECRET`
|
||||||
|
- Confirm Authentik login succeeds against the production domain.
|
||||||
|
- Run `pnpm build` with production-like env before shipping.
|
||||||
|
|
||||||
|
## Decision For Now
|
||||||
|
|
||||||
|
Keep the dev-login code in the repo while active module development is ongoing. It is explicitly gated by environment and avoids requiring Authentik for every local UI/E2E loop.
|
||||||
|
|
||||||
|
Before first real production deployment, decide whether to:
|
||||||
|
|
||||||
|
- remove the dev-login code entirely, or
|
||||||
|
- keep it behind the existing production-safe gates for future local development.
|
||||||
|
|
||||||
|
Removing it entirely is stricter. Keeping it gated is more convenient. The production blocker is not the presence of the code; it is any production environment that enables it.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
CREATE TABLE "list_items" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"list_id" uuid NOT NULL,
|
||||||
|
"text" text NOT NULL,
|
||||||
|
"done" boolean DEFAULT false NOT NULL,
|
||||||
|
"qty" text,
|
||||||
|
"notes" text,
|
||||||
|
"due_at" timestamp with time zone,
|
||||||
|
"assignee_id" uuid,
|
||||||
|
"position" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "lists" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"household_id" uuid NOT NULL,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"archived" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "list_items" ADD CONSTRAINT "list_items_list_id_lists_id_fk" FOREIGN KEY ("list_id") REFERENCES "public"."lists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "list_items" ADD CONSTRAINT "list_items_assignee_id_users_id_fk" FOREIGN KEY ("assignee_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "lists" ADD CONSTRAINT "lists_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "list_items_list_position_idx" ON "list_items" USING btree ("list_id","position");--> statement-breakpoint
|
||||||
|
CREATE INDEX "list_items_assignee_idx" ON "list_items" USING btree ("assignee_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "lists_household_type_idx" ON "lists" USING btree ("household_id","type");--> statement-breakpoint
|
||||||
|
CREATE INDEX "lists_household_archived_idx" ON "lists" USING btree ("household_id","archived");
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'display_name'
|
||||||
|
) AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'name'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "users" RENAME COLUMN "display_name" TO "name";
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ALTER COLUMN "name" TYPE text USING "name"::text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ALTER COLUMN "name" DROP NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "email_verified" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "image" text;--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "accounts" (
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"type" text NOT NULL,
|
||||||
|
"provider" text NOT NULL,
|
||||||
|
"provider_account_id" text NOT NULL,
|
||||||
|
"refresh_token" text,
|
||||||
|
"access_token" text,
|
||||||
|
"expires_at" integer,
|
||||||
|
"token_type" text,
|
||||||
|
"scope" text,
|
||||||
|
"id_token" text,
|
||||||
|
"session_state" text,
|
||||||
|
CONSTRAINT "accounts_provider_provider_account_id_pk" PRIMARY KEY("provider","provider_account_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "sessions" (
|
||||||
|
"session_token" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"expires" timestamp with time zone NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "verification_tokens" (
|
||||||
|
"identifier" text NOT NULL,
|
||||||
|
"token" text NOT NULL,
|
||||||
|
"expires" timestamp with time zone NOT NULL,
|
||||||
|
CONSTRAINT "verification_tokens_identifier_token_pk" PRIMARY KEY("identifier","token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
@@ -2,6 +2,7 @@ import postgres from "postgres";
|
|||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import { households } from "@/modules/_core/schema";
|
import { households } from "@/modules/_core/schema";
|
||||||
import { ensureDefaultCalendars } from "@/modules/calendar/server/defaults";
|
import { ensureDefaultCalendars } from "@/modules/calendar/server/defaults";
|
||||||
|
import { ensureDefaultLists } from "@/modules/lists/server/defaults";
|
||||||
|
|
||||||
const client = postgres(process.env["DATABASE_URL"]!);
|
const client = postgres(process.env["DATABASE_URL"]!);
|
||||||
const db = drizzle(client);
|
const db = drizzle(client);
|
||||||
@@ -17,7 +18,10 @@ async function seed() {
|
|||||||
|
|
||||||
await ensureDefaultCalendars();
|
await ensureDefaultCalendars();
|
||||||
console.log("Ensured default calendars.");
|
console.log("Ensured default calendars.");
|
||||||
|
await ensureDefaultLists();
|
||||||
|
console.log("Ensured default lists.");
|
||||||
await client.end();
|
await client.end();
|
||||||
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
seed().catch((err) => {
|
seed().catch((err) => {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import postgres from "postgres";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { canAccessList } from "@/modules/lists/server/queries";
|
||||||
|
import { listChannel } from "@/modules/lists/server/realtime";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
if (!(await canAccessList(id, household.id))) return new Response("Forbidden", { status: 403 });
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const sql = postgres(process.env["DATABASE_URL"]!, { max: 1 });
|
||||||
|
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let listener: { unlisten(): Promise<void> } | undefined;
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
if (heartbeat) clearInterval(heartbeat);
|
||||||
|
await listener?.unlisten().catch(() => undefined);
|
||||||
|
await sql.end({ timeout: 1 }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
async start(controller) {
|
||||||
|
controller.enqueue(encoder.encode(": connected\n\n"));
|
||||||
|
|
||||||
|
listener = await sql.listen(listChannel(id), (payload) => {
|
||||||
|
if (closed) return;
|
||||||
|
controller.enqueue(encoder.encode(`data: ${payload}\n\n`));
|
||||||
|
});
|
||||||
|
|
||||||
|
heartbeat = setInterval(() => {
|
||||||
|
if (closed) return;
|
||||||
|
controller.enqueue(encoder.encode(": heartbeat\n\n"));
|
||||||
|
}, 25_000);
|
||||||
|
|
||||||
|
request.signal.addEventListener("abort", () => {
|
||||||
|
void cleanup();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
return cleanup();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { ListDetail } from "@/modules/lists/components/list-detail";
|
||||||
|
import { getList } from "@/modules/lists/server/queries";
|
||||||
|
|
||||||
|
export default async function ListPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const list = await getList(id).catch(() => null);
|
||||||
|
if (!list) notFound();
|
||||||
|
|
||||||
|
return <ListDetail initialList={list} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { ListsIndex } from "@/modules/lists/components/lists-index";
|
||||||
|
import { listLists } from "@/modules/lists/server/queries";
|
||||||
|
|
||||||
|
export default async function ListsPage() {
|
||||||
|
const lists = await listLists();
|
||||||
|
return <ListsIndex lists={lists} />;
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import { signIn } from "@/lib/auth";
|
import { signIn } from "@/lib/auth";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { DEV_LOGIN_COOKIE, isDevLoginEnabled } from "@/lib/dev-login-config";
|
||||||
|
import { createDevSession } from "@/lib/dev-login";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
const devLoginEnabled = isDevLoginEnabled();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center p-8">
|
<main className="flex min-h-screen flex-col items-center justify-center p-8">
|
||||||
<div className="flex flex-col items-center gap-6">
|
<div className="flex flex-col items-center gap-6">
|
||||||
@@ -16,6 +22,26 @@ export default function LoginPage() {
|
|||||||
Sign in with SSO
|
Sign in with SSO
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
{devLoginEnabled && (
|
||||||
|
<form
|
||||||
|
action={async () => {
|
||||||
|
"use server";
|
||||||
|
const { sessionToken, expires } = await createDevSession();
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(DEV_LOGIN_COOKIE, sessionToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
expires,
|
||||||
|
});
|
||||||
|
redirect("/");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="submit" variant="outline" size="lg">
|
||||||
|
Dev login
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@/modules/_core/schema";
|
} from "@/modules/_core/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { ensureDefaultCalendarsForMembership } from "@/modules/calendar/server/defaults";
|
import { ensureDefaultCalendarsForMembership } from "@/modules/calendar/server/defaults";
|
||||||
|
import { ensureDefaultListsForHousehold } from "@/modules/lists/server/defaults";
|
||||||
|
|
||||||
declare module "next-auth" {
|
declare module "next-auth" {
|
||||||
interface Session {
|
interface Session {
|
||||||
@@ -64,6 +65,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
householdId: household.id,
|
householdId: household.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
});
|
});
|
||||||
|
await ensureDefaultListsForHousehold(household.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
+2
-1
@@ -2,9 +2,10 @@ import { drizzle } from "drizzle-orm/postgres-js";
|
|||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import * as coreSchema from "@/modules/_core/schema";
|
import * as coreSchema from "@/modules/_core/schema";
|
||||||
import * as calendarSchema from "@/modules/calendar/schema";
|
import * as calendarSchema from "@/modules/calendar/schema";
|
||||||
|
import * as listsSchema from "@/modules/lists/schema";
|
||||||
|
|
||||||
const client = postgres(process.env["DATABASE_URL"]!);
|
const client = postgres(process.env["DATABASE_URL"]!);
|
||||||
|
|
||||||
const schema = { ...coreSchema, ...calendarSchema };
|
const schema = { ...coreSchema, ...calendarSchema, ...listsSchema };
|
||||||
|
|
||||||
export const db = drizzle(client, { schema });
|
export const db = drizzle(client, { schema });
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const DEV_LOGIN_COOKIE = "authjs.session-token";
|
||||||
|
|
||||||
|
export function isDevLoginEnabled() {
|
||||||
|
return process.env.NODE_ENV !== "production" && process.env.ENABLE_DEV_LOGIN === "true";
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { ensureDefaultCalendarsForMembership } from "@/modules/calendar/server/defaults";
|
||||||
|
import { households, householdMembers, sessions, users } from "@/modules/_core/schema";
|
||||||
|
import { ensureDefaultListsForHousehold } from "@/modules/lists/server/defaults";
|
||||||
|
import { isDevLoginEnabled } from "./dev-login-config";
|
||||||
|
|
||||||
|
export async function createDevSession() {
|
||||||
|
if (!isDevLoginEnabled()) throw new Error("Dev login is disabled");
|
||||||
|
|
||||||
|
const email = process.env.DEV_LOGIN_EMAIL ?? "dev@famapp.local";
|
||||||
|
const name = process.env.DEV_LOGIN_NAME ?? "Dev User";
|
||||||
|
const householdName = process.env.DEV_HOUSEHOLD_NAME ?? "Home";
|
||||||
|
|
||||||
|
const [existingHousehold] = await db
|
||||||
|
.select()
|
||||||
|
.from(households)
|
||||||
|
.where(eq(households.name, householdName))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const resolvedHousehold =
|
||||||
|
existingHousehold ??
|
||||||
|
(await db.insert(households).values({ name: householdName }).returning())[0];
|
||||||
|
|
||||||
|
if (!resolvedHousehold) throw new Error("Dev household was not created");
|
||||||
|
|
||||||
|
const [user] = await db.insert(users).values({ email, name }).onConflictDoNothing().returning();
|
||||||
|
|
||||||
|
const resolvedUser =
|
||||||
|
user ?? (await db.select().from(users).where(eq(users.email, email)).limit(1))[0];
|
||||||
|
|
||||||
|
if (!resolvedUser) throw new Error("Dev user was not created");
|
||||||
|
|
||||||
|
const [anyMember] = await db
|
||||||
|
.select()
|
||||||
|
.from(householdMembers)
|
||||||
|
.where(eq(householdMembers.householdId, resolvedHousehold.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(householdMembers)
|
||||||
|
.values({
|
||||||
|
householdId: resolvedHousehold.id,
|
||||||
|
userId: resolvedUser.id,
|
||||||
|
role: anyMember ? "member" : "owner",
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
const [membership] = await db
|
||||||
|
.select()
|
||||||
|
.from(householdMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(householdMembers.householdId, resolvedHousehold.id),
|
||||||
|
eq(householdMembers.userId, resolvedUser.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!membership) throw new Error("Dev membership was not created");
|
||||||
|
|
||||||
|
await ensureDefaultCalendarsForMembership({
|
||||||
|
householdId: resolvedHousehold.id,
|
||||||
|
userId: resolvedUser.id,
|
||||||
|
});
|
||||||
|
await ensureDefaultListsForHousehold(resolvedHousehold.id);
|
||||||
|
|
||||||
|
const sessionToken = randomBytes(32).toString("base64url");
|
||||||
|
const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await db.insert(sessions).values({
|
||||||
|
sessionToken,
|
||||||
|
userId: resolvedUser.id,
|
||||||
|
expires,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sessionToken, expires };
|
||||||
|
}
|
||||||
+28
-1
@@ -1,4 +1,31 @@
|
|||||||
export { auth as middleware } from "@/lib/auth";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const PUBLIC_PREFIXES = ["/api/auth/", "/s/"];
|
||||||
|
const PUBLIC_PATHS = new Set(["/login"]);
|
||||||
|
const SESSION_COOKIE_NAMES = ["authjs.session-token", "__Secure-authjs.session-token"];
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
if (PUBLIC_PATHS.has(pathname) || PUBLIC_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSessionCookie(request)) return NextResponse.next();
|
||||||
|
|
||||||
|
const loginUrl = new URL("/login", request.url);
|
||||||
|
loginUrl.searchParams.set("callbackUrl", request.url);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSessionCookie(request: NextRequest) {
|
||||||
|
return request.cookies
|
||||||
|
.getAll()
|
||||||
|
.some((cookie) =>
|
||||||
|
SESSION_COOKIE_NAMES.some(
|
||||||
|
(name) => cookie.name === name || cookie.name.startsWith(`${name}.`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import FullCalendar from "@fullcalendar/react";
|
|||||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||||
import interactionPlugin from "@fullcalendar/interaction";
|
import interactionPlugin from "@fullcalendar/interaction";
|
||||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||||
import type {
|
import type { DateSelectArg, EventClickArg, EventDropArg } from "@fullcalendar/core";
|
||||||
DateSelectArg,
|
|
||||||
EventClickArg,
|
|
||||||
EventDropArg,
|
|
||||||
} from "@fullcalendar/core";
|
|
||||||
import type { EventResizeDoneArg } from "@fullcalendar/interaction";
|
import type { EventResizeDoneArg } from "@fullcalendar/interaction";
|
||||||
import { CalendarPlus, Check, Eye, EyeOff, Plus, Trash2 } from "lucide-react";
|
import { CalendarPlus, Check, Eye, EyeOff, Plus, Trash2 } from "lucide-react";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
@@ -221,9 +217,7 @@ export function CalendarShell({
|
|||||||
|
|
||||||
function updateCalendar(calendar: CalendarDto, values: Partial<CalendarDto>) {
|
function updateCalendar(calendar: CalendarDto, values: Partial<CalendarDto>) {
|
||||||
const next = { ...calendar, ...values };
|
const next = { ...calendar, ...values };
|
||||||
setCalendarRows((current) =>
|
setCalendarRows((current) => current.map((item) => (item.id === calendar.id ? next : item)));
|
||||||
current.map((item) => (item.id === calendar.id ? next : item)),
|
|
||||||
);
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
if (values.name !== undefined) {
|
if (values.name !== undefined) {
|
||||||
await renameCalendar({ id: calendar.id, name: values.name });
|
await renameCalendar({ id: calendar.id, name: values.name });
|
||||||
@@ -367,7 +361,7 @@ export function CalendarShell({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Button className="w-full" onClick={addCalendar} disabled={!calendarName || isPending}>
|
<Button className="w-full" onClick={addCalendar} disabled={!calendarName}>
|
||||||
<Check />
|
<Check />
|
||||||
Create calendar
|
Create calendar
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -14,20 +14,31 @@ const calendarInput = z.object({
|
|||||||
visibility: z.enum(["private", "household"]).default("household"),
|
visibility: z.enum(["private", "household"]).default("household"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const eventInput = z
|
const eventBaseInput = z.object({
|
||||||
.object({
|
calendarId: z.string().uuid(),
|
||||||
calendarId: z.string().uuid(),
|
title: z.string().trim().min(1).max(200),
|
||||||
title: z.string().trim().min(1).max(200),
|
startAt: z.coerce.date(),
|
||||||
startAt: z.coerce.date(),
|
endAt: z.coerce.date(),
|
||||||
endAt: z.coerce.date(),
|
allDay: z.boolean().default(false),
|
||||||
allDay: z.boolean().default(false),
|
location: z.string().trim().max(300).nullable().optional(),
|
||||||
location: z.string().trim().max(300).nullable().optional(),
|
notes: z.string().trim().max(3000).nullable().optional(),
|
||||||
notes: z.string().trim().max(3000).nullable().optional(),
|
});
|
||||||
})
|
|
||||||
.refine((value) => value.endAt >= value.startAt, {
|
const eventInput = eventBaseInput.refine((value) => value.endAt >= value.startAt, {
|
||||||
|
path: ["endAt"],
|
||||||
|
message: "End must be after start",
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventUpdateInput = eventBaseInput.partial().refine(
|
||||||
|
(value) => {
|
||||||
|
if (!value.startAt || !value.endAt) return true;
|
||||||
|
return value.endAt >= value.startAt;
|
||||||
|
},
|
||||||
|
{
|
||||||
path: ["endAt"],
|
path: ["endAt"],
|
||||||
message: "End must be after start",
|
message: "End must be after start",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export async function createCalendar(input: z.input<typeof calendarInput>) {
|
export async function createCalendar(input: z.input<typeof calendarInput>) {
|
||||||
const parsed = calendarInput.parse(input);
|
const parsed = calendarInput.parse(input);
|
||||||
@@ -79,9 +90,7 @@ export async function setCalendarVisibility(input: {
|
|||||||
|
|
||||||
export async function setCalendarColor(input: { id: string; color: string | null }) {
|
export async function setCalendarColor(input: { id: string; color: string | null }) {
|
||||||
const { user } = await getCurrentSession();
|
const { user } = await getCurrentSession();
|
||||||
const parsed = z
|
const parsed = z.object({ id: z.string().uuid(), color: calendarInput.shape.color }).parse(input);
|
||||||
.object({ id: z.string().uuid(), color: calendarInput.shape.color })
|
|
||||||
.parse(input);
|
|
||||||
await assertOwnsCalendar(user.id, parsed.id);
|
await assertOwnsCalendar(user.id, parsed.id);
|
||||||
await db
|
await db
|
||||||
.update(calendars)
|
.update(calendars)
|
||||||
@@ -124,10 +133,7 @@ export async function createEvent(input: z.input<typeof eventInput>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateEvent(input: { id: string } & Partial<z.input<typeof eventInput>>) {
|
export async function updateEvent(input: { id: string } & Partial<z.input<typeof eventInput>>) {
|
||||||
const parsed = z
|
const parsed = z.object({ id: z.string().uuid() }).and(eventUpdateInput).parse(input);
|
||||||
.object({ id: z.string().uuid() })
|
|
||||||
.and(eventInput.partial())
|
|
||||||
.parse(input);
|
|
||||||
const { user } = await getCurrentSession();
|
const { user } = await getCurrentSession();
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select({ calendarId: calendarEvents.calendarId })
|
.select({ calendarId: calendarEvents.calendarId })
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Archive, GripVertical, Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState, useTransition } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import type { ListDetailDto, ListItemDto } from "../server/queries";
|
||||||
|
import { getList } from "../server/queries";
|
||||||
|
import {
|
||||||
|
addItem,
|
||||||
|
archiveList,
|
||||||
|
deleteItem,
|
||||||
|
renameList,
|
||||||
|
toggleItem,
|
||||||
|
updateItem,
|
||||||
|
} from "../server/actions";
|
||||||
|
|
||||||
|
export function ListDetail({ initialList }: { initialList: ListDetailDto }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [list, setList] = useState(initialList);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const swipeStart = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const events = new EventSource(`/api/lists/${initialList.id}/events`);
|
||||||
|
events.onmessage = () => {
|
||||||
|
getList(initialList.id)
|
||||||
|
.then(setList)
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
return () => events.close();
|
||||||
|
}, [initialList.id]);
|
||||||
|
|
||||||
|
function submitItem() {
|
||||||
|
const text = draft.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const next = await addItem({ listId: list.id, text });
|
||||||
|
setList(next);
|
||||||
|
setDraft("");
|
||||||
|
requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setItemDone(item: ListItemDto, done: boolean) {
|
||||||
|
setList((current) => ({
|
||||||
|
...current,
|
||||||
|
items: current.items.map((row) => (row.id === item.id ? { ...row, done } : row)),
|
||||||
|
}));
|
||||||
|
startTransition(async () => {
|
||||||
|
setList(await toggleItem({ id: item.id, done }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function editItemText(item: ListItemDto, text: string) {
|
||||||
|
setList((current) => ({
|
||||||
|
...current,
|
||||||
|
items: current.items.map((row) => (row.id === item.id ? { ...row, text } : row)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitItemText(item: ListItemDto) {
|
||||||
|
if (!item.text.trim()) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
setList(await updateItem({ id: item.id, text: item.text }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(item: ListItemDto) {
|
||||||
|
setList((current) => ({
|
||||||
|
...current,
|
||||||
|
items: current.items.filter((row) => row.id !== item.id),
|
||||||
|
}));
|
||||||
|
startTransition(async () => {
|
||||||
|
setList(await deleteItem({ id: item.id }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitListName() {
|
||||||
|
if (!list.name.trim()) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
await renameList({ id: list.id, name: list.name });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function archiveCurrentList() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await archiveList({ id: list.id });
|
||||||
|
router.push("/lists");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto grid w-full max-w-3xl gap-5 p-4">
|
||||||
|
<header className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Input
|
||||||
|
aria-label="List name"
|
||||||
|
className="h-auto border-transparent px-0 text-2xl font-semibold shadow-none focus-visible:border-transparent focus-visible:ring-0"
|
||||||
|
value={list.name}
|
||||||
|
onChange={(event) => setList({ ...list, name: event.target.value })}
|
||||||
|
onBlur={commitListName}
|
||||||
|
/>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{list.type} / {list.openCount} open / {list.doneCount} done
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={archiveCurrentList} disabled={isPending}>
|
||||||
|
<Archive />
|
||||||
|
Archive list
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="flex gap-2"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
submitItem();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
aria-label="Add item"
|
||||||
|
autoFocus
|
||||||
|
placeholder={list.type === "shopping" ? "Add milk, eggs, coffee..." : "Add a task..."}
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={!draft.trim() || isPending}>
|
||||||
|
<Plus />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border bg-background">
|
||||||
|
{list.items.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-sm text-muted-foreground">Nothing here yet.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{list.items.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className="grid grid-cols-[auto_1fr_auto] items-center gap-3 p-3"
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
swipeStart.current[item.id] = event.clientX;
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
const start = swipeStart.current[item.id];
|
||||||
|
if (start !== undefined && event.clientX - start < -60) removeItem(item);
|
||||||
|
delete swipeStart.current[item.id];
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={`Complete ${item.text}`}
|
||||||
|
type="checkbox"
|
||||||
|
className="size-5 accent-primary"
|
||||||
|
checked={item.done}
|
||||||
|
onChange={(event) => setItemDone(item, event.target.checked)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
aria-label={`${item.text} text`}
|
||||||
|
className={item.done ? "text-muted-foreground line-through" : ""}
|
||||||
|
value={item.text}
|
||||||
|
onChange={(event) => editItemText(item, event.target.value)}
|
||||||
|
onBlur={() => commitItemText(item)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<GripVertical className="size-4 text-muted-foreground" />
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Delete ${item.text}`}
|
||||||
|
onClick={() => removeItem(item)}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { ListDto } from "../server/queries";
|
||||||
|
import { createList } from "../server/actions";
|
||||||
|
|
||||||
|
export function ListsIndex({ lists }: { lists: ListDto[] }) {
|
||||||
|
const [listRows, setListRows] = useState(lists);
|
||||||
|
const [type, setType] = useState("shopping");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const groups = new Map<string, ListDto[]>();
|
||||||
|
for (const list of listRows) {
|
||||||
|
groups.set(list.type, [...(groups.get(list.type) ?? []), list]);
|
||||||
|
}
|
||||||
|
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
}, [listRows]);
|
||||||
|
|
||||||
|
function addList() {
|
||||||
|
startTransition(async () => {
|
||||||
|
const created = await createList({ type, name });
|
||||||
|
setListRows((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: created.id,
|
||||||
|
type: created.type,
|
||||||
|
name: created.name,
|
||||||
|
archived: created.archived,
|
||||||
|
openCount: 0,
|
||||||
|
doneCount: 0,
|
||||||
|
createdAt: created.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setName("");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto grid w-full max-w-5xl gap-6 p-4">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Lists</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Shopping, tasks, and whatever comes next.</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 rounded-lg border bg-background p-3 sm:grid-cols-[140px_220px_auto]">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="new-list-type">Type</Label>
|
||||||
|
<Input
|
||||||
|
id="new-list-type"
|
||||||
|
value={type}
|
||||||
|
onChange={(event) => setType(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="new-list-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="new-list-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button className="self-end" onClick={addList} disabled={!type || !name}>
|
||||||
|
<Plus />
|
||||||
|
New list
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{grouped.map(([groupType, groupLists]) => (
|
||||||
|
<section key={groupType} className="grid gap-3">
|
||||||
|
<h2 className="text-sm font-medium uppercase tracking-normal text-muted-foreground">
|
||||||
|
{groupType}
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{groupLists.map((list) => (
|
||||||
|
<Link
|
||||||
|
key={list.id}
|
||||||
|
href={`/lists/${list.id}`}
|
||||||
|
className="rounded-lg border bg-card p-4 text-card-foreground transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
<div className="font-medium">{list.name}</div>
|
||||||
|
<div className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{list.openCount} open / {list.doneCount} done
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { ModuleManifest } from "../_core/module";
|
|
||||||
|
|
||||||
const manifest: ModuleManifest = {
|
|
||||||
id: "lists",
|
|
||||||
name: "Lists",
|
|
||||||
nav: { href: "/lists", label: "Lists", icon: "list" },
|
|
||||||
entities: [
|
|
||||||
{
|
|
||||||
type: "lists.list",
|
|
||||||
label: { singular: "List", plural: "Lists" },
|
|
||||||
resolveUrl: (id) => `/lists/${id}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "lists.item",
|
|
||||||
label: { singular: "List item", plural: "List items" },
|
|
||||||
resolveUrl: (id) => `/lists/items/${id}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
dashboardWidgets: [],
|
|
||||||
quickAdds: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default manifest;
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { ModuleManifest } from "../_core/module";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { addItemToDefaultList } from "./server/actions";
|
||||||
|
import { listLists, searchItems, searchLists } from "./server/queries";
|
||||||
|
|
||||||
|
const listIdsSchema = z.union([z.literal("all"), z.array(z.string().uuid())]);
|
||||||
|
|
||||||
|
const manifest: ModuleManifest = {
|
||||||
|
id: "lists",
|
||||||
|
name: "Lists",
|
||||||
|
nav: { href: "/lists", label: "Lists", icon: "list" },
|
||||||
|
entities: [
|
||||||
|
{
|
||||||
|
type: "lists.list",
|
||||||
|
label: { singular: "List", plural: "Lists" },
|
||||||
|
share: { canShare: true, defaultCapabilities: ["read", "write"] },
|
||||||
|
search: { search: searchLists },
|
||||||
|
resolveUrl: (id) => `/lists/${id}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "lists.item",
|
||||||
|
label: { singular: "List item", plural: "List items" },
|
||||||
|
share: { canShare: false },
|
||||||
|
search: { search: searchItems },
|
||||||
|
resolveUrl: (id) => `/lists/items/${id}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dashboardWidgets: [
|
||||||
|
{
|
||||||
|
id: "lists.list",
|
||||||
|
title: "List items",
|
||||||
|
description: "Open or completed items from selected lists.",
|
||||||
|
category: "Lists",
|
||||||
|
defaultSize: { w: 4, h: 3 },
|
||||||
|
minSize: { w: 3, h: 2 },
|
||||||
|
defaultPriority: 30,
|
||||||
|
configSchema: z.object({
|
||||||
|
listIds: listIdsSchema,
|
||||||
|
showCompleted: z.boolean(),
|
||||||
|
limit: z.number().int().min(1).max(50).optional(),
|
||||||
|
}),
|
||||||
|
defaultConfig: { listIds: "all", showCompleted: false },
|
||||||
|
resolveConfigOptions: async () => ({
|
||||||
|
lists: (await listLists()).map((list) => ({
|
||||||
|
id: list.id,
|
||||||
|
type: list.type,
|
||||||
|
name: list.name,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
render: ({ config }) => {
|
||||||
|
const parsed = z
|
||||||
|
.object({
|
||||||
|
listIds: listIdsSchema,
|
||||||
|
showCompleted: z.boolean(),
|
||||||
|
limit: z.number().int().min(1).max(50).optional(),
|
||||||
|
})
|
||||||
|
.parse(config);
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{parsed.showCompleted ? "List items" : "Open list items"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
quickAdds: [
|
||||||
|
{
|
||||||
|
id: "lists.add-shopping",
|
||||||
|
label: "Add to shopping",
|
||||||
|
icon: "shopping-cart",
|
||||||
|
action: async () => {
|
||||||
|
await addItemToDefaultList({ type: "shopping", text: "New item" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lists.add-task",
|
||||||
|
label: "Add to tasks",
|
||||||
|
icon: "list-checks",
|
||||||
|
action: async () => {
|
||||||
|
await addItemToDefaultList({ type: "task", text: "New task" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lists.new-list",
|
||||||
|
label: "New list",
|
||||||
|
icon: "list-plus",
|
||||||
|
action: () => undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default manifest;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { index, integer, pgTable, text, timestamp, uuid, boolean } from "drizzle-orm/pg-core";
|
||||||
|
import { households, users } from "../_core/schema";
|
||||||
|
|
||||||
|
export const lists = pgTable(
|
||||||
|
"lists",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
householdId: uuid("household_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => households.id, { onDelete: "cascade" }),
|
||||||
|
type: text("type").notNull(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
archived: boolean("archived").notNull().default(false),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("lists_household_type_idx").on(t.householdId, t.type),
|
||||||
|
index("lists_household_archived_idx").on(t.householdId, t.archived),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const listItems = pgTable(
|
||||||
|
"list_items",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
listId: uuid("list_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => lists.id, { onDelete: "cascade" }),
|
||||||
|
text: text("text").notNull(),
|
||||||
|
done: boolean("done").notNull().default(false),
|
||||||
|
qty: text("qty"),
|
||||||
|
notes: text("notes"),
|
||||||
|
dueAt: timestamp("due_at", { withTimezone: true }),
|
||||||
|
assigneeId: uuid("assignee_id").references(() => users.id, { onDelete: "set null" }),
|
||||||
|
position: integer("position").notNull().default(0),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("list_items_list_position_idx").on(t.listId, t.position),
|
||||||
|
index("list_items_assignee_idx").on(t.assigneeId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type List = typeof lists.$inferSelect;
|
||||||
|
export type ListItem = typeof listItems.$inferSelect;
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { and, eq, max } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { listItems, lists } from "../schema";
|
||||||
|
import { getOrCreateDefaultList } from "./defaults";
|
||||||
|
import { canAccessList, getList } from "./queries";
|
||||||
|
import { notifyListChanged } from "./realtime";
|
||||||
|
|
||||||
|
const listInput = z.object({
|
||||||
|
type: z.string().trim().min(1).max(80),
|
||||||
|
name: z.string().trim().min(1).max(120),
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemInput = z.object({
|
||||||
|
listId: z.string().uuid(),
|
||||||
|
text: z.string().trim().min(1).max(300),
|
||||||
|
qty: z.string().trim().max(80).nullable().optional(),
|
||||||
|
notes: z.string().trim().max(2000).nullable().optional(),
|
||||||
|
dueAt: z.coerce.date().nullable().optional(),
|
||||||
|
assigneeId: z.string().uuid().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateItemInput = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
text: z.string().trim().min(1).max(300).optional(),
|
||||||
|
qty: z.string().trim().max(80).nullable().optional(),
|
||||||
|
notes: z.string().trim().max(2000).nullable().optional(),
|
||||||
|
dueAt: z.coerce.date().nullable().optional(),
|
||||||
|
assigneeId: z.string().uuid().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createList(input: z.input<typeof listInput>) {
|
||||||
|
const parsed = listInput.parse(input);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
const [list] = await db
|
||||||
|
.insert(lists)
|
||||||
|
.values({
|
||||||
|
householdId: household.id,
|
||||||
|
type: parsed.type,
|
||||||
|
name: parsed.name,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!list) throw new Error("List was not created");
|
||||||
|
revalidatePath("/lists");
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameList(input: { id: string; name: string }) {
|
||||||
|
const parsed = z.object({ id: z.string().uuid(), name: listInput.shape.name }).parse(input);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
await assertCanAccessList(parsed.id, household.id);
|
||||||
|
await db.update(lists).set({ name: parsed.name }).where(eq(lists.id, parsed.id));
|
||||||
|
revalidatePath("/lists");
|
||||||
|
revalidatePath(`/lists/${parsed.id}`);
|
||||||
|
await notifyListChanged(parsed.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveList(input: { id: string }) {
|
||||||
|
const parsed = z.object({ id: z.string().uuid() }).parse(input);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
await assertCanAccessList(parsed.id, household.id);
|
||||||
|
await db.update(lists).set({ archived: true }).where(eq(lists.id, parsed.id));
|
||||||
|
revalidatePath("/lists");
|
||||||
|
revalidatePath(`/lists/${parsed.id}`);
|
||||||
|
await notifyListChanged(parsed.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addItem(input: z.input<typeof itemInput>) {
|
||||||
|
const parsed = itemInput.parse(input);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
await assertCanAccessList(parsed.listId, household.id);
|
||||||
|
|
||||||
|
const [positionRow] = await db
|
||||||
|
.select({ maxPosition: max(listItems.position) })
|
||||||
|
.from(listItems)
|
||||||
|
.where(eq(listItems.listId, parsed.listId));
|
||||||
|
|
||||||
|
const [item] = await db
|
||||||
|
.insert(listItems)
|
||||||
|
.values({
|
||||||
|
listId: parsed.listId,
|
||||||
|
text: parsed.text,
|
||||||
|
qty: parsed.qty || null,
|
||||||
|
notes: parsed.notes || null,
|
||||||
|
dueAt: parsed.dueAt ?? null,
|
||||||
|
assigneeId: parsed.assigneeId ?? null,
|
||||||
|
position: (positionRow?.maxPosition ?? -1) + 1,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!item) throw new Error("List item was not created");
|
||||||
|
revalidatePath(`/lists/${parsed.listId}`);
|
||||||
|
await notifyListChanged(parsed.listId);
|
||||||
|
return getList(parsed.listId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addItemToDefaultList(input: { type: string; text: string }) {
|
||||||
|
const parsed = z
|
||||||
|
.object({
|
||||||
|
type: listInput.shape.type,
|
||||||
|
text: itemInput.shape.text,
|
||||||
|
})
|
||||||
|
.parse(input);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
const list = await getOrCreateDefaultList({ householdId: household.id, type: parsed.type });
|
||||||
|
return addItem({ listId: list.id, text: parsed.text });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleItem(input: { id: string; done?: boolean }) {
|
||||||
|
const parsed = z.object({ id: z.string().uuid(), done: z.boolean().optional() }).parse(input);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
const existing = await getAuthorizedItem(parsed.id, household.id);
|
||||||
|
const done = parsed.done ?? !existing.done;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(listItems)
|
||||||
|
.set({ done, updatedAt: new Date() })
|
||||||
|
.where(eq(listItems.id, parsed.id));
|
||||||
|
|
||||||
|
revalidatePath(`/lists/${existing.listId}`);
|
||||||
|
await notifyListChanged(existing.listId);
|
||||||
|
return getList(existing.listId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateItem(input: z.input<typeof updateItemInput>) {
|
||||||
|
const parsed = updateItemInput.parse(input);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
const existing = await getAuthorizedItem(parsed.id, household.id);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(listItems)
|
||||||
|
.set({
|
||||||
|
text: parsed.text,
|
||||||
|
qty: parsed.qty === undefined ? undefined : parsed.qty || null,
|
||||||
|
notes: parsed.notes === undefined ? undefined : parsed.notes || null,
|
||||||
|
dueAt: parsed.dueAt === undefined ? undefined : parsed.dueAt,
|
||||||
|
assigneeId: parsed.assigneeId === undefined ? undefined : parsed.assigneeId,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(listItems.id, parsed.id));
|
||||||
|
|
||||||
|
revalidatePath(`/lists/${existing.listId}`);
|
||||||
|
await notifyListChanged(existing.listId);
|
||||||
|
return getList(existing.listId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteItem(input: { id: string }) {
|
||||||
|
const parsed = z.object({ id: z.string().uuid() }).parse(input);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
const existing = await getAuthorizedItem(parsed.id, household.id);
|
||||||
|
await db.delete(listItems).where(eq(listItems.id, parsed.id));
|
||||||
|
revalidatePath(`/lists/${existing.listId}`);
|
||||||
|
await notifyListChanged(existing.listId);
|
||||||
|
return getList(existing.listId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reorderItems(input: { listId: string; itemIds: string[] }) {
|
||||||
|
const parsed = z
|
||||||
|
.object({ listId: z.string().uuid(), itemIds: z.array(z.string().uuid()) })
|
||||||
|
.parse(input);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
await assertCanAccessList(parsed.listId, household.id);
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
for (const [position, id] of parsed.itemIds.entries()) {
|
||||||
|
await tx
|
||||||
|
.update(listItems)
|
||||||
|
.set({ position, updatedAt: new Date() })
|
||||||
|
.where(and(eq(listItems.id, id), eq(listItems.listId, parsed.listId)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/lists/${parsed.listId}`);
|
||||||
|
await notifyListChanged(parsed.listId);
|
||||||
|
return getList(parsed.listId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertCanAccessList(listId: string, householdId: string) {
|
||||||
|
if (!(await canAccessList(listId, householdId))) throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAuthorizedItem(itemId: string, householdId: string) {
|
||||||
|
const [item] = await db
|
||||||
|
.select({
|
||||||
|
id: listItems.id,
|
||||||
|
listId: listItems.listId,
|
||||||
|
done: listItems.done,
|
||||||
|
})
|
||||||
|
.from(listItems)
|
||||||
|
.innerJoin(lists, eq(listItems.listId, lists.id))
|
||||||
|
.where(and(eq(listItems.id, itemId), eq(lists.householdId, householdId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!item) throw new Error("Item not found");
|
||||||
|
return item;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { households } from "@/modules/_core/schema";
|
||||||
|
import { lists } from "../schema";
|
||||||
|
|
||||||
|
const DEFAULT_LISTS = [
|
||||||
|
{ type: "shopping", name: "Shopping" },
|
||||||
|
{ type: "task", name: "Tasks" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export async function ensureDefaultListsForHousehold(householdId: string) {
|
||||||
|
for (const defaults of DEFAULT_LISTS) {
|
||||||
|
await ensureDefaultList({ householdId, ...defaults });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureDefaultLists() {
|
||||||
|
const householdRows = await db.select({ id: households.id }).from(households);
|
||||||
|
for (const household of householdRows) {
|
||||||
|
await ensureDefaultListsForHousehold(household.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrCreateDefaultList({
|
||||||
|
householdId,
|
||||||
|
type,
|
||||||
|
}: {
|
||||||
|
householdId: string;
|
||||||
|
type: "shopping" | "task" | string;
|
||||||
|
}) {
|
||||||
|
const defaults = DEFAULT_LISTS.find((list) => list.type === type);
|
||||||
|
const name = defaults?.name ?? type;
|
||||||
|
|
||||||
|
return ensureDefaultList({ householdId, type, name });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDefaultList({
|
||||||
|
householdId,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
householdId: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
}) {
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(lists)
|
||||||
|
.where(and(eq(lists.householdId, householdId), eq(lists.type, type), eq(lists.name, name)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const [created] = await db
|
||||||
|
.insert(lists)
|
||||||
|
.values({
|
||||||
|
householdId,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!created) throw new Error("Default list was not created");
|
||||||
|
return created;
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { and, asc, eq, inArray, or, sql } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { listItems, lists } from "../schema";
|
||||||
|
import { ensureDefaultListsForHousehold } from "./defaults";
|
||||||
|
|
||||||
|
export type ListDto = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
archived: boolean;
|
||||||
|
openCount: number;
|
||||||
|
doneCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListItemDto = {
|
||||||
|
id: string;
|
||||||
|
listId: string;
|
||||||
|
text: string;
|
||||||
|
done: boolean;
|
||||||
|
qty: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
dueAt: string | null;
|
||||||
|
assigneeId: string | null;
|
||||||
|
position: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListDetailDto = ListDto & {
|
||||||
|
items: ListItemDto[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const listListsInput = z
|
||||||
|
.object({
|
||||||
|
type: z.string().trim().min(1).max(80).optional(),
|
||||||
|
includeArchived: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
export async function listLists(input?: z.input<typeof listListsInput>): Promise<ListDto[]> {
|
||||||
|
const parsed = listListsInput.parse(input) ?? { includeArchived: false };
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
await ensureDefaultListsForHousehold(household.id);
|
||||||
|
|
||||||
|
const conditions = [eq(lists.householdId, household.id)];
|
||||||
|
if (parsed.type) conditions.push(eq(lists.type, parsed.type));
|
||||||
|
if (!parsed.includeArchived) conditions.push(eq(lists.archived, false));
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: lists.id,
|
||||||
|
type: lists.type,
|
||||||
|
name: lists.name,
|
||||||
|
archived: lists.archived,
|
||||||
|
createdAt: lists.createdAt,
|
||||||
|
done: listItems.done,
|
||||||
|
})
|
||||||
|
.from(lists)
|
||||||
|
.leftJoin(listItems, eq(listItems.listId, lists.id))
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(asc(lists.type), asc(lists.name), asc(lists.createdAt));
|
||||||
|
|
||||||
|
return summarizeLists(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getList(id: string): Promise<ListDetailDto> {
|
||||||
|
const parsed = z.string().uuid().parse(id);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
await ensureDefaultListsForHousehold(household.id);
|
||||||
|
|
||||||
|
const [list] = await db
|
||||||
|
.select()
|
||||||
|
.from(lists)
|
||||||
|
.where(and(eq(lists.id, parsed), eq(lists.householdId, household.id)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!list) throw new Error("List not found");
|
||||||
|
|
||||||
|
const items = await db
|
||||||
|
.select()
|
||||||
|
.from(listItems)
|
||||||
|
.where(eq(listItems.listId, list.id))
|
||||||
|
.orderBy(asc(listItems.done), asc(listItems.position), asc(listItems.createdAt));
|
||||||
|
|
||||||
|
const dtoItems = items.map(toItemDto);
|
||||||
|
return {
|
||||||
|
...toListDto(list, dtoItems),
|
||||||
|
items: dtoItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function canAccessList(listId: string, householdId: string) {
|
||||||
|
const [list] = await db
|
||||||
|
.select({ id: lists.id })
|
||||||
|
.from(lists)
|
||||||
|
.where(and(eq(lists.id, listId), eq(lists.householdId, householdId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return !!list;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchLists(query: string, householdId: string) {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: lists.id, name: lists.name, type: lists.type })
|
||||||
|
.from(lists)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(lists.householdId, householdId),
|
||||||
|
eq(lists.archived, false),
|
||||||
|
or(sql`${lists.name} ilike ${`%${query}%`}`, sql`${lists.type} ilike ${`%${query}%`}`),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.name,
|
||||||
|
url: `/lists/${row.id}`,
|
||||||
|
excerpt: row.type,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchItems(query: string, householdId: string) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: listItems.id,
|
||||||
|
listId: listItems.listId,
|
||||||
|
text: listItems.text,
|
||||||
|
notes: listItems.notes,
|
||||||
|
listName: lists.name,
|
||||||
|
})
|
||||||
|
.from(listItems)
|
||||||
|
.innerJoin(lists, eq(listItems.listId, lists.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(lists.householdId, householdId),
|
||||||
|
eq(lists.archived, false),
|
||||||
|
or(
|
||||||
|
sql`${listItems.text} ilike ${`%${query}%`}`,
|
||||||
|
sql`${listItems.notes} ilike ${`%${query}%`}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.text,
|
||||||
|
url: `/lists/${row.listId}`,
|
||||||
|
excerpt: row.notes ?? row.listName,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWidgetItems(input: {
|
||||||
|
listIds: "all" | string[];
|
||||||
|
showCompleted: boolean;
|
||||||
|
limit?: number;
|
||||||
|
}) {
|
||||||
|
const parsed = z
|
||||||
|
.object({
|
||||||
|
listIds: z.union([z.literal("all"), z.array(z.string().uuid())]),
|
||||||
|
showCompleted: z.boolean(),
|
||||||
|
limit: z.number().int().min(1).max(50).optional(),
|
||||||
|
})
|
||||||
|
.parse(input);
|
||||||
|
const allLists = await listLists();
|
||||||
|
const visibleIds =
|
||||||
|
parsed.listIds === "all"
|
||||||
|
? allLists.map((list) => list.id)
|
||||||
|
: parsed.listIds.filter((id) => allLists.some((list) => list.id === id));
|
||||||
|
|
||||||
|
if (visibleIds.length === 0) return [];
|
||||||
|
|
||||||
|
const conditions = [inArray(listItems.listId, visibleIds)];
|
||||||
|
if (!parsed.showCompleted) conditions.push(eq(listItems.done, false));
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: listItems.id,
|
||||||
|
text: listItems.text,
|
||||||
|
done: listItems.done,
|
||||||
|
listName: lists.name,
|
||||||
|
})
|
||||||
|
.from(listItems)
|
||||||
|
.innerJoin(lists, eq(listItems.listId, lists.id))
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(asc(listItems.done), asc(listItems.position), asc(listItems.createdAt))
|
||||||
|
.limit(parsed.limit ?? 10);
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeLists(
|
||||||
|
rows: {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
archived: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
done: boolean | null;
|
||||||
|
}[],
|
||||||
|
) {
|
||||||
|
const byId = new Map<string, ListDto>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const existing =
|
||||||
|
byId.get(row.id) ??
|
||||||
|
({
|
||||||
|
id: row.id,
|
||||||
|
type: row.type,
|
||||||
|
name: row.name,
|
||||||
|
archived: row.archived,
|
||||||
|
openCount: 0,
|
||||||
|
doneCount: 0,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
} satisfies ListDto);
|
||||||
|
|
||||||
|
if (row.done === true) existing.doneCount += 1;
|
||||||
|
if (row.done === false) existing.openCount += 1;
|
||||||
|
byId.set(row.id, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byId.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toListDto(list: typeof lists.$inferSelect, items: ListItemDto[]): ListDto {
|
||||||
|
return {
|
||||||
|
id: list.id,
|
||||||
|
type: list.type,
|
||||||
|
name: list.name,
|
||||||
|
archived: list.archived,
|
||||||
|
openCount: items.filter((item) => !item.done).length,
|
||||||
|
doneCount: items.filter((item) => item.done).length,
|
||||||
|
createdAt: list.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toItemDto(item: typeof listItems.$inferSelect): ListItemDto {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
listId: item.listId,
|
||||||
|
text: item.text,
|
||||||
|
done: item.done,
|
||||||
|
qty: item.qty,
|
||||||
|
notes: item.notes,
|
||||||
|
dueAt: item.dueAt?.toISOString() ?? null,
|
||||||
|
assigneeId: item.assigneeId,
|
||||||
|
position: item.position,
|
||||||
|
createdAt: item.createdAt.toISOString(),
|
||||||
|
updatedAt: item.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
|
||||||
|
export function listChannel(listId: string) {
|
||||||
|
return `list:${listId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyListChanged(listId: string) {
|
||||||
|
await db.execute(
|
||||||
|
sql`select pg_notify(${listChannel(listId)}, ${JSON.stringify({ listId, at: Date.now() })})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,19 +10,19 @@ test("calendar CRUD happy path", async ({ page }) => {
|
|||||||
await page.goto("/calendar");
|
await page.goto("/calendar");
|
||||||
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel("Name").fill(calendarName);
|
await page.getByLabel("Name", { exact: true }).fill(calendarName);
|
||||||
await page.getByRole("button", { name: "Create calendar" }).click();
|
await page.getByRole("button", { name: "Create calendar" }).click();
|
||||||
await expect(page.getByDisplayValue(calendarName)).toBeVisible();
|
await expect(page.getByLabel(`${calendarName} name`)).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New event" }).click();
|
await page.getByRole("button", { name: "New event" }).click();
|
||||||
await page.getByLabel("Title").fill(eventTitle);
|
await page.getByLabel("Title", { exact: true }).fill(eventTitle);
|
||||||
await page.getByLabel("Start").fill("2026-06-15T09:00");
|
await page.getByLabel("Start", { exact: true }).fill("2026-05-15T09:00");
|
||||||
await page.getByLabel("End").fill("2026-06-15T10:00");
|
await page.getByLabel("End", { exact: true }).fill("2026-05-15T10:00");
|
||||||
await page.getByRole("button", { name: "Save event" }).click();
|
await page.getByRole("button", { name: "Save event" }).click();
|
||||||
await expect(page.getByText(eventTitle)).toBeVisible();
|
await expect(page.getByText(eventTitle)).toBeVisible();
|
||||||
|
|
||||||
await page.getByText(eventTitle).click();
|
await page.getByText(eventTitle).click();
|
||||||
await page.getByLabel("Title").fill(editedTitle);
|
await page.getByLabel("Title", { exact: true }).fill(editedTitle);
|
||||||
await page.getByRole("button", { name: "Save event" }).click();
|
await page.getByRole("button", { name: "Save event" }).click();
|
||||||
await expect(page.getByText(editedTitle)).toBeVisible();
|
await expect(page.getByText(editedTitle)).toBeVisible();
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ test("calendar CRUD happy path", async ({ page }) => {
|
|||||||
await expect(page.getByText(editedTitle)).toBeHidden();
|
await expect(page.getByText(editedTitle)).toBeHidden();
|
||||||
|
|
||||||
await page.getByLabel(`${calendarName} name`).fill(renamedCalendarName);
|
await page.getByLabel(`${calendarName} name`).fill(renamedCalendarName);
|
||||||
await expect(page.getByDisplayValue(renamedCalendarName)).toBeVisible();
|
await expect(page.getByLabel(`${renamedCalendarName} name`)).toBeVisible();
|
||||||
await page.getByRole("button", { name: `Delete ${renamedCalendarName}` }).click();
|
await page.getByRole("button", { name: `Delete ${renamedCalendarName}` }).click();
|
||||||
await expect(page.getByDisplayValue(renamedCalendarName)).toBeHidden();
|
await expect(page.getByLabel(`${renamedCalendarName} name`)).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test("lists happy path", async ({ page }) => {
|
||||||
|
const suffix = Date.now().toString();
|
||||||
|
const listName = `E2E List ${suffix}`;
|
||||||
|
const itemText = `E2E Item ${suffix}`;
|
||||||
|
|
||||||
|
await page.goto("/lists");
|
||||||
|
await expect(page.getByRole("heading", { name: "Lists" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel("Type").fill("task");
|
||||||
|
await page.getByLabel("Name").fill(listName);
|
||||||
|
await page.getByRole("button", { name: "New list" }).click();
|
||||||
|
await page.getByRole("link", { name: new RegExp(listName) }).click();
|
||||||
|
|
||||||
|
await page.getByLabel("Add item").fill(itemText);
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(page.getByLabel(`${itemText} text`)).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel(`Complete ${itemText}`).check();
|
||||||
|
await expect(page.getByLabel(`Complete ${itemText}`)).toBeChecked();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Archive list" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/lists$/);
|
||||||
|
await expect(page.getByRole("link", { name: new RegExp(listName) })).toBeHidden();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user