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:
@@ -16,15 +16,19 @@ export async function generateMetadata({ params }: { params: { id: string } }) {
|
||||
}
|
||||
|
||||
export default async function PlantDetailPage({ params }: { params: { id: string } }) {
|
||||
const plant = await db.plant.findUnique({
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
logs: { orderBy: { date: "desc" } },
|
||||
},
|
||||
});
|
||||
const [plant, allPlants, groups] = await Promise.all([
|
||||
db.plant.findUnique({
|
||||
where: { id: params.id },
|
||||
include: { logs: { orderBy: { date: "desc" } } },
|
||||
}),
|
||||
db.plant.findMany({ where: { active: true }, select: { zone: true } }),
|
||||
db.plantGroup.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||
]);
|
||||
|
||||
if (!plant || !plant.active) notFound();
|
||||
|
||||
const zones = Array.from(new Set(allPlants.map((p) => p.zone).filter(Boolean) as string[])).sort();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -44,10 +48,16 @@ export default async function PlantDetailPage({ params }: { params: { id: string
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<EditPlantButton plant={plant} />
|
||||
<EditPlantButton plant={plant} zones={zones} groups={groups} />
|
||||
<DeletePlantButton plantId={plant.id} plantName={plant.commonName} />
|
||||
</div>
|
||||
|
||||
{plant.imageUrl && (
|
||||
<div className="rounded-lg overflow-hidden h-56 bg-muted">
|
||||
<img src={plant.imageUrl} alt={plant.commonName} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-2 text-sm">
|
||||
{plant.zone && (
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import { db } from "@/lib/db";
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Leaf, Plus, MapPin, CalendarDays } from "lucide-react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { AddPlantButton } from "@/components/garden/add-plant-button";
|
||||
import { CATEGORY_LABELS } from "@/lib/garden/categories";
|
||||
import { GardenGrid } from "@/components/garden/garden-grid";
|
||||
|
||||
export const metadata = { title: "Garden" };
|
||||
|
||||
export default async function GardenPage() {
|
||||
const plants = await db.plant.findMany({
|
||||
where: { active: true },
|
||||
orderBy: [{ category: "asc" }, { commonName: "asc" }],
|
||||
include: {
|
||||
_count: { select: { logs: true } },
|
||||
logs: { orderBy: { date: "desc" }, take: 1, select: { date: true, type: true } },
|
||||
},
|
||||
});
|
||||
const [plants, groups] = await Promise.all([
|
||||
db.plant.findMany({
|
||||
where: { active: true },
|
||||
orderBy: [{ commonName: "asc" }],
|
||||
include: {
|
||||
_count: { select: { logs: true } },
|
||||
logs: { orderBy: { date: "desc" }, take: 1, select: { date: true, type: true } },
|
||||
group: { select: { id: true, name: true } },
|
||||
},
|
||||
}),
|
||||
db.plantGroup.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: "asc" },
|
||||
include: { _count: { select: { plants: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const zones = Array.from(new Set(plants.map((p) => p.zone).filter(Boolean) as string[])).sort();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -28,70 +33,10 @@ export default async function GardenPage() {
|
||||
{plants.length} {plants.length === 1 ? "plant" : "plants"} on your acre
|
||||
</p>
|
||||
</div>
|
||||
<AddPlantButton />
|
||||
<AddPlantButton groups={groups} zones={zones} />
|
||||
</div>
|
||||
|
||||
{plants.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<Leaf className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||
<p className="font-medium">No plants yet</p>
|
||||
<p className="text-sm mt-1">Add your first plant to start building your food forest registry.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{plants.map((plant) => (
|
||||
<Link key={plant.id} href={`/garden/${plant.id}`}>
|
||||
<Card className="hover:shadow-md transition-shadow h-full cursor-pointer">
|
||||
<CardContent className="pt-4 pb-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">
|
||||
{plant.commonName}
|
||||
{plant.variety && (
|
||||
<span className="text-muted-foreground font-normal"> · {plant.variety}</span>
|
||||
)}
|
||||
</p>
|
||||
{plant.species && (
|
||||
<p className="text-xs text-muted-foreground italic truncate">{plant.species}</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0 text-xs">
|
||||
{CATEGORY_LABELS[plant.category]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{plant.zone && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MapPin className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{plant.zone}</span>
|
||||
</div>
|
||||
)}
|
||||
{plant.plantedAt && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CalendarDays className="h-3 w-3 shrink-0" />
|
||||
<span>Planted {formatDate(plant.plantedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
{plant.logs[0] && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Leaf className="h-3 w-3 shrink-0 text-[hsl(var(--leaf))]" />
|
||||
<span>
|
||||
Last {plant.logs[0].type.toLowerCase()} {formatDate(plant.logs[0].date)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-1 text-xs text-muted-foreground/60">
|
||||
{plant._count.logs} log {plant._count.logs === 1 ? "entry" : "entries"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<GardenGrid plants={plants} groups={groups} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
158
src/app/(dashboard)/tasks/page.tsx
Normal file
158
src/app/(dashboard)/tasks/page.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { isOverdue, isDueSoon, describeRecurrence } from "@/lib/tasks/schedule";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { AddTaskButton } from "@/components/tasks/add-task-button";
|
||||
import { CompleteTaskButton } from "@/components/tasks/complete-task-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { CalendarDays, Leaf, Home, Wrench, CheckCircle2, AlertCircle, Clock } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata = { title: "Reminders" };
|
||||
|
||||
const CATEGORY_ICONS = {
|
||||
GARDEN: Leaf,
|
||||
HOME: Home,
|
||||
EQUIPMENT: Wrench,
|
||||
OTHER: Clock,
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS = {
|
||||
GARDEN: "Garden",
|
||||
HOME: "Home",
|
||||
EQUIPMENT: "Equipment",
|
||||
OTHER: "Other",
|
||||
};
|
||||
|
||||
export default async function TasksPage() {
|
||||
const tasks = await db.task.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { nextDue: "asc" },
|
||||
include: { plant: { select: { id: true, commonName: true, variety: true } } },
|
||||
});
|
||||
|
||||
const overdue = tasks.filter((t) => isOverdue(t.nextDue));
|
||||
const upcoming = tasks.filter((t) => !isOverdue(t.nextDue) && isDueSoon(t.nextDue, 60));
|
||||
const later = tasks.filter((t) => !isOverdue(t.nextDue) && !isDueSoon(t.nextDue, 60));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold">Reminders</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{overdue.length > 0
|
||||
? `${overdue.length} overdue · ${upcoming.length} coming up`
|
||||
: `${upcoming.length} coming up in the next 60 days`}
|
||||
</p>
|
||||
</div>
|
||||
<AddTaskButton />
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 && (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<CalendarDays className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||
<p className="font-medium">No reminders yet</p>
|
||||
<p className="text-sm mt-1">Add your first reminder to start tracking what needs doing.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{overdue.length > 0 && (
|
||||
<Section title="Overdue" icon={AlertCircle} iconClass="text-destructive">
|
||||
{overdue.map((task) => (
|
||||
<TaskCard key={task.id} task={task} overdue />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{upcoming.length > 0 && (
|
||||
<Section title="Coming up" icon={CalendarDays} iconClass="text-[hsl(var(--leaf))]">
|
||||
{upcoming.map((task) => (
|
||||
<TaskCard key={task.id} task={task} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{later.length > 0 && (
|
||||
<Section title="Later" icon={Clock} iconClass="text-muted-foreground">
|
||||
{later.map((task) => (
|
||||
<TaskCard key={task.id} task={task} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, icon: Icon, iconClass, children }: {
|
||||
title: string;
|
||||
icon: React.ElementType;
|
||||
iconClass: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Icon className={`h-4 w-4 ${iconClass}`} />
|
||||
<h2 className="font-display text-lg font-semibold">{title}</h2>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskCard({ task, overdue = false }: {
|
||||
task: Awaited<ReturnType<typeof db.task.findMany>>[0] & {
|
||||
plant: { id: string; commonName: string; variety: string | null } | null;
|
||||
};
|
||||
overdue?: boolean;
|
||||
}) {
|
||||
const Icon = CATEGORY_ICONS[task.category];
|
||||
const daysUntil = Math.ceil((new Date(task.nextDue).getTime() - Date.now()) / 86400000);
|
||||
|
||||
return (
|
||||
<Card className={overdue ? "border-destructive/40" : ""}>
|
||||
<CardContent className="py-3 px-4 flex items-start gap-3">
|
||||
<Icon className={`h-4 w-4 mt-0.5 shrink-0 ${overdue ? "text-destructive" : "text-muted-foreground"}`} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start gap-2 flex-wrap">
|
||||
<p className="font-medium text-sm">{task.title}</p>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{CATEGORY_LABELS[task.category]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{task.plant && (
|
||||
<Link href={`/garden/${task.plant.id}`} className="text-xs text-[hsl(var(--leaf))] hover:underline">
|
||||
{task.plant.commonName}{task.plant.variety ? ` · ${task.plant.variety}` : ""}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{task.notes && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{task.notes}</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{describeRecurrence(task.recurrence, task.intervalDays, task.month)}
|
||||
{task.lastDone && ` · Last done ${formatDate(task.lastDone)}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="text-right">
|
||||
<p className={`text-xs font-medium ${overdue ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{overdue
|
||||
? `${Math.abs(daysUntil)}d overdue`
|
||||
: daysUntil === 0 ? "Today"
|
||||
: daysUntil === 1 ? "Tomorrow"
|
||||
: `In ${daysUntil} days`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{formatDate(task.nextDue)}</p>
|
||||
</div>
|
||||
<CompleteTaskButton taskId={task.id} taskTitle={task.title} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
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