v0.5.1 — Reminders, plant groups, location views, Wikipedia photos, Docker deploy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Bonna Moon
2026-06-15 20:53:03 -05:00
parent e3f6a8ec92
commit 04dec2a198
32 changed files with 1988 additions and 181 deletions

View File

@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
const schema = z.object({
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
notes: z.string().optional(),
quantity: z.string().optional(),
date: z.string().optional(),
});
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
const group = await db.plantGroup.findUnique({
where: { id: params.id },
include: { plants: { where: { active: true }, select: { id: true } } },
});
if (!group || !group.active) return NextResponse.json({ error: "Not found" }, { status: 404 });
const data = schema.parse(await req.json());
const date = data.date ? new Date(data.date) : new Date();
// Write a log entry for every active plant in the group.
const logs = await db.$transaction(
group.plants.map((p) =>
db.plantLog.create({
data: {
plantId: p.id,
type: data.type,
date,
notes: data.notes?.trim() || null,
quantity: data.quantity?.trim() || null,
actorId: session.user.id,
},
})
)
);
await db.auditEvent.create({
data: {
action: "plant_group.log",
entityId: group.id,
actorId: session.user.id,
payload: { groupName: group.name, type: data.type, plantCount: logs.length },
},
});
return NextResponse.json({ count: logs.length }, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError) return NextResponse.json({ error: err.issues[0]?.message }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
const schema = z.object({
name: z.string().min(1),
notes: z.string().optional(),
});
export async function GET() {
const groups = await db.plantGroup.findMany({
where: { active: true },
orderBy: { name: "asc" },
include: { _count: { select: { plants: true } } },
});
return NextResponse.json(groups);
}
export async function POST(req: Request) {
try {
const session = await requireAuth();
const data = schema.parse(await req.json());
const group = await db.plantGroup.create({
data: { name: data.name.trim(), notes: data.notes?.trim() || null },
});
await db.auditEvent.create({
data: { action: "plant_group.create", entityId: group.id, actorId: session.user.id, payload: { name: group.name } },
});
return NextResponse.json(group, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError) return NextResponse.json({ error: err.issues[0]?.message }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth, isAdmin } from "@/lib/auth";
import { fetchPlantImageUrl } from "@/lib/garden/plant-image";
const updateSchema = z.object({
commonName: z.string().min(1),
species: z.string().optional(),
@@ -11,6 +12,7 @@ const updateSchema = z.object({
plantedAt: z.string().optional(),
source: z.string().optional(),
notes: z.string().optional(),
groupId: z.string().nullable().optional(),
});
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
@@ -22,17 +24,29 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
const body = await req.json();
const data = updateSchema.parse(body);
const species = data.species?.trim() || null;
const commonName = data.commonName.trim();
// Re-fetch image if species or name changed.
const speciesChanged = species !== plant.species;
const nameChanged = commonName !== plant.commonName;
const imageUrl = (speciesChanged || nameChanged || !plant.imageUrl)
? await fetchPlantImageUrl(species, commonName)
: undefined;
const updated = await db.plant.update({
where: { id: params.id },
data: {
commonName: data.commonName.trim(),
species: data.species?.trim() || null,
commonName,
species,
variety: data.variety?.trim() || null,
category: data.category,
zone: data.zone?.trim() || null,
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
source: data.source?.trim() || null,
notes: data.notes?.trim() || null,
groupId: data.groupId ?? null,
...(imageUrl !== undefined ? { imageUrl } : {}),
},
});

View File

@@ -2,15 +2,17 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
import { fetchPlantImageUrl } from "@/lib/garden/plant-image";
const createSchema = z.object({
commonName: z.string().min(1),
species: z.string().optional(),
variety: z.string().optional(),
category: z.string().min(1),
zone: z.string().optional(),
plantedAt: z.string().optional(), // ISO date string
plantedAt: z.string().optional(),
source: z.string().optional(),
notes: z.string().optional(),
groupId: z.string().optional(),
});
export async function POST(req: Request) {
@@ -19,16 +21,22 @@ export async function POST(req: Request) {
const body = await req.json();
const data = createSchema.parse(body);
const species = data.species?.trim() || null;
const commonName = data.commonName.trim();
const imageUrl = await fetchPlantImageUrl(species, commonName);
const plant = await db.plant.create({
data: {
commonName: data.commonName.trim(),
species: data.species?.trim() || null,
commonName,
species,
variety: data.variety?.trim() || null,
category: data.category,
zone: data.zone?.trim() || null,
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
source: data.source?.trim() || null,
notes: data.notes?.trim() || null,
groupId: data.groupId || null,
imageUrl,
},
});

View File

@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
import { nextDueDate } from "@/lib/tasks/schedule";
const schema = z.object({ notes: z.string().optional() });
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
const task = await db.task.findUnique({ where: { id: params.id } });
if (!task || !task.active) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json().catch(() => ({}));
const data = schema.parse(body);
const now = new Date();
const next = nextDueDate(task.recurrence, now, task.intervalDays, task.month);
// Record the completion and advance the due date in one transaction.
const [completion] = await db.$transaction([
db.taskCompletion.create({
data: {
taskId: task.id,
doneAt: now,
notes: data.notes?.trim() || null,
actorId: session.user.id,
},
}),
db.task.update({
where: { id: task.id },
data: {
lastDone: now,
nextDue: next ?? task.nextDue,
// Deactivate one-time tasks once completed.
active: task.recurrence === "ONCE" ? false : true,
},
}),
]);
await db.auditEvent.create({
data: {
action: "task.complete",
entityId: task.id,
actorId: session.user.id,
payload: { title: task.title, nextDue: next },
},
});
return NextResponse.json({ completion, nextDue: next });
} catch (err) {
if (err instanceof z.ZodError) return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
import { nextDueDate } from "@/lib/tasks/schedule";
const createSchema = z.object({
title: z.string().min(1),
notes: z.string().optional(),
category: z.enum(["GARDEN", "HOME", "EQUIPMENT", "OTHER"]),
plantId: z.string().optional(),
recurrence: z.enum(["ONCE", "CUSTOM", "MONTHLY", "YEARLY"]),
intervalDays: z.number().int().positive().optional(),
month: z.number().int().min(1).max(12).optional(),
nextDue: z.string(), // ISO date string
});
export async function POST(req: Request) {
try {
const session = await requireAuth();
const body = await req.json();
const data = createSchema.parse(body);
const task = await db.task.create({
data: {
title: data.title.trim(),
notes: data.notes?.trim() || null,
category: data.category,
plantId: data.plantId || null,
recurrence: data.recurrence,
intervalDays: data.intervalDays ?? null,
month: data.month ?? null,
nextDue: new Date(data.nextDue),
},
});
await db.auditEvent.create({
data: {
action: "task.create",
entityId: task.id,
actorId: session.user.id,
payload: { title: task.title, recurrence: task.recurrence },
},
});
return NextResponse.json(task, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError) return NextResponse.json({ error: err.issues[0]?.message ?? "Invalid data" }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
const schema = z.object({
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
notes: z.string().optional(),
quantity: z.string().optional(),
date: z.string().optional(),
});
export async function POST(req: Request, { params }: { params: { zone: string } }) {
try {
const session = await requireAuth();
const zoneName = decodeURIComponent(params.zone);
const plants = await db.plant.findMany({
where: { zone: zoneName, active: true },
select: { id: true },
});
if (plants.length === 0) return NextResponse.json({ error: "No plants found in this zone" }, { status: 404 });
const data = schema.parse(await req.json());
const date = data.date ? new Date(data.date) : new Date();
const logs = await db.$transaction(
plants.map((p) =>
db.plantLog.create({
data: {
plantId: p.id,
type: data.type,
date,
notes: data.notes?.trim() || null,
quantity: data.quantity?.trim() || null,
actorId: session.user.id,
},
})
)
);
await db.auditEvent.create({
data: {
action: "zone.log",
actorId: session.user.id,
payload: { zone: zoneName, type: data.type, plantCount: logs.length },
},
});
return NextResponse.json({ count: logs.length }, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError) return NextResponse.json({ error: err.issues[0]?.message }, { status: 400 });
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}