openapi: 3.0.3 info: title: Meistertracker API version: 1.0.0 description: REST API for the Meistertracker mushroom lab management system. servers: - url: https://localhost:3000 description: Local development (HTTPS) - url: http://localhost:3000 description: Local development (HTTP fallback) components: securitySchemes: cookieAuth: type: apiKey in: cookie name: session schemas: Error: type: object properties: error: type: string Ok: type: object properties: ok: type: boolean MushroomStrain: type: object required: [id, name, kuerzel, created] properties: id: type: integer description: Primary key name: type: string description: Full strain name (unique) kuerzel: type: string description: Short code (unique) description: type: string created: type: string format: date-time updated: type: string format: date-time nullable: true Stamm: type: object required: [id, strainId, name, created] properties: id: type: integer strainId: type: integer description: The Pilzsorte it belongs to name: type: string description: Unique per Pilzsorte, case-insensitive notes: type: string created: type: string format: date-time updated: type: string format: date-time nullable: true security: - cookieAuth: [] paths: /api/auth/setup-required: get: summary: Check if initial setup is needed security: [] responses: '200': description: Setup status content: application/json: schema: type: object properties: setupRequired: type: boolean /api/auth/setup: post: summary: Initial admin account setup security: [] requestBody: required: true content: application/json: schema: type: object required: [username, password] properties: username: type: string password: type: string minLength: 8 responses: '200': description: Setup complete '400': description: Validation error /api/auth/login: post: summary: User login security: [] requestBody: required: true content: application/json: schema: type: object required: [username, password] properties: username: type: string password: type: string responses: '200': description: Login successful, session cookie set '401': description: Invalid credentials /api/auth/logout: post: summary: User logout responses: '200': description: Session invalidated /api/auth/me: get: summary: Get current user info responses: '200': description: Current user content: application/json: schema: type: object properties: username: type: string role: type: string /api/health: get: summary: Server health check security: [] responses: '200': description: Healthy content: application/json: schema: type: object properties: status: type: string enum: [ok, degraded] db: type: string uptime: type: integer version: type: string sseClients: type: integer memory: type: object properties: rss: type: integer heapUsed: type: integer heapTotal: type: integer '503': description: Database unreachable /api/data: get: summary: Fetch all application data responses: '200': description: Full data snapshot post: summary: Full-state save (backup/restore only) requestBody: required: true content: application/json: schema: type: object responses: '200': description: Data saved /api/events: get: summary: Server-Sent Events stream for real-time sync responses: '200': description: SSE stream (text/event-stream) /api/mushroom-strains: get: summary: List all Pilzsorten (mushroom strains) responses: '200': description: Array of strains content: application/json: schema: type: array items: $ref: '#/components/schemas/MushroomStrain' post: summary: Create a new Pilzsorte requestBody: required: true content: application/json: schema: type: object required: [name, kuerzel] properties: name: type: string description: Must be unique kuerzel: type: string description: Short code, must be unique description: type: string responses: '200': description: Strain created content: application/json: schema: type: object properties: id: type: integer '400': description: Validation error (missing fields or unique violation) /api/mushroom-strains/{id}: patch: summary: Update a Pilzsorte description: >- Changes to `name` or `kuerzel` cascade into all batches and cultures that reference this strain (their `species`/`strain` columns are updated in a single transaction). parameters: - name: id in: path required: true schema: type: integer requestBody: content: application/json: schema: type: object properties: name: type: string kuerzel: type: string description: type: string responses: '200': description: Strain updated '400': description: Unique violation delete: summary: Delete a Pilzsorte (admin only) description: >- Fails with 400 when the strain is still referenced by any batch or culture. Delete or reassign dependent rows first. parameters: - name: id in: path required: true schema: type: integer responses: '200': description: Strain deleted '400': description: Strain still in use '403': description: Admin role required /api/staemme: get: summary: List all Stämme (strains / genetic lines) of every Pilzsorte description: >- The vocabulary for the strain field on batches and cultures. A batch or culture stores the chosen strain by NAME in `strainText`, not by id, so rows from before this list existed keep their free text. Also part of `/api/data` as `staemme`. responses: '200': description: Array of strains content: application/json: schema: type: array items: $ref: '#/components/schemas/Stamm' post: summary: Add a Stamm to a Pilzsorte requestBody: required: true content: application/json: schema: type: object required: [strainId, name] properties: strainId: type: integer description: The Pilzsorte (mushroom_strains.id) the strain belongs to name: type: string maxLength: 60 description: >- Unique per Pilzsorte, case-insensitive. Surrounding and repeated whitespace is removed. notes: type: string maxLength: 500 responses: '200': description: Strain created content: application/json: schema: type: object properties: id: type: integer '400': description: >- Validation error — missing fields, unknown Pilzsorte, or "Stamm: already exists" /api/staemme/{id}: patch: summary: Rename a Stamm or change its notes description: >- A new `name` is carried into the `strainText` of every batch and culture of the same Pilzsorte that carried the old name (case-insensitive), in one transaction. parameters: - name: id in: path required: true schema: type: integer requestBody: content: application/json: schema: type: object properties: name: type: string maxLength: 60 notes: type: string maxLength: 500 responses: '200': description: Strain updated '400': description: 'Empty name, unknown id, or "Stamm: already exists"' delete: summary: Remove a Stamm from the list (admin only) description: >- Only removes it from the selection. Batches and cultures that carry the name keep it. parameters: - name: id in: path required: true schema: type: integer responses: '200': description: Strain removed '403': description: Admin role required /api/batches: post: summary: Create a new batch description: >- Either `strainId` or `species` must be supplied. When `strainId` is set, `species` and `strain` are auto-filled from the referenced Pilzsorte and any values passed in the request body are overwritten. requestBody: required: true content: application/json: schema: type: object required: [batchId, qty, days, created, due] properties: batchId: type: string strainId: type: integer description: Pilzsorte id — auto-fills species/strain from mushroom_strains species: type: string description: Required when strainId is omitted strain: type: string description: Kuerzel (free-text fallback when strainId is omitted) qty: type: integer days: type: integer bags: type: array items: type: string created: type: string format: date-time due: type: string format: date-time responses: '200': description: Batch created /api/batches/{batchId}: patch: summary: Update batch fields description: >- Passing `strainId` switches the batch to a different Pilzsorte and cascades `species`/`strain` from the mushroom_strains row. parameters: - name: batchId in: path required: true schema: type: string requestBody: content: application/json: schema: type: object properties: notes: type: string strainId: type: integer species: type: string strain: type: string days: type: integer due: type: string responses: '200': description: Batch updated delete: summary: Delete a batch (cascades bags) parameters: - name: batchId in: path required: true schema: type: string responses: '200': description: Batch deleted /api/batches/{batchId}/bags: patch: summary: Add bags to an existing batch parameters: - name: batchId in: path required: true schema: type: string requestBody: content: application/json: schema: type: object properties: add: type: array items: type: string newQty: type: integer bagKg: type: number description: >- Weight of each added bag in kg. Omit to reuse the batch's own weight. Grain spawn is weighed and one batch may hold more than one size, so the added bags need not match the rest. minimum: 0 exclusiveMinimum: true maximum: 1000 responses: '200': description: Bags added /api/scan-log: post: summary: Append scan log entries requestBody: required: true content: application/json: schema: type: object properties: entries: type: array items: type: object properties: time: type: string action: type: string enum: [ADD, MOVE, REMOVE, HARVEST] batch: type: string bag: type: string from: type: string to: type: string responses: '200': description: Entries added content: application/json: schema: type: object properties: ids: type: array items: type: integer delete: summary: Clear all scan log entries responses: '200': description: Scan log cleared /api/scan-log/{id}: delete: summary: Delete a single scan log entry parameters: - name: id in: path required: true schema: type: integer responses: '200': description: Entry deleted /api/harvests: post: summary: Log a harvest requestBody: required: true content: application/json: schema: type: object required: [time, grams] properties: time: type: string format: date-time batch: type: string bag: type: string species: type: string grams: type: number flush: type: integer responses: '200': description: Harvest logged /api/block-book: get: summary: Blocks marked good or commented, each with its lineage description: >- Lineage (substrate mix with target and measured moisture, grain spawn with its hydration) is read live while the batch exists, otherwise the snapshot taken when the block was first marked is returned (`live: false`). responses: '200': description: '{ entries: [...] }' /api/block-notes/good: post: summary: Mark a block good, or clear the mark requestBody: required: true content: application/json: schema: type: object required: [bag] properties: bag: type: string on: type: boolean default: true responses: '200': description: Saved '404': description: Unknown bag /api/block-notes/comments: post: summary: Add a comment to a block requestBody: required: true content: application/json: schema: type: object required: [bag, text] properties: bag: type: string text: type: string maxLength: 2000 flush: type: integer description: Set when the comment was written while harvesting responses: '200': description: '{ ok, id }' '404': description: Unknown bag /api/block-notes/comments/{id}: delete: summary: Delete a comment (its author or an admin) parameters: - name: id in: path required: true schema: type: integer responses: '200': description: Deleted '403': description: Not the author and not an admin /api/cultures: post: summary: Create cultures description: >- Pass `strainId` on each culture to auto-fill `species`/`strain` from the referenced Pilzsorte. Free-text `species`/`strain` are only used when `strainId` is omitted. requestBody: required: true content: application/json: schema: type: object properties: cultures: type: array items: type: object required: [id, type] properties: id: type: string type: type: string enum: [MC, PD, LC, G2G] strainId: type: integer description: Pilzsorte id (auto-fills species/strain) species: type: string strain: type: string parentId: type: string nullable: true source: type: string nullable: true status: type: string enum: [active, stored, used, contam] notes: type: string created: type: string format: date-time responses: '200': description: Cultures created /api/cultures/{id}: patch: summary: Update a culture description: >- Passing `strainId` switches the culture to a different Pilzsorte and cascades `species`/`strain` from the mushroom_strains row. parameters: - name: id in: path required: true schema: type: string requestBody: content: application/json: schema: type: object properties: status: type: string enum: [active, stored, used, contam] notes: type: string strainId: type: integer species: type: string strain: type: string source: type: string nullable: true responses: '200': description: Culture updated /api/tasks: post: summary: Create a task requestBody: required: true content: application/json: schema: type: object required: [text, created] properties: text: type: string priority: type: string enum: [low, med, high] created: type: string format: date-time assignee: type: string dueDate: type: string format: date responses: '200': description: Task created /api/tasks/{id}: patch: summary: Update a task parameters: - name: id in: path required: true schema: type: integer requestBody: content: application/json: schema: type: object properties: done: type: boolean text: type: string priority: type: string assignee: type: string dueDate: type: string responses: '200': description: Task updated delete: summary: Delete a task parameters: - name: id in: path required: true schema: type: integer responses: '200': description: Task deleted /api/team: post: summary: Add a team member requestBody: required: true content: application/json: schema: type: object required: [name, added] properties: name: type: string role: type: string added: type: string format: date-time responses: '200': description: Member added /api/team/{id}: delete: summary: Remove a team member parameters: - name: id in: path required: true schema: type: integer responses: '200': description: Member removed /api/inventory/delta: post: summary: Adjust inventory stock by delta requestBody: required: true content: application/json: schema: type: object required: [mat, deltaKg] properties: mat: type: string enum: [hardwood, wheatbran, gypsum, grain] deltaKg: type: number type: type: string ref: type: string responses: '200': description: Stock updated /api/inventory/set: post: summary: Set absolute inventory stock level requestBody: required: true content: application/json: schema: type: object required: [mat, value] properties: mat: type: string enum: [hardwood, wheatbran, gypsum, grain] value: type: number responses: '200': description: Stock set /api/inventory/config: post: summary: Update inventory thresholds and avg composition requestBody: content: application/json: schema: type: object properties: thresholds: type: object avgComposition: type: object responses: '200': description: Config updated /api/calendar-events: post: summary: Create a calendar event requestBody: required: true content: application/json: schema: type: object required: [id, title, startDate] properties: id: type: string title: type: string startDate: type: string format: date endDate: type: string format: date allDay: type: boolean category: type: string assignees: type: array items: type: integer responses: '200': description: Event created /api/calendar-events/{id}: patch: summary: Update a calendar event parameters: - name: id in: path required: true schema: type: string responses: '200': description: Event updated delete: summary: Delete a calendar event parameters: - name: id in: path required: true schema: type: string responses: '200': description: Event deleted /api/backup/download: post: summary: Download encrypted database backup requestBody: required: true content: application/json: schema: type: object required: [password] properties: password: type: string minLength: 8 responses: '200': description: Encrypted backup file (AES-256-GCM) content: application/octet-stream: schema: type: string format: binary /api/backup/restore: post: summary: Restore database from encrypted backup requestBody: required: true content: application/octet-stream: schema: type: string format: binary responses: '200': description: Restore successful '401': description: Wrong password /api/usb-backup: get: summary: USB stick backup status (admin) description: > Registered sticks with their last result (with `skipped`, `unreadable` and `missing` when photos on the PC could not be read), sticks plugged in now (`sameDisk` marks a stick folder on the app's own disk, which is never backed up to), the run in progress, and `reminder` — one of running, state, setup, first, error, gaps, remove, due or null — which the start screen shows. Beside it, `keyConfirmed` (false shows a line asking to put the recovery key away), `firstRunPending` (a connected stick waits for "Back up now"), `stateError` (data/usb-backup.json cannot be read) and `enabled` (false with USB_BACKUP=off). Changes are announced as the SSE event `usb-backup`. responses: '200': description: Status '403': description: Not an admin /api/usb-backup/drives: get: summary: Drives a stick could be set up on (admin) responses: '200': description: Drives with free space, whether they are the app's own disk, and any stick on them /api/usb-backup/setup: post: summary: Set up the stick in a drive and back up onto it (admin) description: > The first stick creates the recovery key; later sticks use the same key. The key is returned with every setup until it is confirmed as put away (POST /api/usb-backup/key/confirm), so a first setup that failed half-way cannot lose it (Cache-Control no-store). requestBody: required: true content: application/json: schema: type: object required: [root] properties: root: type: string description: A drive from /api/usb-backup/drives label: type: string maxLength: 40 responses: '200': description: > Stick set up and a backup started. recoveryKey is set until the key is confirmed; newKey says whether this setup created it. '400': description: Not one of the drives (code NOT_A_DRIVE) '409': description: > Refused, with a code (SAME_DISK, NEEDS_KEY, WRONG_KEY, BUSY, FULL, READ_ONLY, STATE when this PC's record cannot be read or saved …) /api/usb-backup/connect: post: summary: Take back a stick from an earlier installation with its recovery key (admin) requestBody: required: true content: application/json: schema: type: object required: [root, recoveryKey] properties: root: type: string recoveryKey: type: string responses: '200': description: > Connected. Nothing is written to the stick until someone starts the first backup with POST /api/usb-backup/run (restore first on a new PC); after that it is backed up every day by itself. '409': description: Refused (HAS_KEY, WRONG_KEY, STATE …) /api/usb-backup/run: post: summary: Back up onto a plugged-in stick now (admin) requestBody: required: false content: application/json: schema: type: object properties: id: type: string description: The stick to back up onto; without it, the first registered stick plugged in force: type: boolean description: > true only for "Back up anyway" after a SHRUNK stop: the run goes ahead although it finds far less than last time responses: '200': description: The run that started '409': description: > No registered stick plugged in (NOT_PRESENT), only a stick folder on the app's own disk (SAME_DISK), or a run in progress (BUSY) /api/usb-backup/key: post: summary: Show the recovery key (admin, Cache-Control no-store) responses: '200': description: The key /api/usb-backup/key/confirm: post: summary: Confirm the recovery key is put away (admin) description: > Stops the key being returned with every setup and the start-screen line asking for it. Saved, or not made. responses: '200': description: Confirmed '409': description: No recovery key yet, or the confirmation could not be saved (code STATE) /api/usb-backup/sticks/{id}: delete: summary: Forget a lost or retired stick (admin); nothing on the stick is touched parameters: - name: id in: path required: true schema: type: string pattern: '^[0-9a-f]{16}$' responses: '200': description: Forgotten '404': description: Unknown stick '409': description: Not forgotten, because this PC's record could not be saved (code STATE) /api/users: get: summary: List all users responses: '200': description: User list (without password hashes) post: summary: Create a new user requestBody: required: true content: application/json: schema: type: object required: [username, password] properties: username: type: string password: type: string minLength: 8 role: type: string enum: [admin, user] responses: '200': description: User created /api/users/{id}: delete: summary: Delete a user parameters: - name: id in: path required: true schema: type: integer responses: '200': description: User deleted /api/usernames: get: summary: Get all usernames (lightweight) responses: '200': description: Username list /api/print: post: summary: Send ZPL to label printer requestBody: required: true content: application/json: schema: type: object required: [zpl] properties: zpl: type: string responses: '200': description: Print job sent /api/caldav/config: post: summary: Update CalDAV configuration requestBody: content: application/json: schema: type: object properties: enabled: type: boolean responses: '200': description: Config updated