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`.
|
||||
|
||||
- **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
@@ -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
|
||||
|
||||
@@ -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 { 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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 { 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,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({
|
||||
|
||||
@@ -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