feat: add garden module infrastructure (task 70)

- Add MinIO service to compose.yaml with named volume
- Add generic /api/uploads POST+GET route with auth, 5 MB, image/* guards
- Add src/lib/minio.ts singleton client with ensureBucket helper
- Add garden Drizzle schema: containers, plants, care_logs, care_schedules, species_cache
- Register garden module in src/modules/index.ts and src/lib/db.ts
- Apply migration 0015_garden_schema.sql
- Add MINIO_* and PERENUAL_API_KEY to .env.example
- Add task briefs 70-75 for the full garden feature arc
- Add uploads.spec.ts E2E test (RED → GREEN on route auth guard)
This commit is contained in:
ginnoir
2026-06-01 19:23:19 -05:00
parent 5dcd49d4c9
commit 2fd0677c5f
21 changed files with 1250 additions and 1 deletions
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { minioClient, MINIO_BUCKET } from "@/lib/minio";
export const runtime = "nodejs";
export async function GET(_request: Request, { params }: { params: Promise<{ key: string[] }> }) {
const { key } = await params;
const objectKey = key.join("/");
try {
const stat = await minioClient.statObject(MINIO_BUCKET, objectKey);
const stream = await minioClient.getObject(MINIO_BUCKET, objectKey);
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
}
const buffer = Buffer.concat(chunks);
const metaData = stat.metaData as Record<string, string> | undefined;
const contentType =
metaData?.["content-type"] ?? metaData?.["Content-Type"] ?? "application/octet-stream";
return new Response(buffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Length": String(buffer.length),
},
});
} catch {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
}
+57
View File
@@ -0,0 +1,57 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { getCurrentSession } from "@/lib/session";
import { ensureBucket, minioClient, MINIO_BUCKET } from "@/lib/minio";
export const runtime = "nodejs";
const MAX_FILE_SIZE = 5 * 1024 * 1024;
export async function POST(request: Request) {
let session: Awaited<ReturnType<typeof getCurrentSession>>;
try {
session = await getCurrentSession();
} catch {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return NextResponse.json({ error: "Invalid multipart body" }, { status: 400 });
}
const file = formData.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file field in request" }, { status: 400 });
}
if (!file.type.startsWith("image/")) {
return NextResponse.json({ error: "Only image files are allowed" }, { status: 415 });
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: "File exceeds 5 MB limit" }, { status: 413 });
}
const rawExt = file.name.split(".").pop() ?? "bin";
const safeExt = rawExt
.replace(/[^a-z0-9]/gi, "")
.toLowerCase()
.slice(0, 8);
const key = `garden/${session.household.id}/${randomUUID()}.${safeExt}`;
try {
await ensureBucket();
const buffer = Buffer.from(await file.arrayBuffer());
await minioClient.putObject(MINIO_BUCKET, key, buffer, buffer.length, {
"Content-Type": file.type,
});
} catch (err) {
console.error("Upload failed", err);
return NextResponse.json({ error: "Upload service unavailable" }, { status: 503 });
}
return NextResponse.json({ url: `/api/uploads/${key}` });
}
+8 -1
View File
@@ -4,9 +4,16 @@ import * as coreSchema from "@/modules/_core/schema";
import * as calendarSchema from "@/modules/calendar/schema";
import * as listsSchema from "@/modules/lists/schema";
import * as notesSchema from "@/modules/notes/schema";
import * as gardenSchema from "@/modules/garden/schema";
const client = postgres(process.env["DATABASE_URL"]!);
const schema = { ...coreSchema, ...calendarSchema, ...listsSchema, ...notesSchema };
const schema = {
...coreSchema,
...calendarSchema,
...listsSchema,
...notesSchema,
...gardenSchema,
};
export const db = drizzle(client, { schema });
+29
View File
@@ -0,0 +1,29 @@
import { Client } from "minio";
const rawEndpoint = process.env.MINIO_ENDPOINT ?? "http://localhost:9000";
const endpointUrl = new URL(rawEndpoint);
export const minioClient = new Client({
endPoint: endpointUrl.hostname,
port: endpointUrl.port
? parseInt(endpointUrl.port)
: endpointUrl.protocol === "https:"
? 443
: 80,
useSSL: endpointUrl.protocol === "https:",
accessKey: process.env.MINIO_ROOT_USER ?? "",
secretKey: process.env.MINIO_ROOT_PASSWORD ?? "",
});
export const MINIO_BUCKET = process.env.MINIO_BUCKET ?? "garden";
let bucketReady = false;
export async function ensureBucket(): Promise<void> {
if (bucketReady) return;
const exists = await minioClient.bucketExists(MINIO_BUCKET);
if (!exists) {
await minioClient.makeBucket(MINIO_BUCKET);
}
bucketReady = true;
}
+12
View File
@@ -0,0 +1,12 @@
import type { ModuleManifest } from "../_core/module";
const gardenManifest: ModuleManifest = {
id: "garden",
name: "Garden",
nav: { href: "/garden", label: "Garden", icon: "sprout" },
entities: [],
dashboardWidgets: [],
quickAdds: [],
};
export default gardenManifest;
+119
View File
@@ -0,0 +1,119 @@
import {
boolean,
date,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import { households, users } from "../_core/schema";
export const gardenContainers = pgTable(
"garden_containers",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
name: text("name").notNull(),
type: text("type").notNull().default("other"),
locationNotes: text("location_notes"),
coverImageUrl: text("cover_image_url"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index("garden_containers_household_idx").on(t.householdId)],
);
export const gardenPlants = pgTable(
"garden_plants",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
containerId: uuid("container_id").references(() => gardenContainers.id, {
onDelete: "set null",
}),
name: text("name").notNull(),
scientificName: text("scientific_name"),
speciesId: text("species_id"),
category: text("category").notNull().default("other"),
notes: text("notes"),
acquisitionDate: date("acquisition_date"),
growthStage: text("growth_stage"),
healthStatus: text("health_status").notNull().default("healthy"),
sunlight: text("sunlight"),
wateringNotes: text("watering_notes"),
fertilizingNotes: text("fertilizing_notes"),
primaryImageUrl: text("primary_image_url"),
images: jsonb("images").notNull().default([]).$type<string[]>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index("garden_plants_household_idx").on(t.householdId),
index("garden_plants_household_container_idx").on(t.householdId, t.containerId),
],
);
export const gardenCareLogs = pgTable(
"garden_care_logs",
{
id: uuid("id").primaryKey().defaultRandom(),
plantId: uuid("plant_id")
.notNull()
.references(() => gardenPlants.id, { onDelete: "cascade" }),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
careType: text("care_type").notNull(),
performedBy: uuid("performed_by").references(() => users.id, { onDelete: "set null" }),
notes: text("notes"),
performedAt: timestamp("performed_at", { withTimezone: true }).notNull().defaultNow(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index("garden_care_logs_plant_performed_idx").on(t.plantId, t.performedAt),
index("garden_care_logs_household_idx").on(t.householdId),
],
);
export const gardenCareSchedules = pgTable(
"garden_care_schedules",
{
id: uuid("id").primaryKey().defaultRandom(),
plantId: uuid("plant_id")
.notNull()
.references(() => gardenPlants.id, { onDelete: "cascade" }),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
careType: text("care_type").notNull(),
intervalDays: integer("interval_days").notNull(),
lastPerformedAt: timestamp("last_performed_at", { withTimezone: true }),
nextDueAt: timestamp("next_due_at", { withTimezone: true }),
enabled: boolean("enabled").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex("garden_care_schedules_plant_type_uq").on(t.plantId, t.careType),
index("garden_care_schedules_household_due_idx").on(t.householdId, t.nextDueAt),
],
);
export const gardenSpeciesCache = pgTable("garden_species_cache", {
speciesId: text("species_id").primaryKey(),
data: jsonb("data").notNull().$type<Record<string, unknown>>(),
cachedAt: timestamp("cached_at", { withTimezone: true }).notNull().defaultNow(),
});
export type GardenContainer = typeof gardenContainers.$inferSelect;
export type GardenPlant = typeof gardenPlants.$inferSelect;
export type GardenCareLog = typeof gardenCareLogs.$inferSelect;
export type GardenCareSchedule = typeof gardenCareSchedules.$inferSelect;
+3
View File
@@ -0,0 +1,3 @@
"use server";
// Garden server actions — implemented in tasks 7174
+1
View File
@@ -0,0 +1 @@
// Garden query functions — implemented in tasks 7174
+2
View File
@@ -3,8 +3,10 @@ import coreManifest from "./_core/manifest";
import calendarManifest from "./calendar/manifest";
import listsManifest from "./lists/manifest";
import notesManifest from "./notes/manifest";
import gardenManifest from "./garden/manifest";
registerModule(coreManifest);
registerModule(calendarManifest);
registerModule(listsManifest);
registerModule(notesManifest);
registerModule(gardenManifest);