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:
57
src/app/api/plant-groups/[id]/log/route.ts
Normal file
57
src/app/api/plant-groups/[id]/log/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
37
src/app/api/plant-groups/route.ts
Normal file
37
src/app/api/plant-groups/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
58
src/app/api/tasks/[id]/complete/route.ts
Normal file
58
src/app/api/tasks/[id]/complete/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
53
src/app/api/tasks/route.ts
Normal file
53
src/app/api/tasks/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
58
src/app/api/zones/[zone]/log/route.ts
Normal file
58
src/app/api/zones/[zone]/log/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user