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_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
|
||||
|
||||
@@ -38,6 +38,7 @@ desktop.ini
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
tests/.auth/
|
||||
|
||||
# Drizzle generated artifacts (migrations themselves are committed)
|
||||
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.
|
||||
|
||||
- **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
|
||||
|
||||
- **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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 { 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) => {
|
||||
|
||||
@@ -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 { 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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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 = {
|
||||
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>
|
||||
|
||||
@@ -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,11 +22,23 @@ 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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 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();
|
||||
});
|
||||
|
||||
@@ -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