openapi: 3.1.0

info:
  title: jamiio API
  version: 1.0.0
  summary: The shared spine behind jamiio and jamiio for trades.
  description: |
    Only what two people can disagree about is stored here: whether a trade is
    verified, what state a job is in, how a community voted, and who somebody
    is. Region packs, copy, design tokens and every derived dataset live in the
    client — they are identical for everyone and a round trip would buy nothing.

    ## Identity, stated plainly

    There are two credentials, and they are not equivalent.

    `x-jamiio-key` is a **device key**: a random value the client generates and
    keeps in local storage. It is **not authentication**. Nobody proved
    anything; a determined caller can send any key it likes. What it provides
    is *ownership* — the server checks that the key on a request matches the key
    that created a record before permitting a write, which stops the ordinary
    accidents and cross-talk between devices.

    `Authorization: Bearer <token>` is a **Clerk session token**, verified
    server-side against Clerk with the secret key. This one is real: a caller
    can claim any user id, but cannot forge a signature. Anything that must
    follow a person across devices keys on this.

    Endpoints marked `deviceKey` alone should be treated as **open** until every
    caller is required to present a session. That is a known and deliberate
    staging point, not an oversight — the app is usable signed out, and the
    trade-off is written down here rather than hidden.

    ## Conventions

    - JSON in, JSON out. `Content-Type: application/json` on every body.
    - Errors are always `{ "error": "a sentence a person could act on" }`.
    - Times are RFC 3339 UTC.
    - No endpoint pages; each returns a documented hard cap instead. Pagination
      is a change to make when a community outgrows the cap, not before.
    - Every response is `Cache-Control: no-store`. Stale governance is worse
      than slow governance.

    ## Rate limits

    Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and
    `RateLimit-Reset` (Unix seconds). Exceeding a budget returns **429** with
    `Retry-After` in seconds.

    Budgets are per caller per minute, and reads are deliberately more generous
    than writes — hammering a list is rude, hammering `POST /votes` corrupts a
    record other people rely on.

    | Scope | Budget |
    |---|---|
    | `GET` / `HEAD` | 120 / min |
    | `POST /votes` | 20 / min |
    | Any other write | 30 / min |
    | `GET /health` | 600 / min |

    Callers are identified most-trustworthy-first: a verified session, else the
    device key, else the address. The address is a last resort because everyone
    behind one NAT shares it.

    Counting is a **fixed window**, so up to twice a budget can pass across a
    boundary. That is accepted knowingly — the aim is to stop scripts and
    accidents, not to meter a paid API to the request. If the counter itself is
    unreachable the request is allowed through: an unmetered request is a
    smaller problem than an outage caused by the thing meant to prevent one.

    ## Not yet true

    Written down so nobody has to discover it:

    - **No CORS allowances.** Browser callers are same-origin only, by default
      rather than by policy.

    ### Personal information

    A person's phone number, email, exact coordinates and anything financial
    are returned only to the person they belong to. There is no endpoint,
    role or query parameter that returns them to anybody else — not to a
    committee member, not to an administrator, not to a trade.

    What other people receive instead is a display name and the house or lot:
    `Wanjiru K.` and `House 14`. The projection is applied server-side in
    `api/_person.ts`, so it holds regardless of which client is asking.

    The system still uses the private fields. A quorum can be counted without
    publishing who voted, and a campaign total is exact without naming who
    gave what.

  contact:
    name: jamiio
    url: https://jamiio.net
  license:
    name: Proprietary
    identifier: LicenseRef-jamiio-proprietary


servers:
  - url: https://jamiio.net/api
    description: Production
  - url: http://localhost:3000/api
    description: Local (vercel dev)

tags:
  - name: Service
    description: Liveness.
  - name: Communities
    description: The record everything else hangs off.
  - name: Membership
    description: Who belongs where. A person may belong to several.
  - name: People
    description: Your own details, and where you left off.
  - name: Providers
    description: Trades accounts, and the resident-facing directory.
  - name: Jobs
    description: The wire between residents and trades.
  - name: Governance
    description: Proposals and votes.

security:
  - deviceKey: []

paths:
  /health:
    get:
      operationId: getHealth
      tags: [Service]
      summary: Liveness, including the database
      security: []
      responses:
        '200':
          description: The function ran and the database answered.
          content:
            application/json:
              schema:
                type: object
                required: [ok, tables]
                properties:
                  ok: { type: boolean, const: true }
                  tables:
                    type: integer
                    description: Tables present in the public schema.
                    examples: [8]
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '500': { $ref: '#/components/responses/Error' }

  /communities:
    get:
      operationId: listCommunities
      tags: [Communities]
      summary: List communities, or fetch one
      description: |
        With no parameters, returns everything standing so a new device can join
        rather than only create. `mine=1` narrows to those this key created.
      parameters:
        - name: mine
          in: query
          schema: { type: string, enum: ['1'] }
          description: Only communities created by the calling key.
        - name: id
          in: query
          schema: { type: string }
          description: Fetch exactly one.
      responses:
        '200':
          description: Matching communities.
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      communities:
                        type: array
                        maxItems: 60
                        items: { $ref: '#/components/schemas/Community' }
                  - type: object
                    properties:
                      community: { $ref: '#/components/schemas/Community' }
        '401': { $ref: '#/components/responses/NoKey' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '404': { $ref: '#/components/responses/Error' }

    post:
      operationId: createCommunity
      tags: [Communities]
      summary: Create a community
      description: |
        Creating one joins you to it as `admin` — a community you are not a
        member of would be a strange thing to have to fix afterwards.

        Names need not be unique. Jobs, proposals and the trades directory key
        on the **id**, so two estates called "Riverside Gardens" are two
        estates with the same name — a thing that genuinely happens. A clash is
        reported as `alsoCalled` on the created community so the client can ask
        "did you mean to join this one?", but it is advice rather than refusal.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NewCommunity' }
      responses:
        '201':
          description: Created, and you are its admin.
          content:
            application/json:
              schema:
                type: object
                properties:
                  community: { $ref: '#/components/schemas/Community' }
                  alsoCalled:
                    type: [string, 'null']
                    description: |
                      Id of an existing community with the same name, if any.
                      Advice for the client, not an error.
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/NoKey' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

    patch:
      operationId: updateCommunity
      tags: [Communities]
      summary: Amend a community you created
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - type: object
                  required: [id]
                  properties:
                    id: { type: string }
                - $ref: '#/components/schemas/NewCommunity'
      responses:
        '200':
          description: Amended.
          content:
            application/json:
              schema:
                type: object
                properties:
                  community: { $ref: '#/components/schemas/Community' }
        '401': { $ref: '#/components/responses/NoKey' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '403':
          description: Not the community's creator.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /memberships:
    get:
      operationId: listMemberships
      tags: [Membership]
      summary: Every community you belong to
      description: |
        Owning a home in two places is ordinary — a flat in town and a plot
        upcountry — so this is a list rather than a field on the person.
      responses:
        '200':
          description: Your memberships, oldest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  communities:
                    type: array
                    items: { $ref: '#/components/schemas/Membership' }
        '401': { $ref: '#/components/responses/NoKey' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      operationId: joinCommunity
      tags: [Membership]
      summary: Join a community
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [communityId]
              properties:
                communityId: { type: string }
                role:
                  type: string
                  default: resident
                  enum: [resident, admin]
      responses:
        '201':
          description: Joined. Joining twice is not an error and does not duplicate.
          content:
            application/json:
              schema:
                type: object
                properties:
                  community: { $ref: '#/components/schemas/Membership' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/NoKey' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '404': { $ref: '#/components/responses/Error' }
    delete:
      operationId: leaveCommunity
      tags: [Membership]
      summary: Leave a community
      description: |
        Leaving your last one is permitted. Somebody who has sold up should not
        have to keep a community on their phone to satisfy a data model.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [communityId]
              properties:
                communityId: { type: string }
      responses:
        '200':
          description: Left, or was never a member.
          content:
            application/json:
              schema:
                type: object
                properties: { ok: { type: boolean } }
        '401': { $ref: '#/components/responses/NoKey' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /profile:
    get:
      operationId: getPerson
      tags: [People]
      summary: Your details, and where you left off
      description: |
        Keyed on the verified Clerk user when a session is presented, and on the
        device otherwise. `portable` tells the client which of those happened,
        so it can be honest about whether anything will follow you.
      security:
        - deviceKey: []
        - bearerAuth: []
          deviceKey: []
      responses:
        '200':
          description: The person, as far as we know them.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PersonState' }
        '401': { $ref: '#/components/responses/NoKey' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    put:
      operationId: putProfile
      tags: [People]
      summary: Replace your details
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Profile' }
      responses:
        '200':
          description: Saved.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PersonState' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/NoKey' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    patch:
      operationId: patchPerson
      tags: [People]
      summary: Update where you left off
      description: |
        Its own verb because switching community and finishing the walkthrough
        happen far more often than a name or a phone number changes, and should
        not rewrite the whole record.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                activeCommunity:
                  type: [string, 'null']
                  description: Community id you moved to, or null on leaving your last.
                tourDone: { type: boolean }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  portable: { type: boolean }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/NoKey' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      operationId: adoptProfile
      tags: [People]
      summary: Carry a signed-out device's profile onto the account
      description: |
        Somebody fills in their name before signing in, so it saves against the
        phone. On sign-in the account has no profile and the phone does — this
        moves it across, **once**, and only when the account is empty. A profile
        that already follows you must never be overwritten by whatever happens
        to be on the device in your hand.
      security:
        - bearerAuth: []
          deviceKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [deviceKey]
              properties:
                deviceKey:
                  type: string
                  description: The device key whose profile should be adopted.
      responses:
        '200':
          description: Adopted, or declined with a reason.
          content:
            application/json:
              schema:
                type: object
                properties:
                  adopted: { type: boolean }
                  reason: { type: string }
                  profile: { $ref: '#/components/schemas/Profile' }
        '400': { $ref: '#/components/responses/Error' }
        '403':
          description: No verified session, so there is no account to adopt onto.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /providers:
    get:
      operationId: listProviders
      tags: [Providers]
      summary: The trades directory, or your own account
      description: |
        `mine=1` returns the caller's account whether or not it is live. Keyed
        on the verified person when a session is presented, and on the device
        otherwise — `portable` says which happened, so a trade signing in on a
        new phone takes their business with them rather than starting again.
        Without it, only **live** accounts are returned — the server decides what
        live means, so a client cannot list itself into a directory.
      parameters:
        - name: mine
          in: query
          schema: { type: string, enum: ['1'] }
        - name: communityId
          in: query
          schema: { type: string }
          description: Community the trade has been opted into.
        - name: trade
          in: query
          schema: { $ref: '#/components/schemas/TradeName' }
      responses:
        '200':
          description: Accounts. Residents project these client-side into cards.
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      account:
                        oneOf:
                          - { $ref: '#/components/schemas/TradeAccount' }
                          - type: 'null'
                  - type: object
                    properties:
                      providers:
                        type: array
                        maxItems: 60
                        items: { $ref: '#/components/schemas/TradeAccount' }
        '401': { $ref: '#/components/responses/NoKey' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    put:
      operationId: putProvider
      tags: [Providers]
      summary: Create or amend your trades account
      description: |
        `live` is **computed here on every write** and ignored on input. A badge
        that says verified has to mean something the server established, or it
        is worth nothing to the resident deciding whether to let somebody into
        their house. Licence, insurance and a background check are required.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TradeAccountInput' }
      responses:
        '200':
          description: Saved, with verification recomputed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  account: { $ref: '#/components/schemas/TradeAccount' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/NoKey' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '403':
          description: Not your account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404': { $ref: '#/components/responses/Error' }

  /jobs:
    get:
      operationId: listJobs
      tags: [Jobs]
      summary: Your jobs, as resident or as trade
      description: |
        `mine=provider` returns jobs already routed to you **and** open jobs
        nobody has taken that you are qualified for — right trade, community
        that has opted you in. An unclaimed request sitting invisible until
        somebody assigns it helps nobody. Without the parameter, returns the
        calling device's own requests.
      parameters:
        - name: mine
          in: query
          schema: { type: string, enum: [provider] }
      responses:
        '200':
          description: Jobs, urgent first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobs:
                    type: array
                    maxItems: 100
                    items: { $ref: '#/components/schemas/Job' }
        '401': { $ref: '#/components/responses/NoKey' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      operationId: raiseJob
      tags: [Jobs]
      summary: Raise a request
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NewJob' }
      responses:
        '201':
          description: Raised, and an event logged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  job: { $ref: '#/components/schemas/Job' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/NoKey' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    patch:
      operationId: moveJob
      tags: [Jobs]
      summary: Move a job on
      description: |
        Who may make which move is decided **here**, not in either UI, because
        "the resident accepted the quote" is a claim about money and the side
        that benefits should not be the one asserting it.

        - Trade may set: `quoted`, `enroute`, `onsite`, `complete`, `declined`.
        - Resident may set: `accepted`, `declined`.
        - A quote may only be set by the trade, and only when quoting.
        - Quoting an unassigned job is how a trade **claims** it; nothing else
          claims one.

        Every transition appends to the job's event log, so both sides read the
        same history.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, status]
              properties:
                id: { type: string }
                status: { $ref: '#/components/schemas/JobStatus' }
                quote: { type: number, minimum: 0 }
                note: { type: string }
      responses:
        '200':
          description: Moved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  job: { $ref: '#/components/schemas/Job' }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/NoKey' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '403':
          description: Not yours, or not a move your side may make.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404': { $ref: '#/components/responses/Error' }

  /proposals:
    get:
      operationId: listProposals
      tags: [Governance]
      summary: A community's proposals, with tallies
      description: |
        Tallies are **counted from the votes table on every read**, never stored
        as a number that can drift. Drafts are visible only to whoever started
        them — save-and-finish-later should not mean the whole community watches
        you think.
      parameters:
        - name: communityId
          in: query
          schema: { type: string }
          description: Preferred. What proposals are keyed on.
        - name: community
          in: query
          schema: { type: string }
          description: Name fallback, for records predating the id.
      responses:
        '200':
          description: Proposals, most recently touched first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  proposals:
                    type: array
                    maxItems: 100
                    items: { $ref: '#/components/schemas/Proposal' }
        '400': { $ref: '#/components/responses/Error' }
    post:
      operationId: saveProposal
      tags: [Governance]
      summary: Create or amend a proposal
      description: Only the proposer may edit, and only while it is still a draft.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProposalInput' }
      responses:
        '200':
          description: Saved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  proposal: { $ref: '#/components/schemas/Proposal' }
        '401': { $ref: '#/components/responses/NoKey' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '403': { $ref: '#/components/responses/Error' }
        '409':
          description: Already submitted, so no longer editable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    patch:
      operationId: decideProposal
      tags: [Governance]
      summary: Record a committee decision
      description: |
        `approved` and `rejected` apply to a proposal that is `proposed` or
        `voting`; `published` only to one already `approved`. A decision out of
        sequence is refused rather than silently applied.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, status]
              properties:
                id: { type: string }
                status: { type: string, enum: [approved, rejected, published] }
      responses:
        '200':
          description: Decided.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  status: { type: string }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/NoKey' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '409':
          description: Not at a stage where that decision applies.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /votes:
    post:
      operationId: castVote
      tags: [Governance]
      summary: Cast or change a vote
      description: |
        One home, one vote — enforced by the primary key on `(proposal, voter)`
        rather than by hiding a button. A second vote from the same caller
        **replaces** the first; it does not add to the count. Voting is only
        open while the proposal is in `voting`.

        Quorum is two-thirds of votes cast, with at least a third of eligible
        homes taking part. Those thresholds are applied client-side from the
        returned tallies.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, choice]
              properties:
                id: { type: string }
                choice: { type: string, enum: [for, against] }
      responses:
        '200':
          description: Counted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  votesFor: { type: integer }
                  votesAgainst: { type: integer }
                  myVote: { type: string, enum: [for, against] }
        '400': { $ref: '#/components/responses/Error' }
        '401': { $ref: '#/components/responses/NoKey' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '404': { $ref: '#/components/responses/Error' }
        '409':
          description: That vote is not open.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /invites:
    get:
      operationId: getInvite
      tags: [Membership]
      summary: Preview an invitation, or list the ones you sent
      description: |
        Two shapes, chosen by which parameter is supplied.

        **`?code=`** — the public preview, readable by whoever holds the link.
        It deliberately omits the recipient's contact details and shows only a
        display name: a forwarded invite must not become a way to learn who
        lives where. Opening an invitation marks it `opened`, so the sender can
        see it arrived.

        **`?communityId=`** — the invitations you issued for that community,
        including `contact` and the spoken `phrase`, because you wrote them.

        The `phrase` is the anti-phishing measure and the reason this endpoint
        exists in this shape. It is three words the sender says out of band —
        aloud, or in the WhatsApp thread. The join screen shows the same three
        words. **A link showing different words did not come from them.** An
        invite that travels through WhatsApp will be forwarded whether or not
        we ask it not to be, so the check has to be something a forwarder
        cannot reproduce.
      parameters:
        - { name: code, in: query, schema: { type: string }, description: The invitation code. }
        - { name: communityId, in: query, schema: { type: string }, description: List invitations you issued. }
      responses:
        '200':
          description: The preview, or your list.
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      invite: { $ref: '#/components/schemas/InvitePreview' }
                  - type: object
                    properties:
                      invites:
                        type: array
                        items: { $ref: '#/components/schemas/Invite' }
        '400': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      operationId: createInvite
      tags: [Membership]
      summary: Invite one named person
      description: |
        One invitation, for one person, usable once, expiring in seven days.
        All three are enforced server-side rather than in the UI, because the
        threat model is a link being passed on — and a link cannot be asked
        not to be.

        The code alphabet excludes `O`, `0`, `I`, `1` and `l`, so a code read
        aloud down a phone line cannot be mistyped into somebody else's.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [communityId, name]
              properties:
                communityId: { type: string }
                community: { type: string, description: 'Display name, for the join screen.' }
                name: { type: string, description: Who it is for. }
                unit: { type: string }
                contact: { type: string, description: 'Returned only to you, never in the preview.' }
                role: { type: string, default: Resident }
                invitedBy: { type: string }
      responses:
        '201':
          description: Created, with the code and the phrase to say aloud.
          content:
            application/json:
              schema:
                type: object
                properties:
                  invite: { $ref: '#/components/schemas/Invite' }
        '400': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    patch:
      operationId: redeemInvite
      tags: [Membership]
      summary: Redeem or revoke an invitation
      description: |
        `action: "join"` spends the invitation and creates the membership.
        `action: "revoke"` withdraws one you sent — possible only while it is
        unspent, and only for the person who issued it.

        Redemption is a single conditional update, so two people racing the
        same forwarded link cannot both get in. The loser gets `409`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string }
                action: { type: string, enum: [join, revoke], default: join }
      responses:
        '200':
          description: Joined, or revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  joined: { type: boolean }
                  communityId: { type: string }
                  ok: { type: boolean }
        '400': { $ref: '#/components/responses/Error' }
        '403':
          description: Not yours to revoke, or already used.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409':
          description: Already used, or expired.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /contributions:
    get:
      operationId: getFund
      tags: [Money]
      summary: What a campaign has raised, and what it is buying
      description: |
        The total, the giver list, and the plan it is being spent against.

        **The total is counted from the contributions themselves on every
        read, never stored.** A number people are trusting with their own money
        must not be able to drift, and a stored count cannot be recounted.

        Allocation across the plan is computed here too, from `mode`:
        `priority` fills each line before starting the next, `spread` shares
        every contribution proportionally. Whichever the planner chose is shown
        to contributors, so nobody has to take on trust where their money went.

        Names in the list are display names — `Wanjiru K.` — even when the
        giver is not anonymous. Giving toward a funeral should not publish your
        full name to the street. `anonymous` hides the name from the list but
        **not** the record: the committee must still be able to account for the
        money, and an untraceable contribution is an accounting hole rather
        than a privacy feature.
      parameters:
        - { name: proposal, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: The campaign as it stands.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Fund' }
        '400': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      operationId: contribute
      tags: [Money]
      summary: Give toward a campaign
      description: |
        Records a contribution and returns the new total.

        Amounts are **minor units** — cents, not currency — as integers, so no
        floating point can round somebody's money. `500` is five of whatever
        the community's currency is when that currency has two decimal places.

        Nothing is charged here. The money moves the way the community already
        moves money; this records that it did, so the committee can account
        for it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [proposalId, amount]
              properties:
                proposalId: { type: string }
                amount: { type: integer, minimum: 1, description: Minor units. }
                name: { type: string }
                anonymous: { type: boolean, default: false }
                note: { type: string }
      responses:
        '201':
          description: Recorded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  raised: { type: integer }
                  givers: { type: integer }
        '400': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    put:
      operationId: setFundPlan
      tags: [Money]
      summary: Set the target and the spending plan
      description: |
        Replaces the plan for a campaign. **Only the person who raised it** —
        checked against the actor, not a field in the request.

        The plan is what turns a total into accountability. A target alone says
        how much is wanted; these lines say what it buys, and let every
        contribution be shown landing somewhere specific.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [proposalId]
              properties:
                proposalId: { type: string }
                target: { type: integer, description: Minor units. }
                purpose: { type: string }
                mode: { type: string, enum: [priority, spread] }
                items:
                  type: array
                  items: { $ref: '#/components/schemas/FundItem' }
      responses:
        '200':
          description: Saved.
          content:
            application/json:
              schema:
                type: object
                properties: { ok: { type: boolean } }
        '400': { $ref: '#/components/responses/Error' }
        '403':
          description: Not your campaign.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

components:
  securitySchemes:
    deviceKey:
      type: apiKey
      in: header
      name: x-jamiio-key
      description: |
        **Not authentication.** A client-generated identifier for one device,
        used to establish ownership of records it created. Treat endpoints
        secured by this alone as open.
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        A Clerk session token, verified server-side against Clerk using the
        instance secret. This is the only credential that identifies a *person*
        rather than a handset.

  responses:
    TooManyRequests:
      description: Budget exceeded for this caller and window.
      headers:
        Retry-After:
          description: Seconds until the window resets.
          schema: { type: integer }
        RateLimit-Limit:
          schema: { type: integer }
        RateLimit-Remaining:
          schema: { type: integer }
        RateLimit-Reset:
          description: Unix seconds when the window ends.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Error:
      description: Something was wrong with the request or the server.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NoKey:
      description: No device key was presented.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Invite:
      type: object
      description: An invitation as its sender sees it — contact details included, because they wrote them.
      properties:
        code: { type: string, description: 'Alphabet excludes O/0/I/1/l so it survives being read aloud.' }
        name: { type: string }
        unit: { type: string }
        contact: { type: string, description: Never returned in the public preview. }
        role: { type: string }
        phrase: { type: string, description: Three words to say out of band. The join screen shows the same three. }
        status: { type: string, enum: [sent, opened, joined, revoked] }
        community: { type: string }
        communityId: { type: string }
        invitedBy: { type: string }
        expiresAt: { type: string, format: date-time }
    InvitePreview:
      type: object
      description: |
        What whoever holds the link may see. No contact details, and a display
        name only — a forwarded invite must not become a way to learn who lives
        where.
      properties:
        name: { type: string, example: Wanjiru K. }
        community: { type: string }
        invitedBy: { type: string, example: Wanjiru K. }
        phrase: { type: string }
        role: { type: string }
        usable: { type: boolean }
        status: { type: string, enum: [sent, opened, joined, expired] }
    FundItem:
      type: object
      description: One line of what the money buys.
      properties:
        id: { type: string }
        name: { type: string }
        amount: { type: integer, description: Minor units. }
        allocated: { type: integer, readOnly: true, description: 'Computed on read, never stored.' }
        spent: { type: integer }
        note: { type: string }
        receipt: { type: string }
    Fund:
      type: object
      description: A campaign as it stands. Totals are counted from contributions on every read.
      properties:
        raised: { type: integer }
        givers: { type: integer }
        target: { type: integer }
        purpose: { type: string }
        mode: { type: string, enum: [priority, spread] }
        plan:
          type: array
          items: { $ref: '#/components/schemas/FundItem' }
        contributions:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              name: { type: string, description: 'Display name, or "Someone" when anonymous.' }
              amount: { type: integer }
              note: { type: string }
              mine: { type: boolean }
              at: { type: string, format: date-time }
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: A sentence a person could act on, not a code.
          examples: ['A community called "Riverside Gardens" already exists. Join it, or pick another name.']

    Community:
      type: object
      properties:
        id: { type: string, examples: [havenwood-ridge] }
        name: { type: string, examples: [Havenwood Ridge] }
        region:
          type: string
          description: Region pack governing currency, payment rail and advice.
          enum: [nbo, los, pdx]
        area: { type: string, examples: ['Fuquay-Varina, Wake, North Carolina'] }
        homes: { type: integer, description: 'Eligible homes, which sets quorum.' }
        lat: { type: [number, 'null'] }
        lon: { type: [number, 'null'] }
        timezone: { type: string }
        country: { type: string }
        config: { type: object, additionalProperties: true }
        mine: { type: boolean }
        createdAt: { type: string, format: date-time }

    NewCommunity:
      type: object
      required: [name]
      properties:
        name: { type: string, minLength: 2 }
        region: { type: string, enum: [nbo, los, pdx], default: nbo }
        area: { type: string }
        homes: { type: integer, minimum: 0 }
        lat: { type: number }
        lon: { type: number }
        timezone: { type: string }
        country: { type: string }
        config: { type: object, additionalProperties: true }

    Membership:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        region: { type: string }
        area: { type: string }
        homes: { type: integer }
        role: { type: string, enum: [resident, admin] }
        mine: { type: boolean }

    Profile:
      type: object
      properties:
        name: { type: string }
        unit:
          type: string
          description: House or flat — the part no place lookup can know.
        phone: { type: string }
        area:
          type: string
          description: Locality, pinned from a geocoder.
        lat: { type: [number, 'null'] }
        lon: { type: [number, 'null'] }
        portable:
          type: boolean
          readOnly: true
          description: False means this is tied to one device until sign-in.

    PersonState:
      type: object
      properties:
        profile:
          oneOf:
            - { $ref: '#/components/schemas/Profile' }
            - type: 'null'
        activeCommunity: { type: [string, 'null'] }
        tourDone: { type: boolean }
        portable: { type: boolean }

    TradeName:
      type: string
      enum: [Plumber, Electrician, Roofing, HVAC, Cleaning, Handyman]

    Check:
      type: object
      properties:
        id: { type: string, enum: [licence, insurance, background, payout, references] }
        name: { type: string }
        hint: { type: string }
        required:
          type: boolean
          description: Licence, insurance and background are required to go live.
        done: { type: boolean }

    TradeAccountInput:
      type: object
      required: [business]
      properties:
        id: { type: string, description: Omit to create. }
        business: { type: string, minLength: 2 }
        owner: { type: string }
        mono: { type: string, maxLength: 3 }
        phone: { type: string }
        trades:
          type: array
          items: { $ref: '#/components/schemas/TradeName' }
        radius: { type: integer, description: Kilometres from base. }
        base: { type: string }
        blurb: { type: string }
        checks:
          type: array
          items: { $ref: '#/components/schemas/Check' }
        communities:
          type: array
          items: { type: string }
          description: Communities whose committee has opted this trade in.
        eta: { type: string }

    TradeAccount:
      allOf:
        - $ref: '#/components/schemas/TradeAccountInput'
        - type: object
          properties:
            id: { type: string }
            rating: { type: number }
            reviews: { type: integer }
            jobsDone: { type: integer }
            live:
              type: boolean
              readOnly: true
              description: Computed server-side. Ignored on input.

    JobStatus:
      type: string
      enum: [new, quoted, accepted, enroute, onsite, complete, declined]

    NewJob:
      type: object
      required: [community, trade, title]
      properties:
        community: { type: string, description: Display name at the time it was raised. }
        communityId: { type: string, description: What the job is keyed on. }
        area: { type: string }
        unit: { type: string }
        resident: { type: string }
        residentMono: { type: string }
        providerId:
          type: [string, 'null']
          description: Null leaves it open to any qualifying trade.
        trade: { $ref: '#/components/schemas/TradeName' }
        title: { type: string, minLength: 1 }
        detail: { type: string }
        photoGuess:
          type: string
          description: |
            What the resident's own device read the photo as, and confirmed.
            The image itself never leaves the phone and is never sent here.
        urgent: { type: boolean }
        symbol: { type: string, examples: [KSh] }

    Job:
      allOf:
        - $ref: '#/components/schemas/NewJob'
        - type: object
          properties:
            id: { type: string }
            status: { $ref: '#/components/schemas/JobStatus' }
            quote: { type: number }
            raised: { type: string, format: date-time }

    EventTerms:
      type: object
      description: What the proposer is asking permission for, stated explicitly.
      properties:
        guestsAllowed: { type: boolean }
        guestsPerHome: { type: integer, minimum: 0 }
        feeAmount: { type: number, minimum: 0 }
        feePurpose: { type: string }
        usesCommunityFunds:
          type: boolean
          description: When true the proposal must go to a vote, never one approver.
        fundsAmount: { type: number, minimum: 0 }
        fundsPurpose: { type: string }

    ProposalInput:
      type: object
      required: [community]
      properties:
        id: { type: string, description: Omit to create. }
        community: { type: string }
        title: { type: string }
        kind: { type: string }
        icon: { type: string }
        hue: { type: integer }
        day: { type: string }
        time: { type: string }
        where: { type: string }
        note: { type: string }
        flier: { type: string, enum: [none, uploaded, generated] }
        terms: { $ref: '#/components/schemas/EventTerms' }
        status:
          type: string
          enum: [draft, proposed, voting, approved, published, rejected]
        path: { type: string, enum: [committee, vote] }
        proposedBy: { type: string }
        proposedByMono: { type: string }
        proposerIsCommittee: { type: boolean }
        eligible: { type: integer, description: 'Homes eligible, snapshotted.' }
        step: { type: integer }

    Proposal:
      allOf:
        - $ref: '#/components/schemas/ProposalInput'
        - type: object
          properties:
            votesFor: { type: integer, readOnly: true }
            votesAgainst: { type: integer, readOnly: true }
            myVote:
              type: [string, 'null']
              enum: [for, against, null]
              readOnly: true
