feat: api v1 garden bangs routes and openapi

This commit is contained in:
ginnoir
2026-07-04 19:13:49 -05:00
parent d4304b005c
commit e8d13bede8
16 changed files with 1351 additions and 113 deletions
+2
View File
@@ -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.
+36 -9
View File
@@ -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 <household-api-token>` header
- `Authorization: Bearer <household-api-token>` 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
+829
View File
@@ -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
+28
View File
@@ -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 });
});
}
+31
View File
@@ -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,
);
});
}
@@ -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 });
});
}
+21
View File
@@ -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);
});
}
@@ -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 });
});
}
+23
View File
@@ -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);
});
}
+52 -33
View File
@@ -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<typeof addBangInput> = {}) {
export async function addBangForScope(
scope: ApiAuthContext,
input: z.input<typeof addBangInput> = {},
) {
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<typeof updateBangInput>) {
const parsed = updateBangInput.parse(input);
const { household } = await getCurrentSession();
await assertCanAccessBang(parsed.id, household.id);
export async function addBang(input: z.input<typeof addBangInput> = {}) {
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<typeof updateBangInput>,
) {
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<typeof updateBangInput>) {
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<typeof deleteBangInput>) {
export async function updateBang(input: { id: string } & z.input<typeof updateBangInput>) {
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<typeof deleteBangInput>) {
.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<typeof deleteBangInput>) {
});
await db.delete(bangEvents).where(eq(bangEvents.id, parsed.id));
}
export async function deleteBang(input: z.input<typeof deleteBangInput>) {
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");
}
+13 -2
View File
@@ -14,7 +14,10 @@ export type RecentBangDto = {
recordedByName: string | null;
};
export async function getBangStats(householdId: string, limit: number): Promise<BangStatsDto> {
export async function getBangStatsForScope(
householdId: string,
limit: number,
): Promise<BangStatsDto> {
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<BangStatsDto> {
return getBangStatsForScope(householdId, limit);
}
+11
View File
@@ -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,
});
+84 -53
View File
@@ -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<typeof containerInput>) {
export async function createContainerForScope(
scope: ApiAuthContext,
input: z.input<typeof containerInput>,
) {
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<typeof containerInput>) {
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<typeof containerInput>) {
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<z.input<typeof containerInput>>,
export async function updateContainerForScope(
scope: ApiAuthContext,
input: { id: string } & z.input<typeof containerUpdateInput>,
) {
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<z.input<typeof containerInput>>,
) {
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<typeof plantInput>) {
export async function createPlantForScope(
scope: ApiAuthContext,
input: z.input<typeof plantInput>,
) {
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<typeof plantInput>) {
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<typeof plantInput>) {
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<z.input<typeof plantInput>>) {
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<typeof plantUpdateInput>,
) {
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<z.input<typeof
})
.where(eq(gardenPlants.id, parsed.id));
await logActivity({
await logActivityForScope(toScope(scope), {
entityType: "garden.plant",
entityId: parsed.id,
action: "update",
payload: { name: parsed.name },
});
}
export async function updatePlant(input: { id: string } & Partial<z.input<typeof plantInput>>) {
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");
}
+37 -16
View File
@@ -37,9 +37,7 @@ export type PlantSummaryDto = {
category: string;
};
export async function listContainers(): Promise<ContainerDto[]> {
const { household } = await getCurrentSession();
export async function listContainersForScope(householdId: string): Promise<ContainerDto[]> {
const [rows, countRows] = await Promise.all([
db
.select({
@@ -54,7 +52,7 @@ export async function listContainers(): Promise<ContainerDto[]> {
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<ContainerDto[]> {
plantCount: sql<number>`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<ContainerDto[]> {
}));
}
export async function getContainer(id: string): Promise<ContainerDetailDto | null> {
export async function listContainers(): Promise<ContainerDto[]> {
const { household } = await getCurrentSession();
return listContainersForScope(household.id);
}
export async function getContainerForScope(
householdId: string,
id: string,
): Promise<ContainerDetailDto | null> {
const [container] = await db
.select({
id: gardenContainers.id,
@@ -98,7 +102,7 @@ export async function getContainer(id: string): Promise<ContainerDetailDto | nul
updatedAt: gardenContainers.updatedAt,
})
.from(gardenContainers)
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, household.id)))
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, householdId)))
.limit(1);
if (!container) return null;
@@ -131,6 +135,11 @@ export async function getContainer(id: string): Promise<ContainerDetailDto | nul
};
}
export async function getContainer(id: string): Promise<ContainerDetailDto | null> {
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<PlantListItemDto[]> {
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<PlantDetailDto | null> {
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<PlantDetailDto | null> {
const [row] = await db
.select({
id: gardenPlants.id,
@@ -275,7 +291,7 @@ export async function getPlant(id: string): Promise<PlantDetailDto | null> {
})
.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<PlantDetailDto | null> {
};
}
export async function getPlant(id: string): Promise<PlantDetailDto | null> {
const { household } = await getCurrentSession();
return getPlantForScope(household.id, id);
}
export async function searchPlants(query: string, householdId: string) {
const rows = await db
.select({
+29
View File
@@ -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();
+83
View File
@@ -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" });
});
});