diff --git a/STATUS.md b/STATUS.md index f2717b1..4996e69 100644 --- a/STATUS.md +++ b/STATUS.md @@ -13,6 +13,8 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b - **84 — Back navigation** (commit `d090200`). Shared `DetailBackLink` on garden/notes/lists detail, create, and edit pages plus household settings. E2E in `tests/e2e/navigation.spec.ts`. +- **87 — HTTP API v1** (commits `ea5d1d0`, `d4304b0`, `TBD`). Household bearer token + session auth; `/api/v1/` routes for calendar, lists, notes, garden, bangs; OpenAPI in `docs/api/openapi.yaml`; owner token UI in Settings → Data. Unit tests: `api-auth`, `api-v1-calendar`, `api-v1-auth`. ADR 0006. + - **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green. - **02 — Next.js app skeleton**. Next.js 15 + React 19 + Tailwind v4 + shadcn/ui (button, card, input, dialog). `pnpm dev` serves placeholder, `pnpm build` produces `.next/standalone/`, `pnpm lint` clean. Added `.npmrc` with `node-linker=hoisted` for Windows symlink compatibility. - **03 — Drizzle + Postgres setup**. drizzle-orm + postgres driver + drizzle-kit wired up. `src/modules/_core/schema.ts` declares `users`, `households`, `household_members`. `docker-compose.dev.yaml` starts Postgres 16. `drizzle/0000_silent_magma.sql` generated and applied. `tsc --noEmit` passes. diff --git a/docs/api/README.md b/docs/api/README.md index ea03a22..8189e5d 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -1,19 +1,27 @@ # famapp HTTP API (v1) -REST JSON API under `/api/v1/`. Full OpenAPI spec is planned in task 87.3. +REST JSON API under `/api/v1/`. Full specification: [`openapi.yaml`](openapi.yaml). ## Authentication Every endpoint accepts **either**: - Auth.js session cookie (browser login), or -- `Authorization: Bearer ` header +- `Authorization: Bearer ` header (recommended for external clients) Unauthorized requests return `401` with `{ "error": "Unauthorized" }`. -Generate bearer tokens in **Settings → API tokens**. +Generate bearer tokens in **Settings → API tokens**. Example: -## Endpoints (87.2) +```bash +curl -H "Authorization: Bearer YOUR_TOKEN" https://fam.ginnoir.com/api/v1/calendars +``` + +## OpenAPI + +See [`openapi.yaml`](openapi.yaml) for paths, request/response schemas, and the `bearerAuth` security scheme. Import into Swagger UI, Postman, or your HTTP client of choice. + +## Endpoints ### Calendars @@ -59,11 +67,30 @@ Generate bearer tokens in **Settings → API tokens**. | PATCH | `/api/v1/notes/:id` | Update note | | DELETE | `/api/v1/notes/:id` | Delete note | +### Garden + +| Method | Path | Description | +| ------ | ------------------------------- | -------------------------------------- | +| GET | `/api/v1/garden/containers` | List containers | +| POST | `/api/v1/garden/containers` | Create container | +| GET | `/api/v1/garden/containers/:id` | Get container with plants | +| PATCH | `/api/v1/garden/containers/:id` | Update container | +| DELETE | `/api/v1/garden/containers/:id` | Delete container | +| GET | `/api/v1/garden/plants` | List plants (optional `?containerId=`) | +| POST | `/api/v1/garden/plants` | Create plant | +| GET | `/api/v1/garden/plants/:id` | Get plant detail | +| PATCH | `/api/v1/garden/plants/:id` | Update plant | +| DELETE | `/api/v1/garden/plants/:id` | Delete plant | + +### Bangs + +| Method | Path | Description | +| ------ | ------------------- | ------------------------------------------------ | +| GET | `/api/v1/bangs` | Stats: `total` + `recent` (optional `?limit=10`) | +| POST | `/api/v1/bangs` | Record bang (optional `occurredOn` YYYY-MM-DD) | +| PATCH | `/api/v1/bangs/:id` | Update `occurredOn` | +| DELETE | `/api/v1/bangs/:id` | Delete bang | + ## Bearer token visibility Bearer tokens see **household-visible calendars only** (private calendars are hidden). Mutations on calendars require ownership (session) or household visibility (bearer). Activity log records `actorId: null` for bearer mutations. - -## Planned (87.3) - -- Garden, bangs routes -- OpenAPI specification diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml new file mode 100644 index 0000000..bec1348 --- /dev/null +++ b/docs/api/openapi.yaml @@ -0,0 +1,829 @@ +openapi: 3.1.0 +info: + title: famapp API + version: "1.0.0" + description: | + Household-scoped REST JSON API for famapp (fam.ginnoir.com). + + Authenticate with an Auth.js session cookie (browser) or a household API bearer token + (external clients). Generate bearer tokens in Settings → API tokens. + +servers: + - url: https://fam.ginnoir.com + description: Production + - url: http://localhost:3000 + description: Local development + +security: + - bearerAuth: [] + - sessionCookie: [] + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: Household API token from Settings → API tokens + sessionCookie: + type: apiKey + in: cookie + name: authjs.session-token + description: Auth.js session cookie (browser login) + + schemas: + Error: + type: object + required: [error] + properties: + error: + type: string + + OkResponse: + type: object + required: [ok] + properties: + ok: + type: boolean + enum: [true] + + Calendar: + type: object + properties: + id: { type: string, format: uuid } + name: { type: string } + color: { type: string, nullable: true } + visibility: { type: string, enum: [private, household] } + ownerId: { type: string, format: uuid } + + CalendarInput: + type: object + required: [name] + properties: + name: { type: string } + color: { type: string, nullable: true } + visibility: { type: string, enum: [private, household], default: household } + + CalendarEvent: + type: object + properties: + id: { type: string, format: uuid } + calendarId: { type: string, format: uuid } + title: { type: string } + startAt: { type: string, format: date-time } + endAt: { type: string, format: date-time } + allDay: { type: boolean } + location: { type: string, nullable: true } + notes: { type: string, nullable: true } + + EventInput: + type: object + required: [calendarId, title, startAt, endAt] + properties: + calendarId: { type: string, format: uuid } + title: { type: string } + startAt: { type: string, format: date-time } + endAt: { type: string, format: date-time } + allDay: { type: boolean, default: false } + location: { type: string, nullable: true } + notes: { type: string, nullable: true } + remindMinutesBefore: { type: integer, nullable: true } + + ListSummary: + type: object + properties: + id: { type: string, format: uuid } + type: { type: string } + name: { type: string } + archived: { type: boolean } + openCount: { type: integer } + doneCount: { type: integer } + createdAt: { type: string, format: date-time } + + ListInput: + type: object + required: [type, name] + properties: + type: { type: string } + name: { type: string } + + ListItem: + type: object + properties: + id: { type: string, format: uuid } + listId: { type: string, format: uuid } + text: { type: string } + qty: { type: string, nullable: true } + notes: { type: string, nullable: true } + done: { type: boolean } + dueAt: { type: string, format: date-time, nullable: true } + assigneeId: { type: string, format: uuid, nullable: true } + position: { type: integer } + + ItemInput: + type: object + required: [text] + properties: + text: { type: string } + qty: { type: string, nullable: true } + notes: { type: string, nullable: true } + dueAt: { type: string, format: date-time, nullable: true } + assigneeId: { type: string, format: uuid, nullable: true } + metadata: { type: object, nullable: true } + + Note: + type: object + properties: + id: { type: string, format: uuid } + householdId: { type: string, format: uuid } + authorId: { type: string, format: uuid } + title: { type: string } + body: { type: string } + pinned: { type: boolean } + remindAt: { type: string, format: date-time, nullable: true } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + NoteInput: + type: object + required: [title] + properties: + title: { type: string } + body: { type: string, default: "" } + pinned: { type: boolean, default: false } + remindAt: { type: string, format: date-time, nullable: true } + + GardenContainer: + type: object + properties: + id: { type: string, format: uuid } + householdId: { type: string, format: uuid } + name: { type: string } + type: { type: string } + locationNotes: { type: string, nullable: true } + coverImageUrl: { type: string, nullable: true } + images: { type: array, items: { type: string } } + plantCount: { type: integer } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + GardenContainerDetail: + allOf: + - $ref: "#/components/schemas/GardenContainer" + - type: object + properties: + plants: + type: array + items: + $ref: "#/components/schemas/PlantSummary" + + ContainerInput: + type: object + required: [name] + properties: + name: { type: string } + type: { type: string, default: other } + locationNotes: { type: string, nullable: true } + coverImageUrl: { type: string, nullable: true } + + PlantSummary: + type: object + properties: + id: { type: string, format: uuid } + name: { type: string } + scientificName: { type: string, nullable: true } + healthStatus: { type: string } + primaryImageUrl: { type: string, nullable: true } + category: { type: string } + + PlantListItem: + type: object + properties: + id: { type: string, format: uuid } + containerId: { type: string, format: uuid, nullable: true } + containerName: { type: string, nullable: true } + name: { type: string } + healthStatus: { type: string } + primaryImageUrl: { type: string, nullable: true } + category: { type: string } + lastWateredAt: { type: string, format: date-time, nullable: true } + hasOverdueCare: { type: boolean } + + PlantDetail: + type: object + properties: + id: { type: string, format: uuid } + householdId: { type: string, format: uuid } + containerId: { type: string, format: uuid, nullable: true } + containerName: { type: string, nullable: true } + name: { type: string } + scientificName: { type: string, nullable: true } + speciesId: { type: string, nullable: true } + category: { type: string } + notes: { type: string, nullable: true } + acquisitionDate: { type: string, nullable: true } + growthStage: { type: string, nullable: true } + healthStatus: { type: string } + sunlight: { type: string, nullable: true } + wateringNotes: { type: string, nullable: true } + fertilizingNotes: { type: string, nullable: true } + primaryImageUrl: { type: string, nullable: true } + images: { type: array, items: { type: string } } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + PlantInput: + type: object + required: [name] + properties: + name: { type: string } + category: { type: string, default: other } + containerId: { type: string, format: uuid, nullable: true } + healthStatus: { type: string, default: healthy } + growthStage: { type: string, nullable: true } + scientificName: { type: string, nullable: true } + speciesId: { type: string, nullable: true } + sunlight: { type: string, nullable: true } + wateringNotes: { type: string, nullable: true } + fertilizingNotes: { type: string, nullable: true } + notes: { type: string, nullable: true } + acquisitionDate: { type: string, nullable: true } + images: { type: array, items: { type: string }, default: [] } + primaryImageUrl: { type: string, nullable: true } + + BangStats: + type: object + properties: + total: { type: integer } + recent: + type: array + items: + $ref: "#/components/schemas/BangSummary" + + BangSummary: + type: object + properties: + id: { type: string, format: uuid } + occurredOn: { type: string, format: date, description: YYYY-MM-DD } + recordedByName: { type: string, nullable: true } + + Bang: + type: object + properties: + id: { type: string, format: uuid } + occurredOn: { type: string, format: date } + recordedBy: { type: string, format: uuid, nullable: true } + createdAt: { type: string, format: date-time } + + BangInput: + type: object + properties: + occurredOn: + type: string + format: date + description: YYYY-MM-DD; defaults to today + + BangUpdateInput: + type: object + required: [occurredOn] + properties: + occurredOn: { type: string, format: date } + +paths: + /api/v1/calendars: + get: + summary: List visible calendars + tags: [Calendars] + responses: + "200": + description: Calendar list + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/Calendar" } + "401": + { + description: Unauthorized, + content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }, + } + post: + summary: Create calendar + tags: [Calendars] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CalendarInput" } + responses: + "201": + description: Created calendar + content: + application/json: + schema: { $ref: "#/components/schemas/Calendar" } + "401": { description: Unauthorized } + + /api/v1/calendars/{id}: + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + get: + summary: Get calendar + tags: [Calendars] + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/Calendar" } + "404": { description: Not found } + patch: + summary: Update calendar + tags: [Calendars] + requestBody: + content: + application/json: + schema: + type: object + properties: + name: { type: string } + visibility: { type: string, enum: [private, household] } + color: { type: string, nullable: true } + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/Calendar" } + delete: + summary: Delete calendar + tags: [Calendars] + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/OkResponse" } + + /api/v1/events: + get: + summary: List events in date range + tags: [Events] + parameters: + - name: from + in: query + required: true + schema: { type: string, format: date-time } + - name: to + in: query + required: true + schema: { type: string, format: date-time } + - name: calendarIds + in: query + description: "all or comma-separated UUIDs" + schema: { type: string, default: all } + responses: + "200": + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/CalendarEvent" } + "400": { description: Missing from/to } + post: + summary: Create event + tags: [Events] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/EventInput" } + responses: + "201": + content: + application/json: + schema: { $ref: "#/components/schemas/CalendarEvent" } + + /api/v1/events/{id}: + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + get: + summary: Get event + tags: [Events] + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/CalendarEvent" } + patch: + summary: Update event + tags: [Events] + requestBody: + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/EventInput" + description: All fields optional for PATCH + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/CalendarEvent" } + delete: + summary: Delete event + tags: [Events] + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/OkResponse" } + + /api/v1/lists: + get: + summary: List lists + tags: [Lists] + parameters: + - name: type + in: query + schema: { type: string } + description: Filter by list type (e.g. shopping, tasks) + responses: + "200": + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/ListSummary" } + post: + summary: Create list + tags: [Lists] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ListInput" } + responses: + "201": + content: + application/json: + schema: { $ref: "#/components/schemas/ListSummary" } + + /api/v1/lists/{id}: + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + get: + summary: Get list with items + tags: [Lists] + responses: + "200": + description: List detail including items array + patch: + summary: Update list name or archive + tags: [Lists] + requestBody: + content: + application/json: + schema: + type: object + properties: + name: { type: string } + archived: { type: boolean } + responses: + "200": { description: Updated list } + delete: + summary: Archive list + tags: [Lists] + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/OkResponse" } + + /api/v1/lists/{id}/items: + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + get: + summary: List items + tags: [Lists] + responses: + "200": + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/ListItem" } + post: + summary: Add item + tags: [Lists] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ItemInput" } + responses: + "201": + content: + application/json: + schema: { $ref: "#/components/schemas/ListItem" } + + /api/v1/lists/{id}/items/{itemId}: + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + - name: itemId + in: path + required: true + schema: { type: string, format: uuid } + patch: + summary: Update or toggle item + tags: [Lists] + requestBody: + content: + application/json: + schema: + type: object + properties: + text: { type: string } + qty: { type: string, nullable: true } + notes: { type: string, nullable: true } + done: { type: boolean } + dueAt: { type: string, format: date-time, nullable: true } + responses: + "200": { description: Updated list with items } + delete: + summary: Delete item + tags: [Lists] + responses: + "200": { description: Updated list with items } + + /api/v1/notes: + get: + summary: List notes + tags: [Notes] + responses: + "200": + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/Note" } + post: + summary: Create note + tags: [Notes] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/NoteInput" } + responses: + "201": + content: + application/json: + schema: { $ref: "#/components/schemas/Note" } + + /api/v1/notes/{id}: + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + get: + summary: Get note + tags: [Notes] + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/Note" } + patch: + summary: Update note + tags: [Notes] + requestBody: + content: + application/json: + schema: + type: object + properties: + title: { type: string } + body: { type: string } + pinned: { type: boolean } + remindAt: { type: string, format: date-time, nullable: true } + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/Note" } + delete: + summary: Delete note + tags: [Notes] + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/OkResponse" } + + /api/v1/garden/containers: + get: + summary: List garden containers + tags: [Garden] + responses: + "200": + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/GardenContainer" } + post: + summary: Create container + tags: [Garden] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ContainerInput" } + responses: + "201": + content: + application/json: + schema: { $ref: "#/components/schemas/GardenContainerDetail" } + + /api/v1/garden/containers/{id}: + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + get: + summary: Get container with plants + tags: [Garden] + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/GardenContainerDetail" } + "404": { description: Not found } + patch: + summary: Update container + tags: [Garden] + requestBody: + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ContainerInput" + description: All fields optional + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/GardenContainerDetail" } + delete: + summary: Delete container + tags: [Garden] + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/OkResponse" } + + /api/v1/garden/plants: + get: + summary: List plants + tags: [Garden] + parameters: + - name: containerId + in: query + schema: { type: string, format: uuid } + description: Filter by container + responses: + "200": + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/PlantListItem" } + post: + summary: Create plant + tags: [Garden] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PlantInput" } + responses: + "201": + content: + application/json: + schema: { $ref: "#/components/schemas/PlantDetail" } + + /api/v1/garden/plants/{id}: + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + get: + summary: Get plant detail + tags: [Garden] + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/PlantDetail" } + "404": { description: Not found } + patch: + summary: Update plant + tags: [Garden] + requestBody: + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/PlantInput" + description: All fields optional + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/PlantDetail" } + delete: + summary: Delete plant + tags: [Garden] + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/OkResponse" } + + /api/v1/bangs: + get: + summary: Bang stats (total + recent) + tags: [Bangs] + parameters: + - name: limit + in: query + schema: { type: integer, default: 10, minimum: 1, maximum: 100 } + description: Max recent entries to return + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/BangStats" } + post: + summary: Record a bang + tags: [Bangs] + requestBody: + content: + application/json: + schema: { $ref: "#/components/schemas/BangInput" } + responses: + "201": + content: + application/json: + schema: { $ref: "#/components/schemas/Bang" } + + /api/v1/bangs/{id}: + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + patch: + summary: Update bang date + tags: [Bangs] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/BangUpdateInput" } + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/Bang" } + delete: + summary: Delete bang + tags: [Bangs] + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/OkResponse" } + +tags: + - name: Calendars + - name: Events + - name: Lists + - name: Notes + - name: Garden + - name: Bangs diff --git a/src/app/api/v1/bangs/[id]/route.ts b/src/app/api/v1/bangs/[id]/route.ts new file mode 100644 index 0000000..3953e51 --- /dev/null +++ b/src/app/api/v1/bangs/[id]/route.ts @@ -0,0 +1,28 @@ +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { deleteBangForScope, updateBangForScope } from "@/modules/bangs/server/actions"; +import { updateBangInput } from "@/modules/bangs/server/schemas"; +import { z } from "zod"; + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope, req) => { + const body: unknown = await req.json(); + const parsed = updateBangInput.parse(body); + const bang = await updateBangForScope(scope, { id, ...parsed }); + return apiJson({ + id: bang.id, + occurredOn: bang.occurredOn, + recordedBy: bang.recordedBy, + createdAt: bang.createdAt.toISOString(), + }); + }); +} + +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope) => { + z.string().uuid().parse(id); + await deleteBangForScope(scope, { id }); + return apiJson({ ok: true }); + }); +} diff --git a/src/app/api/v1/bangs/route.ts b/src/app/api/v1/bangs/route.ts new file mode 100644 index 0000000..e723f58 --- /dev/null +++ b/src/app/api/v1/bangs/route.ts @@ -0,0 +1,31 @@ +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { addBangForScope } from "@/modules/bangs/server/actions"; +import { addBangInput } from "@/modules/bangs/server/schemas"; +import { getBangStatsForScope } from "@/modules/bangs/server/queries"; + +export async function GET(request: Request) { + return withApiHandler(request, async (scope, req) => { + const url = new URL(req.url); + const limitParam = url.searchParams.get("limit"); + const limit = limitParam ? Math.min(Math.max(Number(limitParam) || 10, 1), 100) : 10; + const stats = await getBangStatsForScope(scope.householdId, limit); + return apiJson(stats); + }); +} + +export async function POST(request: Request) { + return withApiHandler(request, async (scope, req) => { + const body: unknown = await req.json().catch(() => ({})); + const parsed = addBangInput.parse(body); + const bang = await addBangForScope(scope, parsed); + return apiJson( + { + id: bang.id, + occurredOn: bang.occurredOn, + recordedBy: bang.recordedBy, + createdAt: bang.createdAt.toISOString(), + }, + 201, + ); + }); +} diff --git a/src/app/api/v1/garden/containers/[id]/route.ts b/src/app/api/v1/garden/containers/[id]/route.ts new file mode 100644 index 0000000..969e225 --- /dev/null +++ b/src/app/api/v1/garden/containers/[id]/route.ts @@ -0,0 +1,36 @@ +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { deleteContainerForScope, updateContainerForScope } from "@/modules/garden/server/actions"; +import { containerUpdateInput } from "@/modules/garden/server/schemas"; +import { getContainerForScope } from "@/modules/garden/server/queries"; +import { z } from "zod"; + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope) => { + z.string().uuid().parse(id); + const container = await getContainerForScope(scope.householdId, id); + if (!container) throw new Error("Container not found"); + return apiJson(container); + }); +} + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope, req) => { + const body: unknown = await req.json(); + const parsed = containerUpdateInput.parse(body); + await updateContainerForScope(scope, { id, ...parsed }); + const container = await getContainerForScope(scope.householdId, id); + if (!container) throw new Error("Container not found"); + return apiJson(container); + }); +} + +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope) => { + z.string().uuid().parse(id); + await deleteContainerForScope(scope, { id }); + return apiJson({ ok: true }); + }); +} diff --git a/src/app/api/v1/garden/containers/route.ts b/src/app/api/v1/garden/containers/route.ts new file mode 100644 index 0000000..1dda9de --- /dev/null +++ b/src/app/api/v1/garden/containers/route.ts @@ -0,0 +1,21 @@ +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { createContainerForScope } from "@/modules/garden/server/actions"; +import { containerInput } from "@/modules/garden/server/schemas"; +import { getContainerForScope, listContainersForScope } from "@/modules/garden/server/queries"; + +export async function GET(request: Request) { + return withApiHandler(request, async (scope) => { + const containers = await listContainersForScope(scope.householdId); + return apiJson(containers); + }); +} + +export async function POST(request: Request) { + return withApiHandler(request, async (scope, req) => { + const body: unknown = await req.json(); + const parsed = containerInput.parse(body); + const container = await createContainerForScope(scope, parsed); + const detail = await getContainerForScope(scope.householdId, container.id); + return apiJson(detail, 201); + }); +} diff --git a/src/app/api/v1/garden/plants/[id]/route.ts b/src/app/api/v1/garden/plants/[id]/route.ts new file mode 100644 index 0000000..a517446 --- /dev/null +++ b/src/app/api/v1/garden/plants/[id]/route.ts @@ -0,0 +1,36 @@ +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { deletePlantForScope, updatePlantForScope } from "@/modules/garden/server/actions"; +import { plantUpdateInput } from "@/modules/garden/server/schemas"; +import { getPlantForScope } from "@/modules/garden/server/queries"; +import { z } from "zod"; + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope) => { + z.string().uuid().parse(id); + const plant = await getPlantForScope(scope.householdId, id); + if (!plant) throw new Error("Plant not found"); + return apiJson(plant); + }); +} + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope, req) => { + const body: unknown = await req.json(); + const parsed = plantUpdateInput.parse(body); + await updatePlantForScope(scope, { id, ...parsed }); + const plant = await getPlantForScope(scope.householdId, id); + if (!plant) throw new Error("Plant not found"); + return apiJson(plant); + }); +} + +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope) => { + z.string().uuid().parse(id); + await deletePlantForScope(scope, { id }); + return apiJson({ ok: true }); + }); +} diff --git a/src/app/api/v1/garden/plants/route.ts b/src/app/api/v1/garden/plants/route.ts new file mode 100644 index 0000000..d139441 --- /dev/null +++ b/src/app/api/v1/garden/plants/route.ts @@ -0,0 +1,23 @@ +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { createPlantForScope } from "@/modules/garden/server/actions"; +import { plantInput } from "@/modules/garden/server/schemas"; +import { getPlantForScope, listPlantsForScope } from "@/modules/garden/server/queries"; + +export async function GET(request: Request) { + return withApiHandler(request, async (scope, req) => { + const url = new URL(req.url); + const containerId = url.searchParams.get("containerId") ?? undefined; + const plants = await listPlantsForScope(scope.householdId, { containerId }); + return apiJson(plants); + }); +} + +export async function POST(request: Request) { + return withApiHandler(request, async (scope, req) => { + const body: unknown = await req.json(); + const parsed = plantInput.parse(body); + const plant = await createPlantForScope(scope, parsed); + const detail = await getPlantForScope(scope.householdId, plant.id); + return apiJson(detail, 201); + }); +} diff --git a/src/modules/bangs/server/actions.ts b/src/modules/bangs/server/actions.ts index 622ebc1..79182c9 100644 --- a/src/modules/bangs/server/actions.ts +++ b/src/modules/bangs/server/actions.ts @@ -3,65 +3,71 @@ import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; +import type { ApiAuthContext } from "@/lib/api-auth"; import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; -import { logActivity } from "@/modules/_core/activity"; +import { logActivityForScope } from "@/modules/_core/activity"; import { bangEvents } from "../schema"; - -const dateString = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD"); - -const addBangInput = z.object({ - occurredOn: dateString.optional(), -}); - -const updateBangInput = z.object({ - id: z.string().uuid(), - occurredOn: dateString, -}); +import { addBangInput, updateBangInput } from "./schemas"; const deleteBangInput = z.object({ id: z.string().uuid(), }); +function toScope(ctx: ApiAuthContext) { + return { householdId: ctx.householdId, userId: ctx.userId }; +} + function todayString(): string { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; } -export async function addBang(input: z.input = {}) { +export async function addBangForScope( + scope: ApiAuthContext, + input: z.input = {}, +) { const parsed = addBangInput.parse(input); - const { user, household } = await getCurrentSession(); - const occurredOn = parsed.occurredOn ?? todayString(); const [bang] = await db .insert(bangEvents) .values({ - householdId: household.id, - recordedBy: user.id, + householdId: scope.householdId, + recordedBy: scope.userId, occurredOn, }) .returning(); if (!bang) throw new Error("Bang was not recorded"); - await logActivity({ + await logActivityForScope(toScope(scope), { entityType: "bangs.event", entityId: bang.id, action: "create", payload: { occurredOn }, }); - revalidatePath("/"); - revalidatePath("/d/[slug]", "page"); - return bang; } -export async function updateBang(input: z.input) { - const parsed = updateBangInput.parse(input); - const { household } = await getCurrentSession(); - await assertCanAccessBang(parsed.id, household.id); +export async function addBang(input: z.input = {}) { + const { user, household } = await getCurrentSession(); + const bang = await addBangForScope( + { householdId: household.id, userId: user.id, role: null }, + input, + ); + revalidatePath("/"); + revalidatePath("/d/[slug]", "page"); + return bang; +} + +export async function updateBangForScope( + scope: ApiAuthContext, + input: { id: string } & z.input, +) { + const parsed = z.object({ id: z.string().uuid() }).and(updateBangInput).parse(input); + await assertCanAccessBang(parsed.id, scope.householdId); const [bang] = await db .update(bangEvents) @@ -71,23 +77,31 @@ export async function updateBang(input: z.input) { if (!bang) throw new Error("Bang was not updated"); - await logActivity({ + await logActivityForScope(toScope(scope), { entityType: "bangs.event", entityId: bang.id, action: "update", payload: { occurredOn: parsed.occurredOn }, }); - revalidatePath("/"); - revalidatePath("/d/[slug]", "page"); - return bang; } -export async function deleteBang(input: z.input) { +export async function updateBang(input: { id: string } & z.input) { + const parsed = z.object({ id: z.string().uuid() }).and(updateBangInput).parse(input); + const { household, user } = await getCurrentSession(); + const bang = await updateBangForScope( + { householdId: household.id, userId: user.id, role: null }, + parsed, + ); + revalidatePath("/"); + revalidatePath("/d/[slug]", "page"); + return bang; +} + +export async function deleteBangForScope(scope: ApiAuthContext, input: { id: string }) { const parsed = deleteBangInput.parse(input); - const { household } = await getCurrentSession(); - await assertCanAccessBang(parsed.id, household.id); + await assertCanAccessBang(parsed.id, scope.householdId); const [existing] = await db .select({ occurredOn: bangEvents.occurredOn }) @@ -95,7 +109,7 @@ export async function deleteBang(input: z.input) { .where(eq(bangEvents.id, parsed.id)) .limit(1); - await logActivity({ + await logActivityForScope(toScope(scope), { entityType: "bangs.event", entityId: parsed.id, action: "delete", @@ -103,7 +117,12 @@ export async function deleteBang(input: z.input) { }); await db.delete(bangEvents).where(eq(bangEvents.id, parsed.id)); +} +export async function deleteBang(input: z.input) { + const parsed = deleteBangInput.parse(input); + const { household, user } = await getCurrentSession(); + await deleteBangForScope({ householdId: household.id, userId: user.id, role: null }, parsed); revalidatePath("/"); revalidatePath("/d/[slug]", "page"); } diff --git a/src/modules/bangs/server/queries.ts b/src/modules/bangs/server/queries.ts index dacccc7..fe1a065 100644 --- a/src/modules/bangs/server/queries.ts +++ b/src/modules/bangs/server/queries.ts @@ -14,7 +14,10 @@ export type RecentBangDto = { recordedByName: string | null; }; -export async function getBangStats(householdId: string, limit: number): Promise { +export async function getBangStatsForScope( + householdId: string, + limit: number, +): Promise { const [totalRow] = await db .select({ total: count() }) .from(bangEvents) @@ -34,6 +37,14 @@ export async function getBangStats(householdId: string, limit: number): Promise< return { total: totalRow?.total ?? 0, - recent, + recent: recent.map((r) => ({ + id: r.id, + occurredOn: r.occurredOn, + recordedByName: r.recordedByName, + })), }; } + +export async function getBangStats(householdId: string, limit: number): Promise { + return getBangStatsForScope(householdId, limit); +} diff --git a/src/modules/bangs/server/schemas.ts b/src/modules/bangs/server/schemas.ts new file mode 100644 index 0000000..6c9b61e --- /dev/null +++ b/src/modules/bangs/server/schemas.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const dateString = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD"); + +export const addBangInput = z.object({ + occurredOn: dateString.optional(), +}); + +export const updateBangInput = z.object({ + occurredOn: dateString, +}); diff --git a/src/modules/garden/server/actions.ts b/src/modules/garden/server/actions.ts index d1104b5..ea5d05e 100644 --- a/src/modules/garden/server/actions.ts +++ b/src/modules/garden/server/actions.ts @@ -3,9 +3,10 @@ import { and, eq, lte, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; +import type { ApiAuthContext } from "@/lib/api-auth"; import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; -import { logActivity } from "@/modules/_core/activity"; +import { logActivity, logActivityForScope } from "@/modules/_core/activity"; import { cancelReminder, scheduleReminder } from "@/modules/_core/reminders"; import { listItems } from "@/modules/lists/schema"; import { notifyListChanged } from "@/modules/lists/server/realtime"; @@ -14,22 +15,22 @@ import { createCalendarEvent } from "./calendar-bridge"; import { buildCareReminderBody, buildCareTitle } from "./care-utils"; import { updateScheduleAfterCare } from "./care-schedule"; import { addGardenCareTask, getList, listLists } from "./lists-bridge"; +import { containerInput, containerUpdateInput, plantInput, plantUpdateInput } from "./schemas"; -const containerInput = z.object({ - name: z.string().trim().min(1).max(120), - type: z.string().trim().min(1).max(40).default("other"), - locationNotes: z.string().trim().max(500).nullable().optional(), - coverImageUrl: z.string().trim().max(500).nullable().optional(), -}); +function toScope(ctx: ApiAuthContext) { + return { householdId: ctx.householdId, userId: ctx.userId }; +} -export async function createContainer(input: z.input) { +export async function createContainerForScope( + scope: ApiAuthContext, + input: z.input, +) { const parsed = containerInput.parse(input); - const { household } = await getCurrentSession(); const [container] = await db .insert(gardenContainers) .values({ - householdId: household.id, + householdId: scope.householdId, name: parsed.name, type: parsed.type, locationNotes: parsed.locationNotes ?? null, @@ -39,22 +40,31 @@ export async function createContainer(input: z.input) { if (!container) throw new Error("Container was not created"); - await logActivity({ + await logActivityForScope(toScope(scope), { entityType: "garden.container", entityId: container.id, action: "create", payload: { name: container.name }, }); + return container; +} + +export async function createContainer(input: z.input) { + const { household, user } = await getCurrentSession(); + const container = await createContainerForScope( + { householdId: household.id, userId: user.id, role: null }, + input, + ); revalidatePath("/garden"); return container; } -export async function updateContainer( - input: { id: string } & Partial>, +export async function updateContainerForScope( + scope: ApiAuthContext, + input: { id: string } & z.input, ) { - const parsed = z.object({ id: z.string().uuid() }).and(containerInput.partial()).parse(input); - const { household } = await getCurrentSession(); - await assertCanAccessContainer(parsed.id, household.id); + const parsed = z.object({ id: z.string().uuid() }).and(containerUpdateInput).parse(input); + await assertCanAccessContainer(parsed.id, scope.householdId); await db .update(gardenContainers) @@ -69,27 +79,40 @@ export async function updateContainer( }) .where(eq(gardenContainers.id, parsed.id)); - await logActivity({ + await logActivityForScope(toScope(scope), { entityType: "garden.container", entityId: parsed.id, action: "update", payload: { name: parsed.name }, }); +} + +export async function updateContainer( + input: { id: string } & Partial>, +) { + const parsed = z.object({ id: z.string().uuid() }).and(containerInput.partial()).parse(input); + const { household, user } = await getCurrentSession(); + await updateContainerForScope({ householdId: household.id, userId: user.id, role: null }, parsed); revalidatePath("/garden"); revalidatePath(`/garden/containers/${parsed.id}`); } -export async function deleteContainer(input: { id: string }) { +export async function deleteContainerForScope(scope: ApiAuthContext, input: { id: string }) { const parsed = z.object({ id: z.string().uuid() }).parse(input); - const { household } = await getCurrentSession(); - await assertCanAccessContainer(parsed.id, household.id); + await assertCanAccessContainer(parsed.id, scope.householdId); - await logActivity({ + await logActivityForScope(toScope(scope), { entityType: "garden.container", entityId: parsed.id, action: "delete", }); await db.delete(gardenContainers).where(eq(gardenContainers.id, parsed.id)); +} + +export async function deleteContainer(input: { id: string }) { + const parsed = z.object({ id: z.string().uuid() }).parse(input); + const { household, user } = await getCurrentSession(); + await deleteContainerForScope({ householdId: household.id, userId: user.id, role: null }, parsed); revalidatePath("/garden"); } @@ -179,35 +202,20 @@ async function assertCanAccessContainer(id: string, householdId: string) { // ─── Plant actions ──────────────────────────────────────────────────────────── -const plantInput = z.object({ - name: z.string().trim().min(1).max(120), - category: z.string().trim().min(1).max(40).default("other"), - containerId: z.string().uuid().nullable().optional(), - healthStatus: z.string().trim().min(1).max(40).default("healthy"), - growthStage: z.string().trim().max(40).nullable().optional(), - scientificName: z.string().trim().max(200).nullable().optional(), - speciesId: z.string().trim().max(100).nullable().optional(), - sunlight: z.string().trim().max(200).nullable().optional(), - wateringNotes: z.string().trim().max(2000).nullable().optional(), - fertilizingNotes: z.string().trim().max(2000).nullable().optional(), - notes: z.string().trim().max(2000).nullable().optional(), - acquisitionDate: z.string().nullable().optional(), - images: z.array(z.string().min(1)).max(10).default([]), - primaryImageUrl: z.string().min(1).nullable().optional(), -}); - -export async function createPlant(input: z.input) { +export async function createPlantForScope( + scope: ApiAuthContext, + input: z.input, +) { const parsed = plantInput.parse(input); - const { household } = await getCurrentSession(); if (parsed.containerId) { - await assertCanAccessContainer(parsed.containerId, household.id); + await assertCanAccessContainer(parsed.containerId, scope.householdId); } const [plant] = await db .insert(gardenPlants) .values({ - householdId: household.id, + householdId: scope.householdId, name: parsed.name, category: parsed.category ?? "other", containerId: parsed.containerId ?? null, @@ -227,23 +235,35 @@ export async function createPlant(input: z.input) { if (!plant) throw new Error("Plant was not created"); - await logActivity({ + await logActivityForScope(toScope(scope), { entityType: "garden.plant", entityId: plant.id, action: "create", payload: { name: plant.name }, }); + return plant; +} + +export async function createPlant(input: z.input) { + const parsed = plantInput.parse(input); + const { household, user } = await getCurrentSession(); + const plant = await createPlantForScope( + { householdId: household.id, userId: user.id, role: null }, + parsed, + ); revalidatePath("/garden"); return plant; } -export async function updatePlant(input: { id: string } & Partial>) { - const parsed = z.object({ id: z.string().uuid() }).and(plantInput.partial()).parse(input); - const { household } = await getCurrentSession(); - await assertCanAccessPlant(parsed.id, household.id); +export async function updatePlantForScope( + scope: ApiAuthContext, + input: { id: string } & z.input, +) { + const parsed = z.object({ id: z.string().uuid() }).and(plantUpdateInput).parse(input); + await assertCanAccessPlant(parsed.id, scope.householdId); if (parsed.containerId) { - await assertCanAccessContainer(parsed.containerId, household.id); + await assertCanAccessContainer(parsed.containerId, scope.householdId); } await db @@ -272,20 +292,25 @@ export async function updatePlant(input: { id: string } & Partial>) { + const parsed = z.object({ id: z.string().uuid() }).and(plantInput.partial()).parse(input); + const { household, user } = await getCurrentSession(); + await updatePlantForScope({ householdId: household.id, userId: user.id, role: null }, parsed); revalidatePath("/garden"); revalidatePath(`/garden/plants/${parsed.id}`); } -export async function deletePlant(input: { id: string }) { +export async function deletePlantForScope(scope: ApiAuthContext, input: { id: string }) { const parsed = z.object({ id: z.string().uuid() }).parse(input); - const { household } = await getCurrentSession(); - await assertCanAccessPlant(parsed.id, household.id); + await assertCanAccessPlant(parsed.id, scope.householdId); const [plant] = await db .select({ name: gardenPlants.name }) @@ -293,13 +318,19 @@ export async function deletePlant(input: { id: string }) { .where(eq(gardenPlants.id, parsed.id)) .limit(1); - await logActivity({ + await logActivityForScope(toScope(scope), { entityType: "garden.plant", entityId: parsed.id, action: "delete", payload: { name: plant?.name }, }); await db.delete(gardenPlants).where(eq(gardenPlants.id, parsed.id)); +} + +export async function deletePlant(input: { id: string }) { + const parsed = z.object({ id: z.string().uuid() }).parse(input); + const { household, user } = await getCurrentSession(); + await deletePlantForScope({ householdId: household.id, userId: user.id, role: null }, parsed); revalidatePath("/garden"); } diff --git a/src/modules/garden/server/queries.ts b/src/modules/garden/server/queries.ts index 85cf7bc..56ab72f 100644 --- a/src/modules/garden/server/queries.ts +++ b/src/modules/garden/server/queries.ts @@ -37,9 +37,7 @@ export type PlantSummaryDto = { category: string; }; -export async function listContainers(): Promise { - const { household } = await getCurrentSession(); - +export async function listContainersForScope(householdId: string): Promise { const [rows, countRows] = await Promise.all([ db .select({ @@ -54,7 +52,7 @@ export async function listContainers(): Promise { updatedAt: gardenContainers.updatedAt, }) .from(gardenContainers) - .where(eq(gardenContainers.householdId, household.id)) + .where(eq(gardenContainers.householdId, householdId)) .orderBy(gardenContainers.name), db .select({ @@ -62,7 +60,7 @@ export async function listContainers(): Promise { plantCount: sql`count(*)::int`, }) .from(gardenPlants) - .where(eq(gardenPlants.householdId, household.id)) + .where(eq(gardenPlants.householdId, householdId)) .groupBy(gardenPlants.containerId), ]); @@ -82,9 +80,15 @@ export async function listContainers(): Promise { })); } -export async function getContainer(id: string): Promise { +export async function listContainers(): Promise { const { household } = await getCurrentSession(); + return listContainersForScope(household.id); +} +export async function getContainerForScope( + householdId: string, + id: string, +): Promise { const [container] = await db .select({ id: gardenContainers.id, @@ -98,7 +102,7 @@ export async function getContainer(id: string): Promise { + const { household } = await getCurrentSession(); + return getContainerForScope(household.id, id); +} + export async function searchContainers(query: string, householdId: string) { const rows = await db .select({ id: gardenContainers.id, name: gardenContainers.name }) @@ -203,14 +212,13 @@ export type PlantDetailDto = { updatedAt: string; }; -export async function listPlants({ containerId }: { containerId?: string } = {}): Promise< - PlantListItemDto[] -> { - const { household } = await getCurrentSession(); - +export async function listPlantsForScope( + householdId: string, + { containerId }: { containerId?: string } = {}, +): Promise { const filter = containerId - ? and(eq(gardenPlants.householdId, household.id), eq(gardenPlants.containerId, containerId)) - : eq(gardenPlants.householdId, household.id); + ? and(eq(gardenPlants.householdId, householdId), eq(gardenPlants.containerId, containerId)) + : eq(gardenPlants.householdId, householdId); const rows = await db .select({ @@ -248,9 +256,17 @@ export async function listPlants({ containerId }: { containerId?: string } = {}) })); } -export async function getPlant(id: string): Promise { +export async function listPlants({ containerId }: { containerId?: string } = {}): Promise< + PlantListItemDto[] +> { const { household } = await getCurrentSession(); + return listPlantsForScope(household.id, { containerId }); +} +export async function getPlantForScope( + householdId: string, + id: string, +): Promise { const [row] = await db .select({ id: gardenPlants.id, @@ -275,7 +291,7 @@ export async function getPlant(id: string): Promise { }) .from(gardenPlants) .leftJoin(gardenContainers, eq(gardenPlants.containerId, gardenContainers.id)) - .where(and(eq(gardenPlants.id, id), eq(gardenPlants.householdId, household.id))) + .where(and(eq(gardenPlants.id, id), eq(gardenPlants.householdId, householdId))) .limit(1); if (!row) return null; @@ -339,6 +355,11 @@ export async function getPlant(id: string): Promise { }; } +export async function getPlant(id: string): Promise { + const { household } = await getCurrentSession(); + return getPlantForScope(household.id, id); +} + export async function searchPlants(query: string, householdId: string) { const rows = await db .select({ diff --git a/src/modules/garden/server/schemas.ts b/src/modules/garden/server/schemas.ts new file mode 100644 index 0000000..82d44a7 --- /dev/null +++ b/src/modules/garden/server/schemas.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +export const containerInput = z.object({ + name: z.string().trim().min(1).max(120), + type: z.string().trim().min(1).max(40).default("other"), + locationNotes: z.string().trim().max(500).nullable().optional(), + coverImageUrl: z.string().trim().max(500).nullable().optional(), +}); + +export const containerUpdateInput = containerInput.partial(); + +export const plantInput = z.object({ + name: z.string().trim().min(1).max(120), + category: z.string().trim().min(1).max(40).default("other"), + containerId: z.string().uuid().nullable().optional(), + healthStatus: z.string().trim().min(1).max(40).default("healthy"), + growthStage: z.string().trim().max(40).nullable().optional(), + scientificName: z.string().trim().max(200).nullable().optional(), + speciesId: z.string().trim().max(100).nullable().optional(), + sunlight: z.string().trim().max(200).nullable().optional(), + wateringNotes: z.string().trim().max(2000).nullable().optional(), + fertilizingNotes: z.string().trim().max(2000).nullable().optional(), + notes: z.string().trim().max(2000).nullable().optional(), + acquisitionDate: z.string().nullable().optional(), + images: z.array(z.string().min(1)).max(10).default([]), + primaryImageUrl: z.string().min(1).nullable().optional(), +}); + +export const plantUpdateInput = plantInput.partial(); diff --git a/tests/unit/api-v1-auth.test.ts b/tests/unit/api-v1-auth.test.ts new file mode 100644 index 0000000..2ac650a --- /dev/null +++ b/tests/unit/api-v1-auth.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { resolveApiAuth } from "../../src/lib/api-auth"; + +describe("resolveApiAuth", () => { + it("returns null without session or bearer token", async () => { + const request = new Request("http://localhost/api/v1/garden/plants"); + const ctx = await resolveApiAuth(request); + assert.equal(ctx, null); + }); + + it("returns null for malformed bearer header", async () => { + const request = new Request("http://localhost/api/v1/garden/plants", { + headers: { Authorization: "Basic dXNlcjpwYXNz" }, + }); + assert.equal(await resolveApiAuth(request), null); + }); + + it("returns null for empty bearer token without hitting DB match", async () => { + const request = new Request("http://localhost/api/v1/garden/plants", { + headers: { Authorization: "Bearer " }, + }); + assert.equal(await resolveApiAuth(request), null); + }); + + it("accepts Bearer prefix on Authorization header shape", async () => { + const request = new Request("http://localhost/api/v1/garden/plants", { + headers: { Authorization: "Bearer famapp-api-token-abc123" }, + }); + const header = request.headers.get("Authorization"); + assert.ok(header?.startsWith("Bearer ")); + assert.equal(header!.slice("Bearer ".length).trim(), "famapp-api-token-abc123"); + }); +}); + +describe("GET /api/v1/garden/plants auth gate", () => { + it("returns 401 without authentication", async () => { + const { GET } = await import("../../src/app/api/v1/garden/plants/route"); + const request = new Request("http://localhost/api/v1/garden/plants"); + const response = await GET(request); + assert.equal(response.status, 401); + assert.deepEqual(await response.json(), { error: "Unauthorized" }); + }); +}); + +describe("GET /api/v1/garden/containers auth gate", () => { + it("returns 401 without authentication", async () => { + const { GET } = await import("../../src/app/api/v1/garden/containers/route"); + const request = new Request("http://localhost/api/v1/garden/containers"); + const response = await GET(request); + assert.equal(response.status, 401); + assert.deepEqual(await response.json(), { error: "Unauthorized" }); + }); +}); + +describe("GET /api/v1/bangs auth gate", () => { + it("returns 401 without authentication", async () => { + const { GET } = await import("../../src/app/api/v1/bangs/route"); + const request = new Request("http://localhost/api/v1/bangs"); + const response = await GET(request); + assert.equal(response.status, 401); + assert.deepEqual(await response.json(), { error: "Unauthorized" }); + }); +}); + +describe("PATCH /api/v1/bangs/[id] auth gate", () => { + it("returns 401 without authentication", async () => { + const { PATCH } = await import("../../src/app/api/v1/bangs/[id]/route"); + const request = new Request( + "http://localhost/api/v1/bangs/00000000-0000-4000-8000-000000000001", + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ occurredOn: "2026-01-01" }), + }, + ); + const response = await PATCH(request, { + params: Promise.resolve({ id: "00000000-0000-4000-8000-000000000001" }), + }); + assert.equal(response.status, 401); + assert.deepEqual(await response.json(), { error: "Unauthorized" }); + }); +});