Implement lists module and dev login setup

This commit is contained in:
ginnoir
2026-05-06 03:49:03 -05:00
parent 744c1119a9
commit 7e3ae6eb04
31 changed files with 1525 additions and 63 deletions
+7
View File
@@ -7,6 +7,13 @@ DATABASE_URL=postgres://famapp:famapp@localhost:5432/famapp
# Auth.js
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)
AUTH_OIDC_ISSUER=https://auth.ginnoir.com/application/o/famapp/
AUTH_OIDC_CLIENT_ID=replace-me
+1
View File
@@ -38,6 +38,7 @@ desktop.ini
coverage/
playwright-report/
test-results/
tests/.auth/
# Drizzle generated artifacts (migrations themselves are committed)
drizzle/meta/
+8 -2
View File
@@ -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.
- **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
- **11Lists module** (or next task in `docs/tasks/`).
- **12Notes 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
@@ -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
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.
## Environment notes
+19
View File
@@ -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.
+112
View File
@@ -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.
+30
View File
@@ -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");
+55
View File
@@ -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 $$;
+4
View File
@@ -2,6 +2,7 @@ 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);
@@ -17,7 +18,10 @@ async function seed() {
await ensureDefaultCalendars();
console.log("Ensured default calendars.");
await ensureDefaultLists();
console.log("Ensured default lists.");
await client.end();
process.exit(0);
}
seed().catch((err) => {
+58
View File
@@ -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",
},
});
}
+11
View File
@@ -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} />;
}
+7
View File
@@ -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} />;
}
+26
View File
@@ -1,7 +1,13 @@
import { signIn } from "@/lib/auth";
import { Button } from "@/components/ui/button";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { DEV_LOGIN_COOKIE, isDevLoginEnabled } from "@/lib/dev-login-config";
import { createDevSession } from "@/lib/dev-login";
export default function LoginPage() {
const devLoginEnabled = isDevLoginEnabled();
return (
<main className="flex min-h-screen flex-col items-center justify-center p-8">
<div className="flex flex-col items-center gap-6">
@@ -16,6 +22,26 @@ export default function LoginPage() {
Sign in with SSO
</Button>
</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>
</main>
);
+2
View File
@@ -11,6 +11,7 @@ import {
} from "@/modules/_core/schema";
import { eq } from "drizzle-orm";
import { ensureDefaultCalendarsForMembership } from "@/modules/calendar/server/defaults";
import { ensureDefaultListsForHousehold } from "@/modules/lists/server/defaults";
declare module "next-auth" {
interface Session {
@@ -64,6 +65,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
householdId: household.id,
userId: user.id,
});
await ensureDefaultListsForHousehold(household.id);
}
}
return true;
+2 -1
View File
@@ -2,9 +2,10 @@ import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as coreSchema from "@/modules/_core/schema";
import * as calendarSchema from "@/modules/calendar/schema";
import * as listsSchema from "@/modules/lists/schema";
const client = postgres(process.env["DATABASE_URL"]!);
const schema = { ...coreSchema, ...calendarSchema };
const schema = { ...coreSchema, ...calendarSchema, ...listsSchema };
export const db = drizzle(client, { schema });
+5
View File
@@ -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";
}
+79
View File
@@ -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
View File
@@ -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 = {
matcher: [
@@ -4,11 +4,7 @@ import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid";
import type {
DateSelectArg,
EventClickArg,
EventDropArg,
} from "@fullcalendar/core";
import type { DateSelectArg, EventClickArg, EventDropArg } from "@fullcalendar/core";
import type { EventResizeDoneArg } from "@fullcalendar/interaction";
import { CalendarPlus, Check, Eye, EyeOff, Plus, Trash2 } from "lucide-react";
import { useMemo, useState, useTransition } from "react";
@@ -221,9 +217,7 @@ export function CalendarShell({
function updateCalendar(calendar: CalendarDto, values: Partial<CalendarDto>) {
const next = { ...calendar, ...values };
setCalendarRows((current) =>
current.map((item) => (item.id === calendar.id ? next : item)),
);
setCalendarRows((current) => current.map((item) => (item.id === calendar.id ? next : item)));
startTransition(async () => {
if (values.name !== undefined) {
await renameCalendar({ id: calendar.id, name: values.name });
@@ -367,7 +361,7 @@ export function CalendarShell({
</SelectContent>
</Select>
</div>
<Button className="w-full" onClick={addCalendar} disabled={!calendarName || isPending}>
<Button className="w-full" onClick={addCalendar} disabled={!calendarName}>
<Check />
Create calendar
</Button>
+17 -11
View File
@@ -14,8 +14,7 @@ const calendarInput = z.object({
visibility: z.enum(["private", "household"]).default("household"),
});
const eventInput = z
.object({
const eventBaseInput = z.object({
calendarId: z.string().uuid(),
title: z.string().trim().min(1).max(200),
startAt: z.coerce.date(),
@@ -23,12 +22,24 @@ const eventInput = z
allDay: z.boolean().default(false),
location: z.string().trim().max(300).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"],
message: "End must be after start",
},
);
export async function createCalendar(input: z.input<typeof calendarInput>) {
const parsed = calendarInput.parse(input);
const { user, household } = await getCurrentSession();
@@ -79,9 +90,7 @@ export async function setCalendarVisibility(input: {
export async function setCalendarColor(input: { id: string; color: string | null }) {
const { user } = await getCurrentSession();
const parsed = z
.object({ id: z.string().uuid(), color: calendarInput.shape.color })
.parse(input);
const parsed = z.object({ id: z.string().uuid(), color: calendarInput.shape.color }).parse(input);
await assertOwnsCalendar(user.id, parsed.id);
await db
.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>>) {
const parsed = z
.object({ id: z.string().uuid() })
.and(eventInput.partial())
.parse(input);
const parsed = z.object({ id: z.string().uuid() }).and(eventUpdateInput).parse(input);
const { user } = await getCurrentSession();
const [existing] = await db
.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>
);
}
-23
View File
@@ -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;
+92
View File
@@ -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;
+46
View File
@@ -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;
+201
View File
@@ -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;
}
+65
View File
@@ -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;
}
+256
View File
@@ -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(),
};
}
+12
View File
@@ -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() })})`,
);
}
+8 -8
View File
@@ -10,19 +10,19 @@ test("calendar CRUD happy path", async ({ page }) => {
await page.goto("/calendar");
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 expect(page.getByDisplayValue(calendarName)).toBeVisible();
await expect(page.getByLabel(`${calendarName} name`)).toBeVisible();
await page.getByRole("button", { name: "New event" }).click();
await page.getByLabel("Title").fill(eventTitle);
await page.getByLabel("Start").fill("2026-06-15T09:00");
await page.getByLabel("End").fill("2026-06-15T10:00");
await page.getByLabel("Title", { exact: true }).fill(eventTitle);
await page.getByLabel("Start", { exact: true }).fill("2026-05-15T09:00");
await page.getByLabel("End", { exact: true }).fill("2026-05-15T10:00");
await page.getByRole("button", { name: "Save event" }).click();
await expect(page.getByText(eventTitle)).toBeVisible();
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 expect(page.getByText(editedTitle)).toBeVisible();
@@ -31,7 +31,7 @@ test("calendar CRUD happy path", async ({ page }) => {
await expect(page.getByText(editedTitle)).toBeHidden();
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 expect(page.getByDisplayValue(renamedCalendarName)).toBeHidden();
await expect(page.getByLabel(`${renamedCalendarName} name`)).toBeHidden();
});
+26
View File
@@ -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();
});