{"openapi":"3.1.0","info":{"title":"UnitBoard API","version":"0.1.0-prototype","summary":"Single source of truth for property operations. AI-first.","description":"The UnitBoard API powers every screen in the product and is the same surface external systems integrate against — no integration tolls, no double-entry, no proprietary file dumps. All endpoints return JSON unless explicitly noted (e.g. CSV report exports). This document describes the prototype surface; production endpoints will be additive.","termsOfService":"https://unitboard.ai/legal/terms","contact":{"name":"UnitBoard API","email":"api@unitboard.ai","url":"https://unitboard.ai"},"license":{"name":"Proprietary","url":"https://unitboard.ai/legal/api"}},"servers":[{"url":"https://unitboard.ai","description":"Production"},{"url":"http://localhost:7777","description":"Local dev"}],"tags":[{"name":"Health","description":"Liveness and service status."},{"name":"Accounts Payable","description":"Invoice intake, AI extraction, approval, and rejection. Approving posts a journal entry to the GL."},{"name":"Work Orders","description":"Maintenance work order lifecycle — creation and status changes."},{"name":"Copilot","description":"Natural-language ledger and operations queries. Agentic tool-use loop over Anthropic Claude."},{"name":"Search","description":"Global cross-entity search — properties, units, residents, leases, vendors, invoices, work orders, journal entries."},{"name":"Reports","description":"Packaged financial and operational statements. JSON by default; pass ?format=csv for an attachment."},{"name":"Activity","description":"Cross-entity activity feed (audit log)."},{"name":"Admin","description":"Privileged endpoints requiring the admin role."},{"name":"Auth","description":"Supabase authentication callbacks."}],"security":[{"bearerAuth":[]}],"paths":{"/api/health":{"get":{"tags":["Health"],"operationId":"getHealth","summary":"Liveness probe","description":"Returns 200 with a small JSON envelope when the service is up. No auth required.","security":[],"responses":{"200":{"description":"Service is healthy.","content":{"application/json":{"schema":{"type":"object","required":["ok","service","timestamp"],"properties":{"ok":{"type":"boolean","const":true},"service":{"type":"string","example":"unitboard"},"timestamp":{"type":"string","format":"date-time"}}}}}}}}},"/api/ap/extract":{"post":{"tags":["Accounts Payable"],"operationId":"extractInvoice","summary":"Extract invoice from PDF","description":"Runs Claude Vision over a base64-encoded PDF, matches the vendor against the directory, suggests a GL coding, and persists a draft invoice (status `extracted`) ready for human review.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["filename","pdfBase64"],"properties":{"filename":{"type":"string","minLength":1,"maxLength":256,"example":"acme-plumbing-2025-07.pdf"},"pdfBase64":{"type":"string","minLength":64,"description":"Base64-encoded PDF body. The data-URL prefix (`data:application/pdf;base64,`) is tolerated and stripped server-side."}}}}}},"responses":{"200":{"description":"Invoice extracted and persisted as a draft.","content":{"application/json":{"schema":{"type":"object","required":["ok","invoiceId","durationMs","vendor","account","confidence"],"properties":{"ok":{"type":"boolean","const":true},"invoiceId":{"type":"string","format":"uuid"},"durationMs":{"type":"integer","minimum":0},"vendor":{"type":"string","description":"Matched vendor name, or the raw extracted name."},"account":{"type":["string","null"],"description":"GL account suggestion in `code — name` form, or null when no match."},"confidence":{"type":"number","minimum":0,"maximum":1}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"500":{"description":"Demo data not seeded or DB write failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"Claude API call failed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"`ANTHROPIC_API_KEY` not configured on the server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/ap/approve":{"post":{"tags":["Accounts Payable"],"operationId":"approveInvoice","summary":"Approve and post invoice","description":"Posts a journal entry (debit each line's GL account, credit AP control) and marks the invoice `posted`. Idempotent on already-posted invoices is NOT guaranteed — caller should check `status` first.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["invoiceId"],"properties":{"invoiceId":{"type":"string","format":"uuid"}}}}}},"responses":{"200":{"description":"Invoice approved and posted to the GL.","content":{"application/json":{"schema":{"type":"object","required":["ok","journalEntryId"],"properties":{"ok":{"type":"boolean","const":true},"journalEntryId":{"type":"string","format":"uuid"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"description":"Invoice not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"Invoice is in a status that cannot be approved.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Server error during posting.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/ap/reject":{"post":{"tags":["Accounts Payable"],"operationId":"rejectInvoice","summary":"Reject invoice","description":"Marks an invoice as `rejected`. Does not post to the GL.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["invoiceId"],"properties":{"invoiceId":{"type":"string","format":"uuid"}}}}}},"responses":{"200":{"description":"Invoice marked rejected.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OkEnvelope"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"500":{"description":"Server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/work-orders/create":{"post":{"tags":["Work Orders"],"operationId":"createWorkOrder","summary":"Create work order","description":"Opens a new maintenance work order on the given property (and optionally a unit / vendor). Defaults `priority=normal`, `status=open`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["title","propertyId"],"properties":{"title":{"type":"string","minLength":1,"maxLength":200},"description":{"type":["string","null"],"maxLength":2000},"propertyId":{"type":"string","format":"uuid"},"unitId":{"type":["string","null"],"format":"uuid"},"priority":{"type":"string","enum":["low","normal","high","urgent"],"default":"normal"},"estimatedCost":{"type":["number","null"],"minimum":0,"maximum":1000000},"vendorId":{"type":["string","null"],"format":"uuid"}}}}}},"responses":{"200":{"description":"Work order created.","content":{"application/json":{"schema":{"type":"object","required":["ok","id"],"properties":{"ok":{"type":"boolean","const":true},"id":{"type":"string","format":"uuid"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"description":"Property not found.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/work-orders/update-status":{"post":{"tags":["Work Orders"],"operationId":"updateWorkOrderStatus","summary":"Update work order status","description":"Transitions a work order to one of the canonical statuses. When set to `completed`, `closed_at` is stamped to now; any other status clears `closed_at` so re-opens stay honest.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["id","status"],"properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["open","in_progress","on_hold","completed","canceled"]}}}}}},"responses":{"200":{"description":"Status updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OkEnvelope"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"500":{"description":"Server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/copilot/query":{"post":{"tags":["Copilot"],"operationId":"copilotQuery","summary":"Natural-language query","description":"Runs an agentic tool-use loop over Claude. The model has read access to ledger / property / leasing tools and is capped at 5 tool-call rounds. Pass the full conversation history each call — the route is stateless.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["messages"],"properties":{"messages":{"type":"array","minItems":1,"items":{"type":"object","required":["role","content"],"properties":{"role":{"type":"string","enum":["user","assistant"]},"content":{"type":"string"}}}}}}}}},"responses":{"200":{"description":"Final answer produced.","content":{"application/json":{"schema":{"type":"object","required":["answer","toolCalls"],"properties":{"answer":{"type":"string"},"toolCalls":{"type":"array","items":{"type":"string"},"description":"Tool names invoked, in order. Empty when the model answered from context alone."}}}}}},"400":{"description":"Body malformed or missing `messages`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Unhandled error during the tool loop.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"`ANTHROPIC_API_KEY` not configured on the server.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/search":{"get":{"tags":["Search"],"operationId":"search","summary":"Global search","description":"Cross-entity ILIKE search. Returns up to 5 hits per group. Queries shorter than 2 characters are answered with empty groups (200 OK) so a debounced typeahead can stay responsive.","parameters":[{"name":"q","in":"query","required":false,"schema":{"type":"string"},"description":"Search query. Leading/trailing whitespace is trimmed; `%` and `_` are escaped.","example":"1500 maple"}],"responses":{"200":{"description":"Search results grouped by entity.","content":{"application/json":{"schema":{"type":"object","required":["results"],"properties":{"results":{"type":"object","required":["properties","units","residents","leases","vendors","invoices","workOrders","journalEntries"],"properties":{"properties":{"type":"array","items":{"$ref":"#/components/schemas/SearchHit"}},"units":{"type":"array","items":{"$ref":"#/components/schemas/SearchHit"}},"residents":{"type":"array","items":{"$ref":"#/components/schemas/SearchHit"}},"leases":{"type":"array","items":{"$ref":"#/components/schemas/SearchHit"}},"vendors":{"type":"array","items":{"$ref":"#/components/schemas/SearchHit"}},"invoices":{"type":"array","items":{"$ref":"#/components/schemas/SearchHit"}},"workOrders":{"type":"array","items":{"$ref":"#/components/schemas/SearchHit"}},"journalEntries":{"type":"array","items":{"$ref":"#/components/schemas/SearchHit"}}}}}}}}}}}},"/api/reports/{slug}":{"get":{"tags":["Reports"],"operationId":"getReport","summary":"Run a packaged report","description":"Runs the named report and returns either a JSON shape (default) suitable for rendering, or a `text/csv` download when `?format=csv`. The same registry powers the in-app report tables, so JSON / CSV / table are always in agreement.","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","enum":["profit-and-loss","balance-sheet","cash-flow","rent-roll","delinquency","lease-expirations"]},"description":"Report identifier."},{"name":"format","in":"query","required":false,"schema":{"type":"string","enum":["csv"]},"description":"Pass `csv` to download a CSV attachment instead of JSON."},{"name":"period","in":"query","required":false,"schema":{"type":"string","enum":["30","90","180","365","ytd"]},"description":"Time window in days. Only honored by reports that support a period."}],"responses":{"200":{"description":"Report payload.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportPayload"}},"text/csv":{"schema":{"type":"string","format":"binary"}}}},"404":{"description":"Unknown report slug.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/activity":{"get":{"tags":["Activity"],"operationId":"getActivity","summary":"Cross-entity activity feed","description":"Reverse-chronological activity events across properties, leases, invoices, journal entries, and work orders. Useful as an audit log or dashboard widget.","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"description":"Max number of events to return."},{"name":"since","in":"query","required":false,"schema":{"type":"string","format":"date-time"},"description":"Return events at or after this ISO-8601 timestamp."}],"responses":{"200":{"description":"Activity events.","content":{"application/json":{"schema":{"type":"object","required":["events"],"properties":{"events":{"type":"array","items":{"$ref":"#/components/schemas/Activity"}}}}}}}}}},"/api/admin/set-role":{"post":{"tags":["Admin"],"operationId":"setUserRole","summary":"Set user role","description":"Updates a user's role. Only callers with the `admin` role may invoke this; uses the Supabase service role internally to bypass RLS.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["userId","role"],"properties":{"userId":{"type":"string","format":"uuid"},"role":{"type":"string","enum":["admin","operator","accountant","viewer"]}}}}}},"responses":{"200":{"description":"Role updated.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OkEnvelope"}}}},"400":{"description":"Body invalid.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"Caller is not an admin.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Service role key missing or DB error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/auth/callback":{"get":{"tags":["Auth"],"operationId":"authCallback","summary":"Supabase auth callback","description":"Magic-link / OAuth callback. Exchanges the `code` query param for a session cookie, then redirects to `next` (default `/dashboard`). On failure, redirects to `/login?error=auth_callback_failed`.","security":[],"parameters":[{"name":"code","in":"query","required":true,"schema":{"type":"string"},"description":"Authorization code issued by Supabase."},{"name":"next","in":"query","required":false,"schema":{"type":"string","default":"/dashboard"},"description":"Path to redirect to on success."}],"responses":{"302":{"description":"Redirect to `next` on success, or `/login?error=…` on failure.","headers":{"Location":{"schema":{"type":"string"},"description":"Target URL."}}}}}}},"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Reserved for future external clients. Internal traffic uses Supabase session cookies; both forms will be accepted in production."}},"schemas":{"Error":{"type":"object","required":["ok","error"],"properties":{"ok":{"type":"boolean","const":false},"error":{"type":"string","description":"Human-readable error message."}},"example":{"ok":false,"error":"Invoice not found."}},"OkEnvelope":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean","const":true}},"example":{"ok":true}},"SearchHit":{"type":"object","required":["id","primary","href"],"properties":{"id":{"type":"string","format":"uuid"},"primary":{"type":"string","description":"Headline label rendered first."},"secondary":{"type":["string","null"],"description":"Subtitle / context."},"href":{"type":"string","description":"Deep link into the app."}}},"Property":{"type":"object","description":"A managed property.","required":["id","org_id","name"],"properties":{"id":{"type":"string","format":"uuid"},"org_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"address_line_1":{"type":["string","null"]},"address_line_2":{"type":["string","null"]},"city":{"type":["string","null"]},"state":{"type":["string","null"]},"postal_code":{"type":["string","null"]},"unit_count":{"type":["integer","null"],"minimum":0},"year_built":{"type":["integer","null"]}}},"Unit":{"type":"object","description":"A leasable unit within a property.","required":["id","property_id","unit_number"],"properties":{"id":{"type":"string","format":"uuid"},"property_id":{"type":"string","format":"uuid"},"unit_number":{"type":"string"},"bedrooms":{"type":["integer","null"],"minimum":0},"bathrooms":{"type":["number","null"],"minimum":0},"square_feet":{"type":["integer","null"],"minimum":0},"market_rent":{"type":["number","null"],"minimum":0}}},"Lease":{"type":"object","description":"A signed lease tying a resident to a unit over a date range.","required":["id","unit_id","primary_resident_id","status"],"properties":{"id":{"type":"string","format":"uuid"},"unit_id":{"type":"string","format":"uuid"},"primary_resident_id":{"type":"string","format":"uuid"},"status":{"type":"string","enum":["draft","active","expired","terminated","renewed"]},"start_date":{"type":"string","format":"date"},"end_date":{"type":["string","null"],"format":"date"},"monthly_rent":{"type":"number","minimum":0},"security_deposit":{"type":["number","null"],"minimum":0}}},"Resident":{"type":"object","description":"An individual resident / tenant record.","required":["id","full_name"],"properties":{"id":{"type":"string","format":"uuid"},"full_name":{"type":"string"},"email":{"type":["string","null"],"format":"email"},"phone":{"type":["string","null"]}}},"Vendor":{"type":"object","description":"A third-party service provider in the AP directory.","required":["id","name"],"properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"email":{"type":["string","null"],"format":"email"},"phone":{"type":["string","null"]},"default_account_id":{"type":["string","null"],"format":"uuid","description":"Default GL account used to code invoices from this vendor."}}},"Invoice":{"type":"object","description":"An accounts-payable invoice.","required":["id","org_id","status","total"],"properties":{"id":{"type":"string","format":"uuid"},"org_id":{"type":"string","format":"uuid"},"vendor_id":{"type":["string","null"],"format":"uuid"},"property_id":{"type":["string","null"],"format":"uuid"},"invoice_number":{"type":["string","null"]},"invoice_date":{"type":["string","null"],"format":"date"},"due_date":{"type":["string","null"],"format":"date"},"subtotal":{"type":["number","null"]},"tax":{"type":["number","null"]},"total":{"type":"number"},"status":{"type":"string","enum":["extracted","pending_approval","approved","posted","rejected","paid"]},"extract_confidence":{"type":["number","null"],"minimum":0,"maximum":1},"approved_at":{"type":["string","null"],"format":"date-time"},"posted_at":{"type":["string","null"],"format":"date-time"},"notes":{"type":["string","null"]}}},"Account":{"type":"object","description":"A GL chart-of-accounts entry.","required":["id","code","name","type"],"properties":{"id":{"type":"string","format":"uuid"},"code":{"type":"string","description":"Numeric account code, e.g. `2010` for AP control."},"name":{"type":"string"},"type":{"type":"string","enum":["asset","liability","equity","revenue","expense"]}}},"JournalEntry":{"type":"object","description":"A double-entry journal posting. Lines net to zero across debit and credit.","required":["id","org_id","entry_date","description"],"properties":{"id":{"type":"string","format":"uuid"},"org_id":{"type":"string","format":"uuid"},"entry_date":{"type":"string","format":"date"},"description":{"type":"string"},"source_type":{"type":["string","null"],"example":"invoice"},"source_id":{"type":["string","null"],"format":"uuid"},"posted_by":{"type":["string","null"],"format":"uuid"},"lines":{"type":"array","description":"Embedded line items when expanded.","items":{"type":"object","required":["account_id","debit","credit"],"properties":{"account_id":{"type":"string","format":"uuid"},"debit":{"type":"number","minimum":0},"credit":{"type":"number","minimum":0},"property_id":{"type":["string","null"],"format":"uuid"},"memo":{"type":["string","null"]}}}}}},"WorkOrder":{"type":"object","description":"A maintenance work order.","required":["id","org_id","property_id","title","status","priority"],"properties":{"id":{"type":"string","format":"uuid"},"org_id":{"type":"string","format":"uuid"},"property_id":{"type":"string","format":"uuid"},"unit_id":{"type":["string","null"],"format":"uuid"},"vendor_id":{"type":["string","null"],"format":"uuid"},"title":{"type":"string"},"description":{"type":["string","null"]},"priority":{"type":"string","enum":["low","normal","high","urgent"]},"status":{"type":"string","enum":["open","in_progress","on_hold","completed","canceled"]},"opened_at":{"type":"string","format":"date-time"},"closed_at":{"type":["string","null"],"format":"date-time"},"estimated_cost":{"type":["number","null"],"minimum":0}}},"Activity":{"type":"object","description":"An audit / activity event.","required":["id","verb","subject_type","subject_id","occurred_at"],"properties":{"id":{"type":"string","format":"uuid"},"verb":{"type":"string","description":"Past-tense action, e.g. `created`, `approved`, `posted`, `closed`."},"subject_type":{"type":"string","enum":["property","unit","lease","resident","vendor","invoice","journal_entry","work_order","user"]},"subject_id":{"type":"string","format":"uuid"},"actor_id":{"type":["string","null"],"format":"uuid"},"summary":{"type":["string","null"]},"occurred_at":{"type":"string","format":"date-time"}}},"ReportPayload":{"type":"object","description":"JSON shape returned by `/api/reports/{slug}`.","required":["ok","slug","title","columns","rows"],"properties":{"ok":{"type":"boolean","const":true},"slug":{"type":"string"},"title":{"type":"string"},"columns":{"type":"array","items":{"type":"object","required":["key","label"],"properties":{"key":{"type":"string"},"label":{"type":"string"},"align":{"type":"string","enum":["left","right","center"]},"format":{"type":"string","enum":["currency","currency2","number","percent","date","text"]}}}},"rows":{"type":"array","items":{"type":"object","additionalProperties":{"type":["string","number","null"]}}},"totals":{"type":["object","null"],"additionalProperties":{"type":["string","number","null"]}},"summary":{"type":["array","null"],"items":{"type":"object","required":["label","value"],"properties":{"label":{"type":"string"},"value":{"type":"string"},"sub":{"type":"string"}}}},"periodLabel":{"type":["string","null"]},"periodDays":{"type":["integer","null"]}}}},"responses":{"BadRequest":{"description":"Request body is invalid or malformed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}}