feat: api v1 garden bangs routes and openapi
This commit is contained in:
@@ -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`.
|
- **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.
|
- **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.
|
- **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.
|
- **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
@@ -1,19 +1,27 @@
|
|||||||
# famapp HTTP API (v1)
|
# 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
|
## Authentication
|
||||||
|
|
||||||
Every endpoint accepts **either**:
|
Every endpoint accepts **either**:
|
||||||
|
|
||||||
- Auth.js session cookie (browser login), or
|
- 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" }`.
|
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
|
### Calendars
|
||||||
|
|
||||||
@@ -59,11 +67,30 @@ Generate bearer tokens in **Settings → API tokens**.
|
|||||||
| PATCH | `/api/v1/notes/:id` | Update note |
|
| PATCH | `/api/v1/notes/:id` | Update note |
|
||||||
| DELETE | `/api/v1/notes/:id` | Delete 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 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.
|
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
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,65 +3,71 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import type { ApiAuthContext } from "@/lib/api-auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { logActivity } from "@/modules/_core/activity";
|
import { logActivityForScope } from "@/modules/_core/activity";
|
||||||
import { bangEvents } from "../schema";
|
import { bangEvents } from "../schema";
|
||||||
|
import { addBangInput, updateBangInput } from "./schemas";
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteBangInput = z.object({
|
const deleteBangInput = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function toScope(ctx: ApiAuthContext) {
|
||||||
|
return { householdId: ctx.householdId, userId: ctx.userId };
|
||||||
|
}
|
||||||
|
|
||||||
function todayString(): string {
|
function todayString(): string {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
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 parsed = addBangInput.parse(input);
|
||||||
const { user, household } = await getCurrentSession();
|
|
||||||
|
|
||||||
const occurredOn = parsed.occurredOn ?? todayString();
|
const occurredOn = parsed.occurredOn ?? todayString();
|
||||||
|
|
||||||
const [bang] = await db
|
const [bang] = await db
|
||||||
.insert(bangEvents)
|
.insert(bangEvents)
|
||||||
.values({
|
.values({
|
||||||
householdId: household.id,
|
householdId: scope.householdId,
|
||||||
recordedBy: user.id,
|
recordedBy: scope.userId,
|
||||||
occurredOn,
|
occurredOn,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!bang) throw new Error("Bang was not recorded");
|
if (!bang) throw new Error("Bang was not recorded");
|
||||||
|
|
||||||
await logActivity({
|
await logActivityForScope(toScope(scope), {
|
||||||
entityType: "bangs.event",
|
entityType: "bangs.event",
|
||||||
entityId: bang.id,
|
entityId: bang.id,
|
||||||
action: "create",
|
action: "create",
|
||||||
payload: { occurredOn },
|
payload: { occurredOn },
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/");
|
|
||||||
revalidatePath("/d/[slug]", "page");
|
|
||||||
|
|
||||||
return bang;
|
return bang;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBang(input: z.input<typeof updateBangInput>) {
|
export async function addBang(input: z.input<typeof addBangInput> = {}) {
|
||||||
const parsed = updateBangInput.parse(input);
|
const { user, household } = await getCurrentSession();
|
||||||
const { household } = await getCurrentSession();
|
const bang = await addBangForScope(
|
||||||
await assertCanAccessBang(parsed.id, household.id);
|
{ 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
|
const [bang] = await db
|
||||||
.update(bangEvents)
|
.update(bangEvents)
|
||||||
@@ -71,23 +77,31 @@ export async function updateBang(input: z.input<typeof updateBangInput>) {
|
|||||||
|
|
||||||
if (!bang) throw new Error("Bang was not updated");
|
if (!bang) throw new Error("Bang was not updated");
|
||||||
|
|
||||||
await logActivity({
|
await logActivityForScope(toScope(scope), {
|
||||||
entityType: "bangs.event",
|
entityType: "bangs.event",
|
||||||
entityId: bang.id,
|
entityId: bang.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
payload: { occurredOn: parsed.occurredOn },
|
payload: { occurredOn: parsed.occurredOn },
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/");
|
|
||||||
revalidatePath("/d/[slug]", "page");
|
|
||||||
|
|
||||||
return bang;
|
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 parsed = deleteBangInput.parse(input);
|
||||||
const { household } = await getCurrentSession();
|
await assertCanAccessBang(parsed.id, scope.householdId);
|
||||||
await assertCanAccessBang(parsed.id, household.id);
|
|
||||||
|
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select({ occurredOn: bangEvents.occurredOn })
|
.select({ occurredOn: bangEvents.occurredOn })
|
||||||
@@ -95,7 +109,7 @@ export async function deleteBang(input: z.input<typeof deleteBangInput>) {
|
|||||||
.where(eq(bangEvents.id, parsed.id))
|
.where(eq(bangEvents.id, parsed.id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
await logActivity({
|
await logActivityForScope(toScope(scope), {
|
||||||
entityType: "bangs.event",
|
entityType: "bangs.event",
|
||||||
entityId: parsed.id,
|
entityId: parsed.id,
|
||||||
action: "delete",
|
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));
|
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("/");
|
||||||
revalidatePath("/d/[slug]", "page");
|
revalidatePath("/d/[slug]", "page");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ export type RecentBangDto = {
|
|||||||
recordedByName: string | null;
|
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
|
const [totalRow] = await db
|
||||||
.select({ total: count() })
|
.select({ total: count() })
|
||||||
.from(bangEvents)
|
.from(bangEvents)
|
||||||
@@ -34,6 +37,14 @@ export async function getBangStats(householdId: string, limit: number): Promise<
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
total: totalRow?.total ?? 0,
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -3,9 +3,10 @@
|
|||||||
import { and, eq, lte, sql } from "drizzle-orm";
|
import { and, eq, lte, sql } from "drizzle-orm";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import type { ApiAuthContext } from "@/lib/api-auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
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 { cancelReminder, scheduleReminder } from "@/modules/_core/reminders";
|
||||||
import { listItems } from "@/modules/lists/schema";
|
import { listItems } from "@/modules/lists/schema";
|
||||||
import { notifyListChanged } from "@/modules/lists/server/realtime";
|
import { notifyListChanged } from "@/modules/lists/server/realtime";
|
||||||
@@ -14,22 +15,22 @@ import { createCalendarEvent } from "./calendar-bridge";
|
|||||||
import { buildCareReminderBody, buildCareTitle } from "./care-utils";
|
import { buildCareReminderBody, buildCareTitle } from "./care-utils";
|
||||||
import { updateScheduleAfterCare } from "./care-schedule";
|
import { updateScheduleAfterCare } from "./care-schedule";
|
||||||
import { addGardenCareTask, getList, listLists } from "./lists-bridge";
|
import { addGardenCareTask, getList, listLists } from "./lists-bridge";
|
||||||
|
import { containerInput, containerUpdateInput, plantInput, plantUpdateInput } from "./schemas";
|
||||||
|
|
||||||
const containerInput = z.object({
|
function toScope(ctx: ApiAuthContext) {
|
||||||
name: z.string().trim().min(1).max(120),
|
return { householdId: ctx.householdId, userId: ctx.userId };
|
||||||
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 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 parsed = containerInput.parse(input);
|
||||||
const { household } = await getCurrentSession();
|
|
||||||
|
|
||||||
const [container] = await db
|
const [container] = await db
|
||||||
.insert(gardenContainers)
|
.insert(gardenContainers)
|
||||||
.values({
|
.values({
|
||||||
householdId: household.id,
|
householdId: scope.householdId,
|
||||||
name: parsed.name,
|
name: parsed.name,
|
||||||
type: parsed.type,
|
type: parsed.type,
|
||||||
locationNotes: parsed.locationNotes ?? null,
|
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");
|
if (!container) throw new Error("Container was not created");
|
||||||
|
|
||||||
await logActivity({
|
await logActivityForScope(toScope(scope), {
|
||||||
entityType: "garden.container",
|
entityType: "garden.container",
|
||||||
entityId: container.id,
|
entityId: container.id,
|
||||||
action: "create",
|
action: "create",
|
||||||
payload: { name: container.name },
|
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");
|
revalidatePath("/garden");
|
||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateContainer(
|
export async function updateContainerForScope(
|
||||||
input: { id: string } & Partial<z.input<typeof containerInput>>,
|
scope: ApiAuthContext,
|
||||||
|
input: { id: string } & z.input<typeof containerUpdateInput>,
|
||||||
) {
|
) {
|
||||||
const parsed = z.object({ id: z.string().uuid() }).and(containerInput.partial()).parse(input);
|
const parsed = z.object({ id: z.string().uuid() }).and(containerUpdateInput).parse(input);
|
||||||
const { household } = await getCurrentSession();
|
await assertCanAccessContainer(parsed.id, scope.householdId);
|
||||||
await assertCanAccessContainer(parsed.id, household.id);
|
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(gardenContainers)
|
.update(gardenContainers)
|
||||||
@@ -69,27 +79,40 @@ export async function updateContainer(
|
|||||||
})
|
})
|
||||||
.where(eq(gardenContainers.id, parsed.id));
|
.where(eq(gardenContainers.id, parsed.id));
|
||||||
|
|
||||||
await logActivity({
|
await logActivityForScope(toScope(scope), {
|
||||||
entityType: "garden.container",
|
entityType: "garden.container",
|
||||||
entityId: parsed.id,
|
entityId: parsed.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
payload: { name: parsed.name },
|
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");
|
||||||
revalidatePath(`/garden/containers/${parsed.id}`);
|
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 parsed = z.object({ id: z.string().uuid() }).parse(input);
|
||||||
const { household } = await getCurrentSession();
|
await assertCanAccessContainer(parsed.id, scope.householdId);
|
||||||
await assertCanAccessContainer(parsed.id, household.id);
|
|
||||||
|
|
||||||
await logActivity({
|
await logActivityForScope(toScope(scope), {
|
||||||
entityType: "garden.container",
|
entityType: "garden.container",
|
||||||
entityId: parsed.id,
|
entityId: parsed.id,
|
||||||
action: "delete",
|
action: "delete",
|
||||||
});
|
});
|
||||||
await db.delete(gardenContainers).where(eq(gardenContainers.id, parsed.id));
|
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");
|
revalidatePath("/garden");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,35 +202,20 @@ async function assertCanAccessContainer(id: string, householdId: string) {
|
|||||||
|
|
||||||
// ─── Plant actions ────────────────────────────────────────────────────────────
|
// ─── Plant actions ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const plantInput = z.object({
|
export async function createPlantForScope(
|
||||||
name: z.string().trim().min(1).max(120),
|
scope: ApiAuthContext,
|
||||||
category: z.string().trim().min(1).max(40).default("other"),
|
input: z.input<typeof plantInput>,
|
||||||
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>) {
|
|
||||||
const parsed = plantInput.parse(input);
|
const parsed = plantInput.parse(input);
|
||||||
const { household } = await getCurrentSession();
|
|
||||||
|
|
||||||
if (parsed.containerId) {
|
if (parsed.containerId) {
|
||||||
await assertCanAccessContainer(parsed.containerId, household.id);
|
await assertCanAccessContainer(parsed.containerId, scope.householdId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [plant] = await db
|
const [plant] = await db
|
||||||
.insert(gardenPlants)
|
.insert(gardenPlants)
|
||||||
.values({
|
.values({
|
||||||
householdId: household.id,
|
householdId: scope.householdId,
|
||||||
name: parsed.name,
|
name: parsed.name,
|
||||||
category: parsed.category ?? "other",
|
category: parsed.category ?? "other",
|
||||||
containerId: parsed.containerId ?? null,
|
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");
|
if (!plant) throw new Error("Plant was not created");
|
||||||
|
|
||||||
await logActivity({
|
await logActivityForScope(toScope(scope), {
|
||||||
entityType: "garden.plant",
|
entityType: "garden.plant",
|
||||||
entityId: plant.id,
|
entityId: plant.id,
|
||||||
action: "create",
|
action: "create",
|
||||||
payload: { name: plant.name },
|
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");
|
revalidatePath("/garden");
|
||||||
return plant;
|
return plant;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePlant(input: { id: string } & Partial<z.input<typeof plantInput>>) {
|
export async function updatePlantForScope(
|
||||||
const parsed = z.object({ id: z.string().uuid() }).and(plantInput.partial()).parse(input);
|
scope: ApiAuthContext,
|
||||||
const { household } = await getCurrentSession();
|
input: { id: string } & z.input<typeof plantUpdateInput>,
|
||||||
await assertCanAccessPlant(parsed.id, household.id);
|
) {
|
||||||
|
const parsed = z.object({ id: z.string().uuid() }).and(plantUpdateInput).parse(input);
|
||||||
|
await assertCanAccessPlant(parsed.id, scope.householdId);
|
||||||
|
|
||||||
if (parsed.containerId) {
|
if (parsed.containerId) {
|
||||||
await assertCanAccessContainer(parsed.containerId, household.id);
|
await assertCanAccessContainer(parsed.containerId, scope.householdId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
@@ -272,20 +292,25 @@ export async function updatePlant(input: { id: string } & Partial<z.input<typeof
|
|||||||
})
|
})
|
||||||
.where(eq(gardenPlants.id, parsed.id));
|
.where(eq(gardenPlants.id, parsed.id));
|
||||||
|
|
||||||
await logActivity({
|
await logActivityForScope(toScope(scope), {
|
||||||
entityType: "garden.plant",
|
entityType: "garden.plant",
|
||||||
entityId: parsed.id,
|
entityId: parsed.id,
|
||||||
action: "update",
|
action: "update",
|
||||||
payload: { name: parsed.name },
|
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");
|
||||||
revalidatePath(`/garden/plants/${parsed.id}`);
|
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 parsed = z.object({ id: z.string().uuid() }).parse(input);
|
||||||
const { household } = await getCurrentSession();
|
await assertCanAccessPlant(parsed.id, scope.householdId);
|
||||||
await assertCanAccessPlant(parsed.id, household.id);
|
|
||||||
|
|
||||||
const [plant] = await db
|
const [plant] = await db
|
||||||
.select({ name: gardenPlants.name })
|
.select({ name: gardenPlants.name })
|
||||||
@@ -293,13 +318,19 @@ export async function deletePlant(input: { id: string }) {
|
|||||||
.where(eq(gardenPlants.id, parsed.id))
|
.where(eq(gardenPlants.id, parsed.id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
await logActivity({
|
await logActivityForScope(toScope(scope), {
|
||||||
entityType: "garden.plant",
|
entityType: "garden.plant",
|
||||||
entityId: parsed.id,
|
entityId: parsed.id,
|
||||||
action: "delete",
|
action: "delete",
|
||||||
payload: { name: plant?.name },
|
payload: { name: plant?.name },
|
||||||
});
|
});
|
||||||
await db.delete(gardenPlants).where(eq(gardenPlants.id, parsed.id));
|
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");
|
revalidatePath("/garden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,9 +37,7 @@ export type PlantSummaryDto = {
|
|||||||
category: string;
|
category: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function listContainers(): Promise<ContainerDto[]> {
|
export async function listContainersForScope(householdId: string): Promise<ContainerDto[]> {
|
||||||
const { household } = await getCurrentSession();
|
|
||||||
|
|
||||||
const [rows, countRows] = await Promise.all([
|
const [rows, countRows] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
@@ -54,7 +52,7 @@ export async function listContainers(): Promise<ContainerDto[]> {
|
|||||||
updatedAt: gardenContainers.updatedAt,
|
updatedAt: gardenContainers.updatedAt,
|
||||||
})
|
})
|
||||||
.from(gardenContainers)
|
.from(gardenContainers)
|
||||||
.where(eq(gardenContainers.householdId, household.id))
|
.where(eq(gardenContainers.householdId, householdId))
|
||||||
.orderBy(gardenContainers.name),
|
.orderBy(gardenContainers.name),
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
@@ -62,7 +60,7 @@ export async function listContainers(): Promise<ContainerDto[]> {
|
|||||||
plantCount: sql<number>`count(*)::int`,
|
plantCount: sql<number>`count(*)::int`,
|
||||||
})
|
})
|
||||||
.from(gardenPlants)
|
.from(gardenPlants)
|
||||||
.where(eq(gardenPlants.householdId, household.id))
|
.where(eq(gardenPlants.householdId, householdId))
|
||||||
.groupBy(gardenPlants.containerId),
|
.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();
|
const { household } = await getCurrentSession();
|
||||||
|
return listContainersForScope(household.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContainerForScope(
|
||||||
|
householdId: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<ContainerDetailDto | null> {
|
||||||
const [container] = await db
|
const [container] = await db
|
||||||
.select({
|
.select({
|
||||||
id: gardenContainers.id,
|
id: gardenContainers.id,
|
||||||
@@ -98,7 +102,7 @@ export async function getContainer(id: string): Promise<ContainerDetailDto | nul
|
|||||||
updatedAt: gardenContainers.updatedAt,
|
updatedAt: gardenContainers.updatedAt,
|
||||||
})
|
})
|
||||||
.from(gardenContainers)
|
.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);
|
.limit(1);
|
||||||
|
|
||||||
if (!container) return null;
|
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) {
|
export async function searchContainers(query: string, householdId: string) {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ id: gardenContainers.id, name: gardenContainers.name })
|
.select({ id: gardenContainers.id, name: gardenContainers.name })
|
||||||
@@ -203,14 +212,13 @@ export type PlantDetailDto = {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function listPlants({ containerId }: { containerId?: string } = {}): Promise<
|
export async function listPlantsForScope(
|
||||||
PlantListItemDto[]
|
householdId: string,
|
||||||
> {
|
{ containerId }: { containerId?: string } = {},
|
||||||
const { household } = await getCurrentSession();
|
): Promise<PlantListItemDto[]> {
|
||||||
|
|
||||||
const filter = containerId
|
const filter = containerId
|
||||||
? and(eq(gardenPlants.householdId, household.id), eq(gardenPlants.containerId, containerId))
|
? and(eq(gardenPlants.householdId, householdId), eq(gardenPlants.containerId, containerId))
|
||||||
: eq(gardenPlants.householdId, household.id);
|
: eq(gardenPlants.householdId, householdId);
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.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();
|
const { household } = await getCurrentSession();
|
||||||
|
return listPlantsForScope(household.id, { containerId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlantForScope(
|
||||||
|
householdId: string,
|
||||||
|
id: string,
|
||||||
|
): Promise<PlantDetailDto | null> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
id: gardenPlants.id,
|
id: gardenPlants.id,
|
||||||
@@ -275,7 +291,7 @@ export async function getPlant(id: string): Promise<PlantDetailDto | null> {
|
|||||||
})
|
})
|
||||||
.from(gardenPlants)
|
.from(gardenPlants)
|
||||||
.leftJoin(gardenContainers, eq(gardenPlants.containerId, gardenContainers.id))
|
.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);
|
.limit(1);
|
||||||
|
|
||||||
if (!row) return null;
|
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) {
|
export async function searchPlants(query: string, householdId: string) {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user