Calendar events support multiple reminder offsets with presets and per-user defaults. Lists index adds inline task entry and list property editing. Generic entity comments on list detail. New bangs.stats dashboard widget. Closes Gitea #28, #29, #30, #31. Migrations 0022 and 0023.
339 lines
10 KiB
TypeScript
339 lines
10 KiB
TypeScript
"use server";
|
|
|
|
import { and, eq, max } from "drizzle-orm";
|
|
import { revalidatePath } from "next/cache";
|
|
import { z } from "zod";
|
|
import type { ApiAuthContext } from "@/lib/api-auth";
|
|
import { db } from "@/lib/db";
|
|
import { getCurrentSession } from "@/lib/session";
|
|
import { logActivityForScope } from "@/modules/_core/activity";
|
|
import { fireItemToggleHooks } from "@/modules/_core/registry";
|
|
import { listItems, lists } from "../schema";
|
|
import { canAccessList, getList, getListForScope } from "./queries";
|
|
import { notifyListChanged } from "./realtime";
|
|
import { itemInput, listInput, listUpdateInput, updateItemInput } from "./schemas";
|
|
|
|
function toScope(ctx: ApiAuthContext) {
|
|
return { householdId: ctx.householdId, userId: ctx.userId };
|
|
}
|
|
|
|
export async function createListForScope(scope: ApiAuthContext, input: z.input<typeof listInput>) {
|
|
const parsed = listInput.parse(input);
|
|
const [list] = await db
|
|
.insert(lists)
|
|
.values({
|
|
householdId: scope.householdId,
|
|
type: parsed.type,
|
|
name: parsed.name,
|
|
})
|
|
.returning();
|
|
|
|
if (!list) throw new Error("List was not created");
|
|
await logActivityForScope(toScope(scope), {
|
|
entityType: "lists.list",
|
|
entityId: list.id,
|
|
action: "create",
|
|
payload: { name: list.name },
|
|
});
|
|
return list;
|
|
}
|
|
|
|
export async function createList(input: z.input<typeof listInput>) {
|
|
const { household, user } = await getCurrentSession();
|
|
const list = await createListForScope(
|
|
{ householdId: household.id, userId: user.id, role: null },
|
|
input,
|
|
);
|
|
revalidatePath("/lists");
|
|
return list;
|
|
}
|
|
|
|
export async function updateListForScope(
|
|
scope: ApiAuthContext,
|
|
input: { id: string } & z.input<typeof listUpdateInput>,
|
|
) {
|
|
const parsed = z.object({ id: z.string().uuid() }).and(listUpdateInput).parse(input);
|
|
await assertCanAccessList(parsed.id, scope.householdId);
|
|
|
|
await db
|
|
.update(lists)
|
|
.set({
|
|
name: parsed.name,
|
|
type: parsed.type,
|
|
archived: parsed.archived,
|
|
})
|
|
.where(eq(lists.id, parsed.id));
|
|
|
|
if (parsed.name || parsed.type) {
|
|
await logActivityForScope(toScope(scope), {
|
|
entityType: "lists.list",
|
|
entityId: parsed.id,
|
|
action: "update",
|
|
payload: {
|
|
...(parsed.name ? { name: parsed.name } : {}),
|
|
...(parsed.type ? { type: parsed.type } : {}),
|
|
},
|
|
});
|
|
}
|
|
if (parsed.archived === true) {
|
|
await logActivityForScope(toScope(scope), {
|
|
entityType: "lists.list",
|
|
entityId: parsed.id,
|
|
action: "archive",
|
|
});
|
|
}
|
|
|
|
await notifyListChanged(parsed.id);
|
|
}
|
|
|
|
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, user } = await getCurrentSession();
|
|
await updateListForScope(
|
|
{ householdId: household.id, userId: user.id, role: null },
|
|
{ id: parsed.id, name: parsed.name },
|
|
);
|
|
revalidatePath("/lists");
|
|
revalidatePath(`/lists/${parsed.id}`);
|
|
}
|
|
|
|
export async function updateListProperties(input: { id: string; name?: string; type?: string }) {
|
|
const parsed = z
|
|
.object({
|
|
id: z.string().uuid(),
|
|
name: listInput.shape.name.optional(),
|
|
type: listInput.shape.type.optional(),
|
|
})
|
|
.parse(input);
|
|
const { household, user } = await getCurrentSession();
|
|
await updateListForScope({ householdId: household.id, userId: user.id, role: null }, parsed);
|
|
revalidatePath("/lists");
|
|
revalidatePath(`/lists/${parsed.id}`);
|
|
}
|
|
|
|
export async function archiveList(input: { id: string }) {
|
|
const parsed = z.object({ id: z.string().uuid() }).parse(input);
|
|
const { household, user } = await getCurrentSession();
|
|
await updateListForScope(
|
|
{ householdId: household.id, userId: user.id, role: null },
|
|
{ id: parsed.id, archived: true },
|
|
);
|
|
revalidatePath("/lists");
|
|
revalidatePath(`/lists/${parsed.id}`);
|
|
}
|
|
|
|
export async function deleteListForScope(scope: ApiAuthContext, input: { id: string }) {
|
|
await updateListForScope(scope, { id: input.id, archived: true });
|
|
}
|
|
|
|
export async function addItemForScope(scope: ApiAuthContext, input: z.input<typeof itemInput>) {
|
|
const parsed = itemInput.parse(input);
|
|
await assertCanAccessList(parsed.listId, scope.householdId);
|
|
|
|
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,
|
|
metadata: parsed.metadata ?? null,
|
|
position: (positionRow?.maxPosition ?? -1) + 1,
|
|
})
|
|
.returning();
|
|
|
|
if (!item) throw new Error("List item was not created");
|
|
await logActivityForScope(toScope(scope), {
|
|
entityType: "lists.item",
|
|
entityId: item.id,
|
|
action: "create",
|
|
payload: { text: item.text },
|
|
});
|
|
await notifyListChanged(parsed.listId);
|
|
return toItemDto(item);
|
|
}
|
|
|
|
export async function addItem(input: z.input<typeof itemInput>) {
|
|
const parsed = itemInput.parse(input);
|
|
const { household, user } = await getCurrentSession();
|
|
await addItemForScope({ householdId: household.id, userId: user.id, role: null }, parsed);
|
|
revalidatePath(`/lists/${parsed.listId}`);
|
|
return getList(parsed.listId);
|
|
}
|
|
|
|
export async function toggleItemForScope(
|
|
scope: ApiAuthContext,
|
|
input: { id: string; done?: boolean },
|
|
) {
|
|
const parsed = z.object({ id: z.string().uuid(), done: z.boolean().optional() }).parse(input);
|
|
const existing = await getAuthorizedItem(parsed.id, scope.householdId);
|
|
const done = parsed.done ?? !existing.done;
|
|
|
|
await db
|
|
.update(listItems)
|
|
.set({ done, updatedAt: new Date() })
|
|
.where(eq(listItems.id, parsed.id));
|
|
|
|
await logActivityForScope(toScope(scope), {
|
|
entityType: "lists.item",
|
|
entityId: parsed.id,
|
|
action: "toggle",
|
|
payload: { done, text: existing.text },
|
|
});
|
|
|
|
if (done && existing.metadata && scope.userId) {
|
|
await fireItemToggleHooks({
|
|
itemId: parsed.id,
|
|
metadata: existing.metadata as Record<string, unknown>,
|
|
done,
|
|
userId: scope.userId,
|
|
});
|
|
}
|
|
|
|
await notifyListChanged(existing.listId);
|
|
return getListForScope(scope.householdId, existing.listId);
|
|
}
|
|
|
|
export async function toggleItem(input: { id: string; done?: boolean }) {
|
|
const { household, user } = await getCurrentSession();
|
|
const list = await toggleItemForScope(
|
|
{ householdId: household.id, userId: user.id, role: null },
|
|
input,
|
|
);
|
|
revalidatePath(`/lists/${list.id}`);
|
|
return list;
|
|
}
|
|
|
|
export async function updateItemForScope(
|
|
scope: ApiAuthContext,
|
|
input: z.input<typeof updateItemInput>,
|
|
) {
|
|
const parsed = updateItemInput.parse(input);
|
|
const existing = await getAuthorizedItem(parsed.id, scope.householdId);
|
|
|
|
if (parsed.done !== undefined) {
|
|
return toggleItemForScope(scope, { id: parsed.id, done: parsed.done });
|
|
}
|
|
|
|
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));
|
|
|
|
await logActivityForScope(toScope(scope), {
|
|
entityType: "lists.item",
|
|
entityId: parsed.id,
|
|
action: "update",
|
|
payload: { text: parsed.text ?? existing.text },
|
|
});
|
|
await notifyListChanged(existing.listId);
|
|
return getListForScope(scope.householdId, existing.listId);
|
|
}
|
|
|
|
export async function updateItem(input: z.input<typeof updateItemInput>) {
|
|
const { household, user } = await getCurrentSession();
|
|
const list = await updateItemForScope(
|
|
{ householdId: household.id, userId: user.id, role: null },
|
|
input,
|
|
);
|
|
revalidatePath(`/lists/${list.id}`);
|
|
return list;
|
|
}
|
|
|
|
export async function deleteItemForScope(scope: ApiAuthContext, input: { id: string }) {
|
|
const parsed = z.object({ id: z.string().uuid() }).parse(input);
|
|
const existing = await getAuthorizedItem(parsed.id, scope.householdId);
|
|
await logActivityForScope(toScope(scope), {
|
|
entityType: "lists.item",
|
|
entityId: parsed.id,
|
|
action: "delete",
|
|
payload: { text: existing.text },
|
|
});
|
|
await db.delete(listItems).where(eq(listItems.id, parsed.id));
|
|
await notifyListChanged(existing.listId);
|
|
return getListForScope(scope.householdId, existing.listId);
|
|
}
|
|
|
|
export async function deleteItem(input: { id: string }) {
|
|
const { household, user } = await getCurrentSession();
|
|
const list = await deleteItemForScope(
|
|
{ householdId: household.id, userId: user.id, role: null },
|
|
input,
|
|
);
|
|
revalidatePath(`/lists/${list.id}`);
|
|
return list;
|
|
}
|
|
|
|
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,
|
|
text: listItems.text,
|
|
metadata: listItems.metadata,
|
|
})
|
|
.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;
|
|
}
|
|
|
|
function toItemDto(item: typeof listItems.$inferSelect) {
|
|
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(),
|
|
};
|
|
}
|