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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
"use server";
|
||||
|
||||
// Garden server actions — implemented in tasks 71–74
|
||||
@@ -0,0 +1 @@
|
||||
// Garden query functions — implemented in tasks 71–74
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user