Implement lists module and dev login setup
This commit is contained in:
@@ -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() })})`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user