feat: api v1 garden bangs routes and openapi

This commit is contained in:
ginnoir
2026-07-04 19:13:49 -05:00
parent d4304b005c
commit e8d13bede8
16 changed files with 1351 additions and 113 deletions
+52 -33
View File
@@ -3,65 +3,71 @@
import { and, eq } 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 { logActivity } from "@/modules/_core/activity";
import { logActivityForScope } from "@/modules/_core/activity";
import { bangEvents } from "../schema";
const dateString = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD");
const addBangInput = z.object({
occurredOn: dateString.optional(),
});
const updateBangInput = z.object({
id: z.string().uuid(),
occurredOn: dateString,
});
import { addBangInput, updateBangInput } from "./schemas";
const deleteBangInput = z.object({
id: z.string().uuid(),
});
function toScope(ctx: ApiAuthContext) {
return { householdId: ctx.householdId, userId: ctx.userId };
}
function todayString(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export async function addBang(input: z.input<typeof addBangInput> = {}) {
export async function addBangForScope(
scope: ApiAuthContext,
input: z.input<typeof addBangInput> = {},
) {
const parsed = addBangInput.parse(input);
const { user, household } = await getCurrentSession();
const occurredOn = parsed.occurredOn ?? todayString();
const [bang] = await db
.insert(bangEvents)
.values({
householdId: household.id,
recordedBy: user.id,
householdId: scope.householdId,
recordedBy: scope.userId,
occurredOn,
})
.returning();
if (!bang) throw new Error("Bang was not recorded");
await logActivity({
await logActivityForScope(toScope(scope), {
entityType: "bangs.event",
entityId: bang.id,
action: "create",
payload: { occurredOn },
});
revalidatePath("/");
revalidatePath("/d/[slug]", "page");
return bang;
}
export async function updateBang(input: z.input<typeof updateBangInput>) {
const parsed = updateBangInput.parse(input);
const { household } = await getCurrentSession();
await assertCanAccessBang(parsed.id, household.id);
export async function addBang(input: z.input<typeof addBangInput> = {}) {
const { user, household } = await getCurrentSession();
const bang = await addBangForScope(
{ householdId: household.id, userId: user.id, role: null },
input,
);
revalidatePath("/");
revalidatePath("/d/[slug]", "page");
return bang;
}
export async function updateBangForScope(
scope: ApiAuthContext,
input: { id: string } & z.input<typeof updateBangInput>,
) {
const parsed = z.object({ id: z.string().uuid() }).and(updateBangInput).parse(input);
await assertCanAccessBang(parsed.id, scope.householdId);
const [bang] = await db
.update(bangEvents)
@@ -71,23 +77,31 @@ export async function updateBang(input: z.input<typeof updateBangInput>) {
if (!bang) throw new Error("Bang was not updated");
await logActivity({
await logActivityForScope(toScope(scope), {
entityType: "bangs.event",
entityId: bang.id,
action: "update",
payload: { occurredOn: parsed.occurredOn },
});
revalidatePath("/");
revalidatePath("/d/[slug]", "page");
return bang;
}
export async function deleteBang(input: z.input<typeof deleteBangInput>) {
export async function updateBang(input: { id: string } & z.input<typeof updateBangInput>) {
const parsed = z.object({ id: z.string().uuid() }).and(updateBangInput).parse(input);
const { household, user } = await getCurrentSession();
const bang = await updateBangForScope(
{ householdId: household.id, userId: user.id, role: null },
parsed,
);
revalidatePath("/");
revalidatePath("/d/[slug]", "page");
return bang;
}
export async function deleteBangForScope(scope: ApiAuthContext, input: { id: string }) {
const parsed = deleteBangInput.parse(input);
const { household } = await getCurrentSession();
await assertCanAccessBang(parsed.id, household.id);
await assertCanAccessBang(parsed.id, scope.householdId);
const [existing] = await db
.select({ occurredOn: bangEvents.occurredOn })
@@ -95,7 +109,7 @@ export async function deleteBang(input: z.input<typeof deleteBangInput>) {
.where(eq(bangEvents.id, parsed.id))
.limit(1);
await logActivity({
await logActivityForScope(toScope(scope), {
entityType: "bangs.event",
entityId: parsed.id,
action: "delete",
@@ -103,7 +117,12 @@ export async function deleteBang(input: z.input<typeof deleteBangInput>) {
});
await db.delete(bangEvents).where(eq(bangEvents.id, parsed.id));
}
export async function deleteBang(input: z.input<typeof deleteBangInput>) {
const parsed = deleteBangInput.parse(input);
const { household, user } = await getCurrentSession();
await deleteBangForScope({ householdId: household.id, userId: user.id, role: null }, parsed);
revalidatePath("/");
revalidatePath("/d/[slug]", "page");
}
+13 -2
View File
@@ -14,7 +14,10 @@ export type RecentBangDto = {
recordedByName: string | null;
};
export async function getBangStats(householdId: string, limit: number): Promise<BangStatsDto> {
export async function getBangStatsForScope(
householdId: string,
limit: number,
): Promise<BangStatsDto> {
const [totalRow] = await db
.select({ total: count() })
.from(bangEvents)
@@ -34,6 +37,14 @@ export async function getBangStats(householdId: string, limit: number): Promise<
return {
total: totalRow?.total ?? 0,
recent,
recent: recent.map((r) => ({
id: r.id,
occurredOn: r.occurredOn,
recordedByName: r.recordedByName,
})),
};
}
export async function getBangStats(householdId: string, limit: number): Promise<BangStatsDto> {
return getBangStatsForScope(householdId, limit);
}
+11
View File
@@ -0,0 +1,11 @@
import { z } from "zod";
export const dateString = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD");
export const addBangInput = z.object({
occurredOn: dateString.optional(),
});
export const updateBangInput = z.object({
occurredOn: dateString,
});
+84 -53
View File
@@ -3,9 +3,10 @@
import { and, eq, lte, sql } 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 { logActivity } from "@/modules/_core/activity";
import { logActivity, logActivityForScope } from "@/modules/_core/activity";
import { cancelReminder, scheduleReminder } from "@/modules/_core/reminders";
import { listItems } from "@/modules/lists/schema";
import { notifyListChanged } from "@/modules/lists/server/realtime";
@@ -14,22 +15,22 @@ import { createCalendarEvent } from "./calendar-bridge";
import { buildCareReminderBody, buildCareTitle } from "./care-utils";
import { updateScheduleAfterCare } from "./care-schedule";
import { addGardenCareTask, getList, listLists } from "./lists-bridge";
import { containerInput, containerUpdateInput, plantInput, plantUpdateInput } from "./schemas";
const containerInput = z.object({
name: z.string().trim().min(1).max(120),
type: z.string().trim().min(1).max(40).default("other"),
locationNotes: z.string().trim().max(500).nullable().optional(),
coverImageUrl: z.string().trim().max(500).nullable().optional(),
});
function toScope(ctx: ApiAuthContext) {
return { householdId: ctx.householdId, userId: ctx.userId };
}
export async function createContainer(input: z.input<typeof containerInput>) {
export async function createContainerForScope(
scope: ApiAuthContext,
input: z.input<typeof containerInput>,
) {
const parsed = containerInput.parse(input);
const { household } = await getCurrentSession();
const [container] = await db
.insert(gardenContainers)
.values({
householdId: household.id,
householdId: scope.householdId,
name: parsed.name,
type: parsed.type,
locationNotes: parsed.locationNotes ?? null,
@@ -39,22 +40,31 @@ export async function createContainer(input: z.input<typeof containerInput>) {
if (!container) throw new Error("Container was not created");
await logActivity({
await logActivityForScope(toScope(scope), {
entityType: "garden.container",
entityId: container.id,
action: "create",
payload: { name: container.name },
});
return container;
}
export async function createContainer(input: z.input<typeof containerInput>) {
const { household, user } = await getCurrentSession();
const container = await createContainerForScope(
{ householdId: household.id, userId: user.id, role: null },
input,
);
revalidatePath("/garden");
return container;
}
export async function updateContainer(
input: { id: string } & Partial<z.input<typeof containerInput>>,
export async function updateContainerForScope(
scope: ApiAuthContext,
input: { id: string } & z.input<typeof containerUpdateInput>,
) {
const parsed = z.object({ id: z.string().uuid() }).and(containerInput.partial()).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessContainer(parsed.id, household.id);
const parsed = z.object({ id: z.string().uuid() }).and(containerUpdateInput).parse(input);
await assertCanAccessContainer(parsed.id, scope.householdId);
await db
.update(gardenContainers)
@@ -69,27 +79,40 @@ export async function updateContainer(
})
.where(eq(gardenContainers.id, parsed.id));
await logActivity({
await logActivityForScope(toScope(scope), {
entityType: "garden.container",
entityId: parsed.id,
action: "update",
payload: { name: parsed.name },
});
}
export async function updateContainer(
input: { id: string } & Partial<z.input<typeof containerInput>>,
) {
const parsed = z.object({ id: z.string().uuid() }).and(containerInput.partial()).parse(input);
const { household, user } = await getCurrentSession();
await updateContainerForScope({ householdId: household.id, userId: user.id, role: null }, parsed);
revalidatePath("/garden");
revalidatePath(`/garden/containers/${parsed.id}`);
}
export async function deleteContainer(input: { id: string }) {
export async function deleteContainerForScope(scope: ApiAuthContext, input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessContainer(parsed.id, household.id);
await assertCanAccessContainer(parsed.id, scope.householdId);
await logActivity({
await logActivityForScope(toScope(scope), {
entityType: "garden.container",
entityId: parsed.id,
action: "delete",
});
await db.delete(gardenContainers).where(eq(gardenContainers.id, parsed.id));
}
export async function deleteContainer(input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input);
const { household, user } = await getCurrentSession();
await deleteContainerForScope({ householdId: household.id, userId: user.id, role: null }, parsed);
revalidatePath("/garden");
}
@@ -179,35 +202,20 @@ async function assertCanAccessContainer(id: string, householdId: string) {
// ─── Plant actions ────────────────────────────────────────────────────────────
const plantInput = z.object({
name: z.string().trim().min(1).max(120),
category: z.string().trim().min(1).max(40).default("other"),
containerId: z.string().uuid().nullable().optional(),
healthStatus: z.string().trim().min(1).max(40).default("healthy"),
growthStage: z.string().trim().max(40).nullable().optional(),
scientificName: z.string().trim().max(200).nullable().optional(),
speciesId: z.string().trim().max(100).nullable().optional(),
sunlight: z.string().trim().max(200).nullable().optional(),
wateringNotes: z.string().trim().max(2000).nullable().optional(),
fertilizingNotes: z.string().trim().max(2000).nullable().optional(),
notes: z.string().trim().max(2000).nullable().optional(),
acquisitionDate: z.string().nullable().optional(),
images: z.array(z.string().min(1)).max(10).default([]),
primaryImageUrl: z.string().min(1).nullable().optional(),
});
export async function createPlant(input: z.input<typeof plantInput>) {
export async function createPlantForScope(
scope: ApiAuthContext,
input: z.input<typeof plantInput>,
) {
const parsed = plantInput.parse(input);
const { household } = await getCurrentSession();
if (parsed.containerId) {
await assertCanAccessContainer(parsed.containerId, household.id);
await assertCanAccessContainer(parsed.containerId, scope.householdId);
}
const [plant] = await db
.insert(gardenPlants)
.values({
householdId: household.id,
householdId: scope.householdId,
name: parsed.name,
category: parsed.category ?? "other",
containerId: parsed.containerId ?? null,
@@ -227,23 +235,35 @@ export async function createPlant(input: z.input<typeof plantInput>) {
if (!plant) throw new Error("Plant was not created");
await logActivity({
await logActivityForScope(toScope(scope), {
entityType: "garden.plant",
entityId: plant.id,
action: "create",
payload: { name: plant.name },
});
return plant;
}
export async function createPlant(input: z.input<typeof plantInput>) {
const parsed = plantInput.parse(input);
const { household, user } = await getCurrentSession();
const plant = await createPlantForScope(
{ householdId: household.id, userId: user.id, role: null },
parsed,
);
revalidatePath("/garden");
return plant;
}
export async function updatePlant(input: { id: string } & Partial<z.input<typeof plantInput>>) {
const parsed = z.object({ id: z.string().uuid() }).and(plantInput.partial()).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessPlant(parsed.id, household.id);
export async function updatePlantForScope(
scope: ApiAuthContext,
input: { id: string } & z.input<typeof plantUpdateInput>,
) {
const parsed = z.object({ id: z.string().uuid() }).and(plantUpdateInput).parse(input);
await assertCanAccessPlant(parsed.id, scope.householdId);
if (parsed.containerId) {
await assertCanAccessContainer(parsed.containerId, household.id);
await assertCanAccessContainer(parsed.containerId, scope.householdId);
}
await db
@@ -272,20 +292,25 @@ export async function updatePlant(input: { id: string } & Partial<z.input<typeof
})
.where(eq(gardenPlants.id, parsed.id));
await logActivity({
await logActivityForScope(toScope(scope), {
entityType: "garden.plant",
entityId: parsed.id,
action: "update",
payload: { name: parsed.name },
});
}
export async function updatePlant(input: { id: string } & Partial<z.input<typeof plantInput>>) {
const parsed = z.object({ id: z.string().uuid() }).and(plantInput.partial()).parse(input);
const { household, user } = await getCurrentSession();
await updatePlantForScope({ householdId: household.id, userId: user.id, role: null }, parsed);
revalidatePath("/garden");
revalidatePath(`/garden/plants/${parsed.id}`);
}
export async function deletePlant(input: { id: string }) {
export async function deletePlantForScope(scope: ApiAuthContext, input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessPlant(parsed.id, household.id);
await assertCanAccessPlant(parsed.id, scope.householdId);
const [plant] = await db
.select({ name: gardenPlants.name })
@@ -293,13 +318,19 @@ export async function deletePlant(input: { id: string }) {
.where(eq(gardenPlants.id, parsed.id))
.limit(1);
await logActivity({
await logActivityForScope(toScope(scope), {
entityType: "garden.plant",
entityId: parsed.id,
action: "delete",
payload: { name: plant?.name },
});
await db.delete(gardenPlants).where(eq(gardenPlants.id, parsed.id));
}
export async function deletePlant(input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input);
const { household, user } = await getCurrentSession();
await deletePlantForScope({ householdId: household.id, userId: user.id, role: null }, parsed);
revalidatePath("/garden");
}
+37 -16
View File
@@ -37,9 +37,7 @@ export type PlantSummaryDto = {
category: string;
};
export async function listContainers(): Promise<ContainerDto[]> {
const { household } = await getCurrentSession();
export async function listContainersForScope(householdId: string): Promise<ContainerDto[]> {
const [rows, countRows] = await Promise.all([
db
.select({
@@ -54,7 +52,7 @@ export async function listContainers(): Promise<ContainerDto[]> {
updatedAt: gardenContainers.updatedAt,
})
.from(gardenContainers)
.where(eq(gardenContainers.householdId, household.id))
.where(eq(gardenContainers.householdId, householdId))
.orderBy(gardenContainers.name),
db
.select({
@@ -62,7 +60,7 @@ export async function listContainers(): Promise<ContainerDto[]> {
plantCount: sql<number>`count(*)::int`,
})
.from(gardenPlants)
.where(eq(gardenPlants.householdId, household.id))
.where(eq(gardenPlants.householdId, householdId))
.groupBy(gardenPlants.containerId),
]);
@@ -82,9 +80,15 @@ export async function listContainers(): Promise<ContainerDto[]> {
}));
}
export async function getContainer(id: string): Promise<ContainerDetailDto | null> {
export async function listContainers(): Promise<ContainerDto[]> {
const { household } = await getCurrentSession();
return listContainersForScope(household.id);
}
export async function getContainerForScope(
householdId: string,
id: string,
): Promise<ContainerDetailDto | null> {
const [container] = await db
.select({
id: gardenContainers.id,
@@ -98,7 +102,7 @@ export async function getContainer(id: string): Promise<ContainerDetailDto | nul
updatedAt: gardenContainers.updatedAt,
})
.from(gardenContainers)
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, household.id)))
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, householdId)))
.limit(1);
if (!container) return null;
@@ -131,6 +135,11 @@ export async function getContainer(id: string): Promise<ContainerDetailDto | nul
};
}
export async function getContainer(id: string): Promise<ContainerDetailDto | null> {
const { household } = await getCurrentSession();
return getContainerForScope(household.id, id);
}
export async function searchContainers(query: string, householdId: string) {
const rows = await db
.select({ id: gardenContainers.id, name: gardenContainers.name })
@@ -203,14 +212,13 @@ export type PlantDetailDto = {
updatedAt: string;
};
export async function listPlants({ containerId }: { containerId?: string } = {}): Promise<
PlantListItemDto[]
> {
const { household } = await getCurrentSession();
export async function listPlantsForScope(
householdId: string,
{ containerId }: { containerId?: string } = {},
): Promise<PlantListItemDto[]> {
const filter = containerId
? and(eq(gardenPlants.householdId, household.id), eq(gardenPlants.containerId, containerId))
: eq(gardenPlants.householdId, household.id);
? and(eq(gardenPlants.householdId, householdId), eq(gardenPlants.containerId, containerId))
: eq(gardenPlants.householdId, householdId);
const rows = await db
.select({
@@ -248,9 +256,17 @@ export async function listPlants({ containerId }: { containerId?: string } = {})
}));
}
export async function getPlant(id: string): Promise<PlantDetailDto | null> {
export async function listPlants({ containerId }: { containerId?: string } = {}): Promise<
PlantListItemDto[]
> {
const { household } = await getCurrentSession();
return listPlantsForScope(household.id, { containerId });
}
export async function getPlantForScope(
householdId: string,
id: string,
): Promise<PlantDetailDto | null> {
const [row] = await db
.select({
id: gardenPlants.id,
@@ -275,7 +291,7 @@ export async function getPlant(id: string): Promise<PlantDetailDto | null> {
})
.from(gardenPlants)
.leftJoin(gardenContainers, eq(gardenPlants.containerId, gardenContainers.id))
.where(and(eq(gardenPlants.id, id), eq(gardenPlants.householdId, household.id)))
.where(and(eq(gardenPlants.id, id), eq(gardenPlants.householdId, householdId)))
.limit(1);
if (!row) return null;
@@ -339,6 +355,11 @@ export async function getPlant(id: string): Promise<PlantDetailDto | null> {
};
}
export async function getPlant(id: string): Promise<PlantDetailDto | null> {
const { household } = await getCurrentSession();
return getPlantForScope(household.id, id);
}
export async function searchPlants(query: string, householdId: string) {
const rows = await db
.select({
+29
View File
@@ -0,0 +1,29 @@
import { z } from "zod";
export const containerInput = z.object({
name: z.string().trim().min(1).max(120),
type: z.string().trim().min(1).max(40).default("other"),
locationNotes: z.string().trim().max(500).nullable().optional(),
coverImageUrl: z.string().trim().max(500).nullable().optional(),
});
export const containerUpdateInput = containerInput.partial();
export const plantInput = z.object({
name: z.string().trim().min(1).max(120),
category: z.string().trim().min(1).max(40).default("other"),
containerId: z.string().uuid().nullable().optional(),
healthStatus: z.string().trim().min(1).max(40).default("healthy"),
growthStage: z.string().trim().max(40).nullable().optional(),
scientificName: z.string().trim().max(200).nullable().optional(),
speciesId: z.string().trim().max(100).nullable().optional(),
sunlight: z.string().trim().max(200).nullable().optional(),
wateringNotes: z.string().trim().max(2000).nullable().optional(),
fertilizingNotes: z.string().trim().max(2000).nullable().optional(),
notes: z.string().trim().max(2000).nullable().optional(),
acquisitionDate: z.string().nullable().optional(),
images: z.array(z.string().min(1)).max(10).default([]),
primaryImageUrl: z.string().min(1).nullable().optional(),
});
export const plantUpdateInput = plantInput.partial();