feat: api v1 garden bangs routes and openapi
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { apiJson, withApiHandler } from "@/lib/api-handler";
|
||||
import { deleteBangForScope, updateBangForScope } from "@/modules/bangs/server/actions";
|
||||
import { updateBangInput } from "@/modules/bangs/server/schemas";
|
||||
import { z } from "zod";
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return withApiHandler(request, async (scope, req) => {
|
||||
const body: unknown = await req.json();
|
||||
const parsed = updateBangInput.parse(body);
|
||||
const bang = await updateBangForScope(scope, { id, ...parsed });
|
||||
return apiJson({
|
||||
id: bang.id,
|
||||
occurredOn: bang.occurredOn,
|
||||
recordedBy: bang.recordedBy,
|
||||
createdAt: bang.createdAt.toISOString(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return withApiHandler(request, async (scope) => {
|
||||
z.string().uuid().parse(id);
|
||||
await deleteBangForScope(scope, { id });
|
||||
return apiJson({ ok: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { apiJson, withApiHandler } from "@/lib/api-handler";
|
||||
import { addBangForScope } from "@/modules/bangs/server/actions";
|
||||
import { addBangInput } from "@/modules/bangs/server/schemas";
|
||||
import { getBangStatsForScope } from "@/modules/bangs/server/queries";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return withApiHandler(request, async (scope, req) => {
|
||||
const url = new URL(req.url);
|
||||
const limitParam = url.searchParams.get("limit");
|
||||
const limit = limitParam ? Math.min(Math.max(Number(limitParam) || 10, 1), 100) : 10;
|
||||
const stats = await getBangStatsForScope(scope.householdId, limit);
|
||||
return apiJson(stats);
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return withApiHandler(request, async (scope, req) => {
|
||||
const body: unknown = await req.json().catch(() => ({}));
|
||||
const parsed = addBangInput.parse(body);
|
||||
const bang = await addBangForScope(scope, parsed);
|
||||
return apiJson(
|
||||
{
|
||||
id: bang.id,
|
||||
occurredOn: bang.occurredOn,
|
||||
recordedBy: bang.recordedBy,
|
||||
createdAt: bang.createdAt.toISOString(),
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { apiJson, withApiHandler } from "@/lib/api-handler";
|
||||
import { deleteContainerForScope, updateContainerForScope } from "@/modules/garden/server/actions";
|
||||
import { containerUpdateInput } from "@/modules/garden/server/schemas";
|
||||
import { getContainerForScope } from "@/modules/garden/server/queries";
|
||||
import { z } from "zod";
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return withApiHandler(request, async (scope) => {
|
||||
z.string().uuid().parse(id);
|
||||
const container = await getContainerForScope(scope.householdId, id);
|
||||
if (!container) throw new Error("Container not found");
|
||||
return apiJson(container);
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return withApiHandler(request, async (scope, req) => {
|
||||
const body: unknown = await req.json();
|
||||
const parsed = containerUpdateInput.parse(body);
|
||||
await updateContainerForScope(scope, { id, ...parsed });
|
||||
const container = await getContainerForScope(scope.householdId, id);
|
||||
if (!container) throw new Error("Container not found");
|
||||
return apiJson(container);
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return withApiHandler(request, async (scope) => {
|
||||
z.string().uuid().parse(id);
|
||||
await deleteContainerForScope(scope, { id });
|
||||
return apiJson({ ok: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { apiJson, withApiHandler } from "@/lib/api-handler";
|
||||
import { createContainerForScope } from "@/modules/garden/server/actions";
|
||||
import { containerInput } from "@/modules/garden/server/schemas";
|
||||
import { getContainerForScope, listContainersForScope } from "@/modules/garden/server/queries";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return withApiHandler(request, async (scope) => {
|
||||
const containers = await listContainersForScope(scope.householdId);
|
||||
return apiJson(containers);
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return withApiHandler(request, async (scope, req) => {
|
||||
const body: unknown = await req.json();
|
||||
const parsed = containerInput.parse(body);
|
||||
const container = await createContainerForScope(scope, parsed);
|
||||
const detail = await getContainerForScope(scope.householdId, container.id);
|
||||
return apiJson(detail, 201);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { apiJson, withApiHandler } from "@/lib/api-handler";
|
||||
import { deletePlantForScope, updatePlantForScope } from "@/modules/garden/server/actions";
|
||||
import { plantUpdateInput } from "@/modules/garden/server/schemas";
|
||||
import { getPlantForScope } from "@/modules/garden/server/queries";
|
||||
import { z } from "zod";
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return withApiHandler(request, async (scope) => {
|
||||
z.string().uuid().parse(id);
|
||||
const plant = await getPlantForScope(scope.householdId, id);
|
||||
if (!plant) throw new Error("Plant not found");
|
||||
return apiJson(plant);
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return withApiHandler(request, async (scope, req) => {
|
||||
const body: unknown = await req.json();
|
||||
const parsed = plantUpdateInput.parse(body);
|
||||
await updatePlantForScope(scope, { id, ...parsed });
|
||||
const plant = await getPlantForScope(scope.householdId, id);
|
||||
if (!plant) throw new Error("Plant not found");
|
||||
return apiJson(plant);
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return withApiHandler(request, async (scope) => {
|
||||
z.string().uuid().parse(id);
|
||||
await deletePlantForScope(scope, { id });
|
||||
return apiJson({ ok: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { apiJson, withApiHandler } from "@/lib/api-handler";
|
||||
import { createPlantForScope } from "@/modules/garden/server/actions";
|
||||
import { plantInput } from "@/modules/garden/server/schemas";
|
||||
import { getPlantForScope, listPlantsForScope } from "@/modules/garden/server/queries";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return withApiHandler(request, async (scope, req) => {
|
||||
const url = new URL(req.url);
|
||||
const containerId = url.searchParams.get("containerId") ?? undefined;
|
||||
const plants = await listPlantsForScope(scope.householdId, { containerId });
|
||||
return apiJson(plants);
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return withApiHandler(request, async (scope, req) => {
|
||||
const body: unknown = await req.json();
|
||||
const parsed = plantInput.parse(body);
|
||||
const plant = await createPlantForScope(scope, parsed);
|
||||
const detail = await getPlantForScope(scope.householdId, plant.id);
|
||||
return apiJson(detail, 201);
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user