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,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;
|
||||
Reference in New Issue
Block a user