feat: api v1 garden bangs routes and openapi

This commit is contained in:
ginnoir
2026-07-04 19:13:49 -05:00
parent d4304b005c
commit e8d13bede8
16 changed files with 1351 additions and 113 deletions
+36 -9
View File
@@ -1,19 +1,27 @@
# famapp HTTP API (v1)
REST JSON API under `/api/v1/`. Full OpenAPI spec is planned in task 87.3.
REST JSON API under `/api/v1/`. Full specification: [`openapi.yaml`](openapi.yaml).
## Authentication
Every endpoint accepts **either**:
- Auth.js session cookie (browser login), or
- `Authorization: Bearer <household-api-token>` header
- `Authorization: Bearer <household-api-token>` header (recommended for external clients)
Unauthorized requests return `401` with `{ "error": "Unauthorized" }`.
Generate bearer tokens in **Settings → API tokens**.
Generate bearer tokens in **Settings → API tokens**. Example:
## Endpoints (87.2)
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" https://fam.ginnoir.com/api/v1/calendars
```
## OpenAPI
See [`openapi.yaml`](openapi.yaml) for paths, request/response schemas, and the `bearerAuth` security scheme. Import into Swagger UI, Postman, or your HTTP client of choice.
## Endpoints
### Calendars
@@ -59,11 +67,30 @@ Generate bearer tokens in **Settings → API tokens**.
| PATCH | `/api/v1/notes/:id` | Update note |
| DELETE | `/api/v1/notes/:id` | Delete note |
### Garden
| Method | Path | Description |
| ------ | ------------------------------- | -------------------------------------- |
| GET | `/api/v1/garden/containers` | List containers |
| POST | `/api/v1/garden/containers` | Create container |
| GET | `/api/v1/garden/containers/:id` | Get container with plants |
| PATCH | `/api/v1/garden/containers/:id` | Update container |
| DELETE | `/api/v1/garden/containers/:id` | Delete container |
| GET | `/api/v1/garden/plants` | List plants (optional `?containerId=`) |
| POST | `/api/v1/garden/plants` | Create plant |
| GET | `/api/v1/garden/plants/:id` | Get plant detail |
| PATCH | `/api/v1/garden/plants/:id` | Update plant |
| DELETE | `/api/v1/garden/plants/:id` | Delete plant |
### Bangs
| Method | Path | Description |
| ------ | ------------------- | ------------------------------------------------ |
| GET | `/api/v1/bangs` | Stats: `total` + `recent` (optional `?limit=10`) |
| POST | `/api/v1/bangs` | Record bang (optional `occurredOn` YYYY-MM-DD) |
| PATCH | `/api/v1/bangs/:id` | Update `occurredOn` |
| DELETE | `/api/v1/bangs/:id` | Delete bang |
## Bearer token visibility
Bearer tokens see **household-visible calendars only** (private calendars are hidden). Mutations on calendars require ownership (session) or household visibility (bearer). Activity log records `actorId: null` for bearer mutations.
## Planned (87.3)
- Garden, bangs routes
- OpenAPI specification
+829
View File
@@ -0,0 +1,829 @@
openapi: 3.1.0
info:
title: famapp API
version: "1.0.0"
description: |
Household-scoped REST JSON API for famapp (fam.ginnoir.com).
Authenticate with an Auth.js session cookie (browser) or a household API bearer token
(external clients). Generate bearer tokens in Settings → API tokens.
servers:
- url: https://fam.ginnoir.com
description: Production
- url: http://localhost:3000
description: Local development
security:
- bearerAuth: []
- sessionCookie: []
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: Household API token from Settings → API tokens
sessionCookie:
type: apiKey
in: cookie
name: authjs.session-token
description: Auth.js session cookie (browser login)
schemas:
Error:
type: object
required: [error]
properties:
error:
type: string
OkResponse:
type: object
required: [ok]
properties:
ok:
type: boolean
enum: [true]
Calendar:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
color: { type: string, nullable: true }
visibility: { type: string, enum: [private, household] }
ownerId: { type: string, format: uuid }
CalendarInput:
type: object
required: [name]
properties:
name: { type: string }
color: { type: string, nullable: true }
visibility: { type: string, enum: [private, household], default: household }
CalendarEvent:
type: object
properties:
id: { type: string, format: uuid }
calendarId: { type: string, format: uuid }
title: { type: string }
startAt: { type: string, format: date-time }
endAt: { type: string, format: date-time }
allDay: { type: boolean }
location: { type: string, nullable: true }
notes: { type: string, nullable: true }
EventInput:
type: object
required: [calendarId, title, startAt, endAt]
properties:
calendarId: { type: string, format: uuid }
title: { type: string }
startAt: { type: string, format: date-time }
endAt: { type: string, format: date-time }
allDay: { type: boolean, default: false }
location: { type: string, nullable: true }
notes: { type: string, nullable: true }
remindMinutesBefore: { type: integer, nullable: true }
ListSummary:
type: object
properties:
id: { type: string, format: uuid }
type: { type: string }
name: { type: string }
archived: { type: boolean }
openCount: { type: integer }
doneCount: { type: integer }
createdAt: { type: string, format: date-time }
ListInput:
type: object
required: [type, name]
properties:
type: { type: string }
name: { type: string }
ListItem:
type: object
properties:
id: { type: string, format: uuid }
listId: { type: string, format: uuid }
text: { type: string }
qty: { type: string, nullable: true }
notes: { type: string, nullable: true }
done: { type: boolean }
dueAt: { type: string, format: date-time, nullable: true }
assigneeId: { type: string, format: uuid, nullable: true }
position: { type: integer }
ItemInput:
type: object
required: [text]
properties:
text: { type: string }
qty: { type: string, nullable: true }
notes: { type: string, nullable: true }
dueAt: { type: string, format: date-time, nullable: true }
assigneeId: { type: string, format: uuid, nullable: true }
metadata: { type: object, nullable: true }
Note:
type: object
properties:
id: { type: string, format: uuid }
householdId: { type: string, format: uuid }
authorId: { type: string, format: uuid }
title: { type: string }
body: { type: string }
pinned: { type: boolean }
remindAt: { type: string, format: date-time, nullable: true }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
NoteInput:
type: object
required: [title]
properties:
title: { type: string }
body: { type: string, default: "" }
pinned: { type: boolean, default: false }
remindAt: { type: string, format: date-time, nullable: true }
GardenContainer:
type: object
properties:
id: { type: string, format: uuid }
householdId: { type: string, format: uuid }
name: { type: string }
type: { type: string }
locationNotes: { type: string, nullable: true }
coverImageUrl: { type: string, nullable: true }
images: { type: array, items: { type: string } }
plantCount: { type: integer }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
GardenContainerDetail:
allOf:
- $ref: "#/components/schemas/GardenContainer"
- type: object
properties:
plants:
type: array
items:
$ref: "#/components/schemas/PlantSummary"
ContainerInput:
type: object
required: [name]
properties:
name: { type: string }
type: { type: string, default: other }
locationNotes: { type: string, nullable: true }
coverImageUrl: { type: string, nullable: true }
PlantSummary:
type: object
properties:
id: { type: string, format: uuid }
name: { type: string }
scientificName: { type: string, nullable: true }
healthStatus: { type: string }
primaryImageUrl: { type: string, nullable: true }
category: { type: string }
PlantListItem:
type: object
properties:
id: { type: string, format: uuid }
containerId: { type: string, format: uuid, nullable: true }
containerName: { type: string, nullable: true }
name: { type: string }
healthStatus: { type: string }
primaryImageUrl: { type: string, nullable: true }
category: { type: string }
lastWateredAt: { type: string, format: date-time, nullable: true }
hasOverdueCare: { type: boolean }
PlantDetail:
type: object
properties:
id: { type: string, format: uuid }
householdId: { type: string, format: uuid }
containerId: { type: string, format: uuid, nullable: true }
containerName: { type: string, nullable: true }
name: { type: string }
scientificName: { type: string, nullable: true }
speciesId: { type: string, nullable: true }
category: { type: string }
notes: { type: string, nullable: true }
acquisitionDate: { type: string, nullable: true }
growthStage: { type: string, nullable: true }
healthStatus: { type: string }
sunlight: { type: string, nullable: true }
wateringNotes: { type: string, nullable: true }
fertilizingNotes: { type: string, nullable: true }
primaryImageUrl: { type: string, nullable: true }
images: { type: array, items: { type: string } }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
PlantInput:
type: object
required: [name]
properties:
name: { type: string }
category: { type: string, default: other }
containerId: { type: string, format: uuid, nullable: true }
healthStatus: { type: string, default: healthy }
growthStage: { type: string, nullable: true }
scientificName: { type: string, nullable: true }
speciesId: { type: string, nullable: true }
sunlight: { type: string, nullable: true }
wateringNotes: { type: string, nullable: true }
fertilizingNotes: { type: string, nullable: true }
notes: { type: string, nullable: true }
acquisitionDate: { type: string, nullable: true }
images: { type: array, items: { type: string }, default: [] }
primaryImageUrl: { type: string, nullable: true }
BangStats:
type: object
properties:
total: { type: integer }
recent:
type: array
items:
$ref: "#/components/schemas/BangSummary"
BangSummary:
type: object
properties:
id: { type: string, format: uuid }
occurredOn: { type: string, format: date, description: YYYY-MM-DD }
recordedByName: { type: string, nullable: true }
Bang:
type: object
properties:
id: { type: string, format: uuid }
occurredOn: { type: string, format: date }
recordedBy: { type: string, format: uuid, nullable: true }
createdAt: { type: string, format: date-time }
BangInput:
type: object
properties:
occurredOn:
type: string
format: date
description: YYYY-MM-DD; defaults to today
BangUpdateInput:
type: object
required: [occurredOn]
properties:
occurredOn: { type: string, format: date }
paths:
/api/v1/calendars:
get:
summary: List visible calendars
tags: [Calendars]
responses:
"200":
description: Calendar list
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/Calendar" }
"401":
{
description: Unauthorized,
content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } },
}
post:
summary: Create calendar
tags: [Calendars]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CalendarInput" }
responses:
"201":
description: Created calendar
content:
application/json:
schema: { $ref: "#/components/schemas/Calendar" }
"401": { description: Unauthorized }
/api/v1/calendars/{id}:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
get:
summary: Get calendar
tags: [Calendars]
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/Calendar" }
"404": { description: Not found }
patch:
summary: Update calendar
tags: [Calendars]
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
visibility: { type: string, enum: [private, household] }
color: { type: string, nullable: true }
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/Calendar" }
delete:
summary: Delete calendar
tags: [Calendars]
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/OkResponse" }
/api/v1/events:
get:
summary: List events in date range
tags: [Events]
parameters:
- name: from
in: query
required: true
schema: { type: string, format: date-time }
- name: to
in: query
required: true
schema: { type: string, format: date-time }
- name: calendarIds
in: query
description: "all or comma-separated UUIDs"
schema: { type: string, default: all }
responses:
"200":
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/CalendarEvent" }
"400": { description: Missing from/to }
post:
summary: Create event
tags: [Events]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/EventInput" }
responses:
"201":
content:
application/json:
schema: { $ref: "#/components/schemas/CalendarEvent" }
/api/v1/events/{id}:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
get:
summary: Get event
tags: [Events]
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/CalendarEvent" }
patch:
summary: Update event
tags: [Events]
requestBody:
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/EventInput"
description: All fields optional for PATCH
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/CalendarEvent" }
delete:
summary: Delete event
tags: [Events]
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/OkResponse" }
/api/v1/lists:
get:
summary: List lists
tags: [Lists]
parameters:
- name: type
in: query
schema: { type: string }
description: Filter by list type (e.g. shopping, tasks)
responses:
"200":
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/ListSummary" }
post:
summary: Create list
tags: [Lists]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ListInput" }
responses:
"201":
content:
application/json:
schema: { $ref: "#/components/schemas/ListSummary" }
/api/v1/lists/{id}:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
get:
summary: Get list with items
tags: [Lists]
responses:
"200":
description: List detail including items array
patch:
summary: Update list name or archive
tags: [Lists]
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
archived: { type: boolean }
responses:
"200": { description: Updated list }
delete:
summary: Archive list
tags: [Lists]
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/OkResponse" }
/api/v1/lists/{id}/items:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
get:
summary: List items
tags: [Lists]
responses:
"200":
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/ListItem" }
post:
summary: Add item
tags: [Lists]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ItemInput" }
responses:
"201":
content:
application/json:
schema: { $ref: "#/components/schemas/ListItem" }
/api/v1/lists/{id}/items/{itemId}:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
- name: itemId
in: path
required: true
schema: { type: string, format: uuid }
patch:
summary: Update or toggle item
tags: [Lists]
requestBody:
content:
application/json:
schema:
type: object
properties:
text: { type: string }
qty: { type: string, nullable: true }
notes: { type: string, nullable: true }
done: { type: boolean }
dueAt: { type: string, format: date-time, nullable: true }
responses:
"200": { description: Updated list with items }
delete:
summary: Delete item
tags: [Lists]
responses:
"200": { description: Updated list with items }
/api/v1/notes:
get:
summary: List notes
tags: [Notes]
responses:
"200":
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/Note" }
post:
summary: Create note
tags: [Notes]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/NoteInput" }
responses:
"201":
content:
application/json:
schema: { $ref: "#/components/schemas/Note" }
/api/v1/notes/{id}:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
get:
summary: Get note
tags: [Notes]
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/Note" }
patch:
summary: Update note
tags: [Notes]
requestBody:
content:
application/json:
schema:
type: object
properties:
title: { type: string }
body: { type: string }
pinned: { type: boolean }
remindAt: { type: string, format: date-time, nullable: true }
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/Note" }
delete:
summary: Delete note
tags: [Notes]
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/OkResponse" }
/api/v1/garden/containers:
get:
summary: List garden containers
tags: [Garden]
responses:
"200":
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/GardenContainer" }
post:
summary: Create container
tags: [Garden]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ContainerInput" }
responses:
"201":
content:
application/json:
schema: { $ref: "#/components/schemas/GardenContainerDetail" }
/api/v1/garden/containers/{id}:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
get:
summary: Get container with plants
tags: [Garden]
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/GardenContainerDetail" }
"404": { description: Not found }
patch:
summary: Update container
tags: [Garden]
requestBody:
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/ContainerInput"
description: All fields optional
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/GardenContainerDetail" }
delete:
summary: Delete container
tags: [Garden]
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/OkResponse" }
/api/v1/garden/plants:
get:
summary: List plants
tags: [Garden]
parameters:
- name: containerId
in: query
schema: { type: string, format: uuid }
description: Filter by container
responses:
"200":
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/PlantListItem" }
post:
summary: Create plant
tags: [Garden]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/PlantInput" }
responses:
"201":
content:
application/json:
schema: { $ref: "#/components/schemas/PlantDetail" }
/api/v1/garden/plants/{id}:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
get:
summary: Get plant detail
tags: [Garden]
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/PlantDetail" }
"404": { description: Not found }
patch:
summary: Update plant
tags: [Garden]
requestBody:
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/PlantInput"
description: All fields optional
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/PlantDetail" }
delete:
summary: Delete plant
tags: [Garden]
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/OkResponse" }
/api/v1/bangs:
get:
summary: Bang stats (total + recent)
tags: [Bangs]
parameters:
- name: limit
in: query
schema: { type: integer, default: 10, minimum: 1, maximum: 100 }
description: Max recent entries to return
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/BangStats" }
post:
summary: Record a bang
tags: [Bangs]
requestBody:
content:
application/json:
schema: { $ref: "#/components/schemas/BangInput" }
responses:
"201":
content:
application/json:
schema: { $ref: "#/components/schemas/Bang" }
/api/v1/bangs/{id}:
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
patch:
summary: Update bang date
tags: [Bangs]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/BangUpdateInput" }
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/Bang" }
delete:
summary: Delete bang
tags: [Bangs]
responses:
"200":
content:
application/json:
schema: { $ref: "#/components/schemas/OkResponse" }
tags:
- name: Calendars
- name: Events
- name: Lists
- name: Notes
- name: Garden
- name: Bangs