feat(docs): add a Diátaxis documentation section to the public site
A new public /docs area covering the whole platform, organized by Diátaxis: tutorials (learning), how-to guides (tasks), reference (facts), and explanation (the why). 29 pages, each grounded in the actual routes, actions, schema, and UI — verified against the code and corrected for accuracy (e.g. admin-only vs admin+board gating, where the permissions matrix actually lives, how Stripe reconciliation matches). Implementation: - lib/docs/nav.ts is the single source of truth (categories + pages), driving the sidebar, static params, and prev/next. - Content authored as content/docs/<category>/<slug>.md, read at build time (the routes are statically generated), rendered by <DocsMarkdown> — reuses the existing react-markdown/GFM stack, no new dependencies. - (docs) route group with its own theme (docs.css) matching the public site's dark / monospace / lime aesthetic; sidebar with active-state, breadcrumb, prev/next, and a Diátaxis home. Public — signed-in users can read it too. - "Docs" link added to the landing nav. typecheck 0 · lint 0 errors · build ok (all /docs pages prerendered)
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
Physical access lives in the door module: member cards, per-door connections to a controller, the slot map that keys cards into a controller, inbound event ingest, and an append-only access log. This page is the exhaustive reference for those tables, their fields, and the permissions that gate them. To set a door up step by step, see [Connect a door](/docs/how-to/connect-a-door).
|
||||
|
||||
## Permissions
|
||||
|
||||
Two access permission codes gate the whole module, plus one for API buttons. All are additive and assignable to any role; the seed grants them to `board`, and `admin` holds them implicitly. See [Roles and permissions](/docs/reference/roles-and-permissions).
|
||||
|
||||
| Code | Group | Grants |
|
||||
| --- | --- | --- |
|
||||
| `door.manage` | Access | Configure connections and API buttons; manage member cards; view the access log |
|
||||
| `door.operate` | Access | Live actions: open/unlock/lock, grant/revoke a card; view the access log |
|
||||
| `apicall.invoke` | Access | Default required permission to press an API button |
|
||||
|
||||
Managers configure at [/door/manage](/door/manage) and [/door/buttons](/door/buttons). Members see their own cards, self-entry doors, buttons they may press, and their recent activity at [/doors](/doors); the dashboard also surfaces self-entry.
|
||||
|
||||
## Member cards
|
||||
|
||||
`member_cards` maps a physical card UID to a member. The raw UID is treated as a credential: only `door.manage` holders can read the table, and a member's own view returns a per-card list (`card_type`, `label`, `is_active`, and the last four characters of the UID, rendered `••••ABCD`) — never more than the last four of the UID.
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `card_uid` | text | The credential. Unique per `(space_id, card_uid)` |
|
||||
| `card_type` | text | `rfid` or `nfc` (default `rfid`) |
|
||||
| `label` | text | Optional operator label |
|
||||
| `is_active` | boolean | Inactive cards cannot be granted |
|
||||
|
||||
## Door connections
|
||||
|
||||
A `door_connections` row is one configured integration to a physical controller.
|
||||
|
||||
| Field | Values | Notes |
|
||||
| --- | --- | --- |
|
||||
| `adapter` | `native_heatsync`, `generic_http` | HeatSync/23b uses built-in query-string verbs; generic uses per-verb templates |
|
||||
| `base_url` | must start with `http://`/`https://` | Controller or reachable proxy |
|
||||
| `pinned_host` | hostname/IP | SSRF pin: the executor calls only this host, blocks cloud-metadata/link-local, and forbids redirects |
|
||||
| `auth_mode` | `none`, `query`, `header`, `bearer` | With `auth_param` |
|
||||
| `secret_ref` | secrets vault id | Outbound door password; decrypted server-side only, never returned |
|
||||
| `verbs` | jsonb | Generic per-verb templates with `{slot}`, `{tag}`, `{perm}`, `{door}`, `{pw}` |
|
||||
| `allow_member_self_entry` | boolean | Opt-in per connection; off by default |
|
||||
| `is_enabled` | boolean | Disabled connections reject all calls |
|
||||
| `inbound_enabled` | boolean | Gates the inbound webhook |
|
||||
| `inbound_secret_ref` | secrets vault id | Inbound webhook bearer, distinct from the door password |
|
||||
|
||||
Native HeatSync encodes fixed-width query strings: grant `?m<slot>&p<perm>&t<tag>&e=<pw>`, revoke `?r<slot>&e=<pw>`, and control verbs `?o1` (open), `?u` (unlock), `?l` (lock), `?9` (status), `?z` (log).
|
||||
|
||||
## Card slots
|
||||
|
||||
`door_card_slots` is the platform's allocation map. Controllers key cards by an integer `slot` (HeatSync range 0–200), scoped per connection.
|
||||
|
||||
- `UNIQUE (connection_id, slot)` arbitrates concurrent grants racing for a slot.
|
||||
- `UNIQUE (connection_id, card_id)` makes re-granting idempotent.
|
||||
- The lowest-free-slot policy lives in unit-tested logic, not SQL. Only the service-client executor writes rows, in lockstep with the controller call.
|
||||
|
||||
## Inbound ingest
|
||||
|
||||
Two transports pull entry/denied events into the log and share one core. Ingested rows carry `door_access_log.dedupe_key` (partial-unique per connection), so retries and re-polls insert once via `ON CONFLICT DO NOTHING`.
|
||||
|
||||
- Poll: a `CRON_SECRET`-guarded route reads each enabled `native_heatsync` connection's `?z` log through the hardened executor.
|
||||
- Webhook: a per-connection public endpoint accepts normalized event JSON, authenticated by the inbound bearer secret. Used for generic controllers and relays.
|
||||
|
||||
## Access log
|
||||
|
||||
`door_access_log` is an append-only, immutable audit of every attempt. Secrets are redacted before write, and only the validated service-client executor inserts rows (no client write policy). `door.manage` or `door.operate` may read it.
|
||||
|
||||
| Field | Notes |
|
||||
| --- | --- |
|
||||
| `action` | `grant`, `revoke`, `open`, `unlock`, `lock`, `test`, `self_entry`, plus ingested events |
|
||||
| `success` | Attempt outcome |
|
||||
| `actor_member_id` / `target_member_id` | Who acted / whose card |
|
||||
| `detail` | Redacted status snippet |
|
||||
| `dedupe_key` | Set only on ingested rows |
|
||||
|
||||
## API buttons
|
||||
|
||||
`api_buttons` are named admin-defined HTTP calls fired through the same hardened egress as the door executor.
|
||||
|
||||
| Field | Values | Notes |
|
||||
| --- | --- | --- |
|
||||
| `label` / `button_group` | text | Grouped and ordered by `sort_order` |
|
||||
| `method` | `GET`, `POST`, `PUT`, `PATCH`, `DELETE` | Default `POST` |
|
||||
| `base_url` / `pinned_host` | — | Per-button SSRF pin |
|
||||
| `url_template` / `body_template` / `headers` | — | With `secret_ref` injected server-side |
|
||||
| `required_permission` | catalog code | Default `apicall.invoke`; a member sees only buttons whose permission they hold |
|
||||
| `confirm` | boolean | Prompt before firing (default true) |
|
||||
|
||||
Managing buttons requires `door.manage`. Every press writes one redacted row to `api_call_log`, which mirrors the access log's immutable, `door.manage`-read posture.
|
||||
@@ -0,0 +1,106 @@
|
||||
The classes, certifications, and equipment modules share one spine: a certification type awarded by completing a class can gate who may reserve a piece of equipment. This page is the exhaustive reference for their data model, statuses, capacity rules, and the permissions that guard each action.
|
||||
|
||||
## Classes
|
||||
|
||||
A class is a per-space offering; a session is a scheduled occurrence; a signup is one member's place in a session. Members browse and sign up at [/classes](/classes); managers create classes and sessions at [/classes/manage](/classes/manage).
|
||||
|
||||
### Class fields
|
||||
|
||||
| Field | Notes |
|
||||
| --- | --- |
|
||||
| `title` | 1–200 chars, required |
|
||||
| `description` | optional |
|
||||
| `payment_link` | optional `http(s)` URL only; a manual link, no live payment integration |
|
||||
| `capacity` | optional default per-session capacity; `> 0`; null = unlimited |
|
||||
| `grants_certification_id` | optional; completing a session of this class can award this certification |
|
||||
| `required_form_id` | optional; a published [form](/docs/how-to/build-a-form) that must be on file before signup |
|
||||
| `is_active` | archive flag; inactive classes are hidden from members |
|
||||
|
||||
### Session fields
|
||||
|
||||
`class_sessions` carry `starts_at`, optional `ends_at` (must be `>= starts_at`), `location`, an optional `capacity` override, `notes`, and a status.
|
||||
|
||||
| Session status | Meaning |
|
||||
| --- | --- |
|
||||
| `scheduled` | open; the default |
|
||||
| `cancelled` | closed to signups; signed-up members are emailed |
|
||||
| `completed` | run and closed; set only via completion, which fires cert awards |
|
||||
|
||||
### Signup and waitlist rules
|
||||
|
||||
- **Effective capacity** = the session `capacity` if set, else the class `capacity`, else unlimited.
|
||||
- A new signup is `registered` when the current registered count is below effective capacity, otherwise `waitlisted`.
|
||||
- At most one non-cancelled signup per member per session; cancelling leaves the row as history and a re-signup is allowed.
|
||||
- When a `registered` member cancels and a seat frees, the earliest-signed-up `waitlisted` member is promoted to `registered` and emailed.
|
||||
- Signup and cancel run inside per-session transactional functions (`class_signup_tx` / `class_cancel_tx`) so concurrent signups cannot over-enroll and concurrent cancels cannot double-promote.
|
||||
|
||||
| Signup status | Set by |
|
||||
| --- | --- |
|
||||
| `registered` | signup below capacity, or waitlist promotion |
|
||||
| `waitlisted` | signup at or above capacity |
|
||||
| `cancelled` | member or manager cancel |
|
||||
|
||||
Signing up needs no permission, only space membership. If the class has a `required_form_id`, the member must have a completed submission on file first; a manager acting with override bypasses this and can sign another member up.
|
||||
|
||||
### Attendance and completion
|
||||
|
||||
Instructors mark `attended` per signup and complete a session. Completing sets the session to `completed`; if the class has `grants_certification_id`, the certification is awarded to every attended, non-cancelled member. That award goes through the normal certifications path, so it only succeeds if the acting instructor also holds `certifications.grant`; otherwise completion still succeeds and the response reports that certificates were not issued.
|
||||
|
||||
### Class permissions
|
||||
|
||||
| Permission | Grants |
|
||||
| --- | --- |
|
||||
| `classes.manage` | create/edit/archive classes; schedule/cancel/delete sessions |
|
||||
| `classes.instruct` | view attendees, mark attendance, complete a session |
|
||||
|
||||
Both seed to the `board` role by default.
|
||||
|
||||
## Certifications
|
||||
|
||||
A certification is a per-space type (name unique case-insensitive, `description`, optional `validity_months`, `is_active`). A grant (`member_certifications`) awards one type to one member. Managed alongside members at [/certifications](/certifications) and [/members](/members).
|
||||
|
||||
- `expires_at` is computed and stored **at grant time** from the type's `validity_months` (null = never expires). Later edits to the type never change existing grants.
|
||||
- At most one active (non-revoked) grant per member per type; revoked grants stay as history and a re-grant is allowed.
|
||||
- Revoking is a soft revoke (`revoked_at`, `revoked_by`, `revoked_reason`); grants are never hard-deleted by clients.
|
||||
- Renewing a non-revoked grant resets `granted_at` and recomputes expiry. A revoked grant is terminal — issue a fresh grant.
|
||||
|
||||
| Cert status | Rule |
|
||||
| --- | --- |
|
||||
| `active` | not revoked, not expired |
|
||||
| `expiring_soon` | expires within 30 days |
|
||||
| `expired` | past `expires_at` |
|
||||
| `revoked` | `revoked_at` set (always wins) |
|
||||
|
||||
| Permission | Grants |
|
||||
| --- | --- |
|
||||
| `certifications.manage` | create/edit/archive certification types |
|
||||
| `certifications.grant` | award/revoke/renew grants; this is the "instructor" capability |
|
||||
|
||||
## Equipment and reservations
|
||||
|
||||
Equipment is a per-space tool record; a reservation is one member's time window on one tool. Members browse and reserve at [/equipment](/equipment); managers maintain the registry at [/equipment/manage](/equipment/manage). See [Reserve equipment](/docs/how-to/equipment-reservations) for the task walkthrough.
|
||||
|
||||
Equipment fields: `name` (1–200), `description`, `location`, `asset_tag`, `is_active`, an optional `required_certification_id`, and an operational status.
|
||||
|
||||
| Equipment status | Reservable? |
|
||||
| --- | --- |
|
||||
| `available` | yes |
|
||||
| `maintenance` | no |
|
||||
| `retired` | no |
|
||||
|
||||
### Reservation rules
|
||||
|
||||
- Reservations use half-open `[starts_at, ends_at)` windows; `ends_at` must be after `starts_at` and cannot start in the past.
|
||||
- Only `reserved` rows block; `cancelled` and `completed` never do. Two `reserved` windows on the same equipment may not overlap. Touching end-to-start does not overlap.
|
||||
- No-overlap is enforced in the database by a GiST exclusion constraint, so concurrent requests cannot double-book.
|
||||
- If the equipment has `required_certification_id`, the member must hold an active grant of it. A manager with `equipment.manage` override bypasses the cert gate and may book on another member's behalf, but override never bypasses an operational status block.
|
||||
|
||||
| Reservation status | Meaning |
|
||||
| --- | --- |
|
||||
| `reserved` | active; blocks overlaps |
|
||||
| `cancelled` | released; member or manager |
|
||||
| `completed` | past/closed |
|
||||
|
||||
Reserving needs only membership. `equipment.manage` covers registry CRUD, status changes, and adjusting or cancelling any reservation. Equipment that has reservations must be archived rather than deleted; likewise a class with sessions and a session with signups.
|
||||
|
||||
Cert-gating ties the modules together: grant a certification on class completion, require that certification to reserve a machine. Roles and the seeding of these permissions are covered in [Roles and permissions](/docs/reference/roles-and-permissions).
|
||||
@@ -0,0 +1,82 @@
|
||||
Forms and waivers are per-space form definitions with a jsonb field schema, a lifecycle status, and a visibility setting. Every submission snapshots the schema and legal text it was signed against, so a waiver stays valid against exactly what the signer saw even after the form is edited. This page documents the field types, visibility rules, gating, and the immutable submission record. To build one, see [Build a form](/docs/how-to/build-a-form).
|
||||
|
||||
## Where forms live
|
||||
|
||||
Managers work with forms at [/forms](/forms): created at [/forms/new](/forms/new), edited at `/forms/{id}/edit`, and their submissions viewed at `/forms/{id}/results`. Public and signed-in forms are filled at `/f/{space}/{slug}`; members-only forms are filled from inside the app. Managing forms requires the `forms.manage` permission, seeded to the `board` role by default (admins always have it via implicit-all). Ordinary members can read only `published` forms.
|
||||
|
||||
## Form record
|
||||
|
||||
Each form is one row in the `forms` table.
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `slug` | text | Unique per space. Lowercase letters, numbers, and internal hyphens; 1–80 chars. Immutable after creation. |
|
||||
| `title` | text | 1–200 chars. |
|
||||
| `description` | text | Optional, up to 2000 chars. |
|
||||
| `kind` | enum | `form` or `waiver`. |
|
||||
| `visibility` | enum | `public_anon`, `public_auth`, or `members`. |
|
||||
| `status` | enum | `draft`, `published`, or `closed`. |
|
||||
| `schema` | jsonb | Ordered array of field definitions (see below). |
|
||||
| `legal_text` | text | Optional legal/consent text, up to 100000 chars. |
|
||||
| `version` | integer | Starts at 1; bumps on waiver edits (see [Versioning](#versioning)). |
|
||||
|
||||
The slug is unique per space (`UNIQUE (space_id, slug)`), not globally, so two spaces can use the same slug.
|
||||
|
||||
## Field types
|
||||
|
||||
The `schema` is an ordered array of fields. Each field has a `key` (lowercase letters, numbers, underscores; 1–60 chars; unique within the form), a `label`, an optional `help` string, and an optional `required` flag (default `false`). A form may have up to 200 fields.
|
||||
|
||||
| `type` | Answer stored as | Server validation |
|
||||
| --- | --- | --- |
|
||||
| `short_text` | trimmed string | max 2000 chars |
|
||||
| `long_text` | trimmed string | max 20000 chars |
|
||||
| `email` | trimmed, lowercased string | must match an email pattern |
|
||||
| `number` | number | must be finite |
|
||||
| `date` | string | must parse as a date |
|
||||
| `checkbox` | boolean | if `required`, must be checked (`true`) |
|
||||
| `select` | string | must be one of `options` |
|
||||
| `radio` | string | must be one of `options` |
|
||||
|
||||
`select` and `radio` fields require an `options` array (each option 1–200 chars, up to 100 options). Answers are validated server-side against the stored schema on submit: unknown keys are discarded, empty required fields are rejected, and only known fields are persisted.
|
||||
|
||||
## Member linking
|
||||
|
||||
A submission links to a member when its `submitter_email` matches a member in the same space (case-insensitive); if several members share an email, the earliest-joined one is chosen. For members-only and signed-in forms the linked member and email come from the authenticated session. For anonymous public submissions the email may be typed or derived from an `email`-type answer; linking by a typed email is an intentional attribution-only behavior that grants no access. Managers can re-run linking across the space, and members can retro-claim their own prior anonymous submissions once their email is verified.
|
||||
|
||||
## Visibility
|
||||
|
||||
Visibility controls who can open and submit a form.
|
||||
|
||||
| `visibility` | UI label | Who can submit | Public URL |
|
||||
| --- | --- | --- | --- |
|
||||
| `public_anon` | Public | Anyone, no sign-in | `/f/{space}/{slug}` |
|
||||
| `public_auth` | Signed in | Any signed-in user | `/f/{space}/{slug}` |
|
||||
| `members` | Members only | Members of the space | Not served publicly; filled in-app |
|
||||
|
||||
Members-only forms are never returned by the public page, so their schema is not exposed to non-members. The public read path serves only a `published` form and never a `members` form. A form must be `published` to accept responses regardless of visibility; `draft` and `closed` forms reject submissions.
|
||||
|
||||
## Required-form gating
|
||||
|
||||
A class definition can reference a form as its `required_form_id`. When set, the form gates signups for that class's sessions: a member cannot sign up until they have a submission on file for that form. The referenced form must be `published` before it can gate signups. See [Classes and sessions](/docs/reference/modules) and the class screens under [/classes](/classes).
|
||||
|
||||
## Submission snapshots
|
||||
|
||||
Submissions are rows in `form_submissions`. Each row captures a point-in-time copy of the form so the record is self-contained:
|
||||
|
||||
| Field | Notes |
|
||||
| --- | --- |
|
||||
| `answers` | jsonb, keyed by field `key`; only known, validated fields. |
|
||||
| `form_snapshot` | The field schema as it was at submit time. |
|
||||
| `legal_text_snapshot` | The legal text as it was at submit time. |
|
||||
| `form_version` | The form's `version` at submit time. |
|
||||
| `member_id` | Linked member, if resolved (see below). |
|
||||
| `submitter_email` | Provided or derived email, lowercased. |
|
||||
| `ip`, `user_agent`, `created_at` | Request metadata and timestamp. |
|
||||
|
||||
Submissions are immutable and append-only. The `form_submissions` table has no client insert, update, or delete policy under row-level security, so every submission is written by one validated server action using the service client, after server-side schema validation and snapshotting. Managers can read submissions (`forms.manage`) and permanently delete a single submission from the results screen. Deleting a form cascades and permanently removes all of its submissions, including signed waivers; this requires an explicit confirmation.
|
||||
|
||||
Results are viewable at `/forms/{id}/results` and can be exported to CSV. The CSV header is `submitted_at`, `form_version`, `member_id`, `submitter_email`, `ip`, followed by one column per field key.
|
||||
|
||||
## Waivers and versioning {#versioning}
|
||||
|
||||
A form with `kind` set to `waiver` requires the submitter to agree (a consent flag must be `true`) before the submission is accepted. A form's `version` starts at 1. Editing bumps the version only when it is a `published` waiver and either the legal text or the field schema actually changed. This is a non-blocking re-sign: existing submissions stay valid against their own snapshot, and only new signers see the bumped version. Ordinary forms and draft waivers never bump their version on edit.
|
||||
@@ -0,0 +1,165 @@
|
||||
The governance kernel is three linked entities — proposals, incidents, and policies — plus the enums and computed fields that drive async voting, code-of-conduct tracking, and versioned bylaws. This page enumerates every enum value, field, and lifecycle state defined in the schema. It backs the app screens at [/proposals](/proposals), [/incidents](/incidents), and [/policies](/policies), and the how-to guides [Run a proposal](/docs/how-to/run-a-proposal), [Handle incidents](/docs/how-to/handle-incidents), and [Publish a policy](/docs/how-to/publish-a-policy).
|
||||
|
||||
## Proposals
|
||||
|
||||
A proposal is an async vote in a space. Members read and cast; admin and board members transition status. Tallies are recomputed by trigger on every vote.
|
||||
|
||||
### `proposal_type`
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `bylaw_change` | Amend a policy; usually cites a clause via `policy_ref_id` |
|
||||
| `board_action` | A board decision put to a vote |
|
||||
| `membership_vote` | A vote on a membership matter |
|
||||
| `advisory_poll` | Non-binding poll (the default) |
|
||||
| `recall` | Vote of confidence / recall |
|
||||
| `budget` | Budget or spending decision |
|
||||
|
||||
The column default is `advisory_poll`.
|
||||
|
||||
### `proposal_status`
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `draft` | Editable by the proposer; not yet open for votes |
|
||||
| `open` | Voting window active; votes accepted |
|
||||
| `decided` | Closed with a recorded outcome |
|
||||
| `withdrawn` | Retracted before decision |
|
||||
| `expired` | Voting window elapsed without a decision |
|
||||
|
||||
The column default is `draft`. Votes are only accepted while `status = 'open'` and the current time is within `voting_opens_at` and `voting_closes_at`.
|
||||
|
||||
### `threshold_rule`
|
||||
|
||||
Determines what fraction of yes-plus-no votes is required to pass.
|
||||
|
||||
| Value | Passes when |
|
||||
|---|---|
|
||||
| `simple_majority` | `yes > no` |
|
||||
| `two_thirds` | `yes * 3 >= (yes + no) * 2` |
|
||||
| `three_fourths` | `yes * 4 >= (yes + no) * 3` |
|
||||
| `unanimous` | `no = 0` and `yes > 0` |
|
||||
|
||||
A proposal passes only if quorum is met and at least one non-abstain vote exists. The column default is `simple_majority`.
|
||||
|
||||
### `vote_position`
|
||||
|
||||
| Value | Counts toward quorum | Counts toward threshold |
|
||||
|---|---|---|
|
||||
| `yes` | Yes | Yes |
|
||||
| `no` | Yes | Yes |
|
||||
| `abstain` | Yes | No |
|
||||
| `recused` | No | No |
|
||||
|
||||
A vote of `recused` requires a non-empty `recusal_reason` (enforced by a check constraint). One vote per member per proposal (`UNIQUE (proposal_id, member_id)`); votes are never deleted.
|
||||
|
||||
### Quorum and outcome fields
|
||||
|
||||
Quorum is computed by trigger when a proposal moves to `open`. The member count includes only `space_members` with `status` in `current` or `late` and `approved = true`.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `quorum_percent` | integer | Percentage from space settings at open time |
|
||||
| `quorum_floor` | integer | Minimum absolute count |
|
||||
| `quorum_required` | integer | `GREATEST(quorum_floor, ceil(members * quorum_percent / 100))` |
|
||||
| `voting_opens_at` | timestamptz | Defaults to `now()` at open |
|
||||
| `voting_closes_at` | timestamptz | Defaults to open plus the space voting window |
|
||||
| `outcome_yes` / `outcome_no` / `outcome_abstain` / `outcome_recused` | integer | Live tallies, refreshed per vote |
|
||||
| `total_voters` | integer | Total votes cast |
|
||||
| `quorum_met` | boolean | `(yes + no + abstain) >= quorum_required` |
|
||||
| `passed` | boolean | Applies the `threshold_rule` |
|
||||
|
||||
## Incidents
|
||||
|
||||
An incident is a code-of-conduct complaint, safety report, or conflict. Admin and board members see all incidents; the reporter sees their own. Incidents are never deleted.
|
||||
|
||||
### `incident_status`
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `received` | Filed, not yet acknowledged (the default) |
|
||||
| `under_review` | Acknowledged by board; `acknowledged_at` set |
|
||||
| `decided` | Disposition recorded; `decided_at` set |
|
||||
| `appealed` | Reporter filed an appeal, linked via `appeal_proposal_id` |
|
||||
| `closed` | Resolved; `closed_at` set |
|
||||
|
||||
### `incident_severity`
|
||||
|
||||
| Value |
|
||||
|---|
|
||||
| `low` |
|
||||
| `medium` (default) |
|
||||
| `high` |
|
||||
| `critical` |
|
||||
|
||||
### `incident_update_visibility`
|
||||
|
||||
Each entry in an incident's update thread carries its own visibility. Admin and board can always read.
|
||||
|
||||
| Value | Visible to |
|
||||
|---|---|
|
||||
| `reporter_only` | The reporter |
|
||||
| `all_parties` | Reporter and board (the default) |
|
||||
| `board_only` | Admin and board only |
|
||||
|
||||
### Other incident fields
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `reporter_id` | uuid | Null when anonymous |
|
||||
| `reporter_token` | text | Opaque token for anonymous status lookup; unique |
|
||||
| `is_anonymous` | boolean | Default false |
|
||||
| `subjects` | uuid[] | Members the report concerns |
|
||||
| `category` | text | Free-form; default `general` |
|
||||
| `disposition` | text | The recorded decision |
|
||||
| `decision_maker_ids` | uuid[] | Members who decided |
|
||||
| `sla_response_by` | timestamptz | Computed as `created_at + incident_sla_hours` |
|
||||
|
||||
## Policies
|
||||
|
||||
A policy is a versioned document — bylaws, code of conduct, station rules. Members read; admin and board create. Rows are immutable: a new version is a new row pointing back via `prior_version_id`, uniqueness enforced by `UNIQUE (space_id, slug, version)`.
|
||||
|
||||
### `policy_status`
|
||||
|
||||
| Value | Meaning |
|
||||
|---|---|
|
||||
| `draft` | Not yet in force (the default) |
|
||||
| `active` | In effect |
|
||||
| `deprecated` | Retired without a replacement |
|
||||
| `superseded` | Replaced by a newer version |
|
||||
|
||||
### Other policy fields
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `slug` | text | Stable identifier, e.g. `code-of-conduct` |
|
||||
| `section_ref` | text | Free-form clause reference, e.g. `8.4` |
|
||||
| `parent_policy_id` | uuid | Nests a policy under another |
|
||||
| `body_formal` | text | Formal text |
|
||||
| `body_plain` | text | Plain-language summary |
|
||||
| `version` | integer | Starts at 1 |
|
||||
| `effective_at` | timestamptz | When the version takes effect |
|
||||
| `adopted_by_proposal_id` | uuid | The proposal that adopted it, if any |
|
||||
|
||||
## Space-level defaults
|
||||
|
||||
Six columns on `spaces` seed governance behavior. Proposals copy the quorum, window, and threshold values at open time; incidents copy the SLA at creation.
|
||||
|
||||
| Column | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `default_quorum_percent` | 10 | Quorum as a percent of eligible members |
|
||||
| `default_quorum_floor` | 1 | Minimum quorum count |
|
||||
| `default_voting_window_hours` | 216 | Voting window (9 days) |
|
||||
| `default_threshold` | `simple_majority` | Default `threshold_rule` |
|
||||
| `incident_sla_hours` | 72 | Response SLA (3 days) |
|
||||
| `mission_statement` | null | Space mission text |
|
||||
|
||||
## Cross-references
|
||||
|
||||
The three entities link to each other:
|
||||
|
||||
- A bylaw-change proposal cites a policy via `proposals.policy_ref_id`.
|
||||
- An incident appeal becomes a proposal; the proposal carries `parent_incident_id` and the incident carries `appeal_proposal_id`.
|
||||
- An adopted policy version records the proposal that passed it via `policies.adopted_by_proposal_id`.
|
||||
|
||||
See also the [Modules reference](/docs/reference/modules) for where governance sits among the platform's modules.
|
||||
@@ -0,0 +1,91 @@
|
||||
The platform reaches the outside world through three server endpoints: a notification dispatcher cron that drains the email outbox, two inbound door transports (a poll and a webhook), and the Resend email transport behind them. All three are session-less and authenticated by a shared secret or a per-connection bearer secret, never by a logged-in user. This page documents their contracts, environment variables, and behavior.
|
||||
|
||||
## Environment variables
|
||||
|
||||
These control delivery and cron authentication. The ones that govern integrations are:
|
||||
|
||||
| Variable | Purpose | Unset behavior |
|
||||
|----------|---------|----------------|
|
||||
| `RESEND_API_KEY` | Resend HTTP API key for transactional email. | Outbox still fills; each row is recorded `failed` with "transport not configured" (non-retryable). |
|
||||
| `EMAIL_FROM` | From address for all platform email, e.g. `HeatSync Labs <[email protected]>`. Domain must be verified in Resend (SPF + DKIM). | Same as unset `RESEND_API_KEY`: sends no-op as failed. |
|
||||
| `CRON_SECRET` | Shared secret guarding both cron endpoints. Generate with `openssl rand -hex 32`. | Both cron routes return `503` and never run. |
|
||||
| `SENTRY_DSN` | Optional Sentry-compatible error backend; both cron dispatchers and the webhook report to it. | Capture seam no-ops. |
|
||||
|
||||
The two cron endpoints reuse the same `CRON_SECRET`. The door webhook does not use it — it authenticates on a per-connection secret from the vault.
|
||||
|
||||
## Notification dispatcher — `POST /api/cron/notifications`
|
||||
|
||||
Transactional email is an outbox (the `notifications` table). The Stripe webhook and other server actions only enqueue rows; nothing sends inline. This endpoint drains the outbox and is meant to be hit once a minute by the droplet crontab.
|
||||
|
||||
- Auth: `Authorization: Bearer <CRON_SECRET>`, compared in constant time. Returns `503` if `CRON_SECRET` is unset, `401` on mismatch.
|
||||
- Selects up to 200 oldest `pending`, `channel = 'email'` rows under the attempt cap, then round-robin fair-drains up to 20 across spaces so one tenant's burst cannot head-of-line-block others.
|
||||
- Sends are spaced ~220 ms apart (~4.5/sec) to stay under Resend's 5 req/sec limit.
|
||||
- Idempotent and safe to overlap: there is no row lock, but each send carries a per-attempt `Idempotency-Key` of `${id}:${attempts}`, so concurrent runs of the same attempt dedupe at Resend.
|
||||
- Response body: `{ scanned, sent, failed, retried, skipped }`.
|
||||
|
||||
### Outbox row lifecycle
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| `pending` | Awaiting send or awaiting retry. |
|
||||
| `sent` | Delivered to Resend; `sent_at` set. |
|
||||
| `failed` | Terminal: permanent (config/validation) error, or attempt budget exhausted. |
|
||||
| `skipped` | Terminal: the recipient muted this category (never sent, no Resend call). |
|
||||
|
||||
A row retries on transient failures (`429`, `5xx`, network) up to `MAX_NOTIFICATION_ATTEMPTS` (5) before going to `failed`. Every status write is guarded on `status = 'pending'`, so a losing overlapping run is a no-op.
|
||||
|
||||
## Notification preferences and muting
|
||||
|
||||
Members opt out of muteable categories from their profile at [/me](/me); the toggles write per-member rows the dispatcher reads. Billing notices are membership-critical and can never be muted.
|
||||
|
||||
| Category | Muteable | Example types |
|
||||
|----------|----------|---------------|
|
||||
| `billing` | No | `dues_renewed`, `dues_payment_failed`, `dues_lapsed` |
|
||||
| `bookings` | Yes | `booking_confirmed`, `booking_cancelled` |
|
||||
| `classes` | Yes | `class_signup_registered`, `class_signup_waitlisted`, `class_signup_promoted`, `class_session_cancelled` |
|
||||
| `forms` | Yes | `form_submission_received` |
|
||||
| `admin_alerts` | Yes | `form_submission_admin` |
|
||||
|
||||
The model is opt-out: an absent preference row means enabled. A type with no category mapping is treated as always-on (fail-open). When the dispatcher marks an email `skipped`, the notification still appears unread in the member's in-app inbox on [/me](/me) — the `read_at` marker is independent of email delivery.
|
||||
|
||||
## Door inbound — poll vs. webhook
|
||||
|
||||
A space that runs a door controller can pull real entry/denied events into the access log through one of two transports, both configured per connection on [/door/manage](/door/manage) (requires the `door.manage` permission — see [Access control](/docs/reference/access-control) and [Connect a door](/docs/how-to/connect-a-door)). Both share one ingest core; ingested rows dedupe on `(connection_id, dedupe_key)`.
|
||||
|
||||
### Poll — `POST /api/cron/door-ingest`
|
||||
|
||||
For native-HeatSync controllers only. A once-a-minute crontab call reads each enabled connection's `?z` log through the hardened outbound executor and matches each card to a member.
|
||||
|
||||
- Auth: same `CRON_SECRET` bearer as the notification cron. `503` if unset, `401` on mismatch.
|
||||
- Polls only connections with `is_enabled`, `inbound_enabled`, and `adapter = 'native_heatsync'`, up to 50 per run, concurrently.
|
||||
- Idempotent: overlapping or missed minutes are harmless.
|
||||
|
||||
### Webhook — `POST /api/door/inbound/[connection]`
|
||||
|
||||
For generic (non-HeatSync) controllers or a LAN relay that pushes normalized events. This is the reliable transport: each event carries a caller-supplied stable id, so retries are idempotent.
|
||||
|
||||
- `[connection]` is the connection's UUID; a malformed id returns a generic `404`.
|
||||
- Auth: `Authorization: Bearer <inbound secret>` — the per-connection secret from the AES-256-GCM vault (`inbound_secret_ref`), distinct from the outbound door password. A missing connection, disabled inbound, or bad secret all return the same `401`. The webhook URL is shown on [/door/manage](/door/manage) once inbound is enabled.
|
||||
- Rate limited to 120 requests/minute per connection (`429` over that). Bodies over 64 KB are rejected `413`.
|
||||
|
||||
Request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"events": [
|
||||
{
|
||||
"id": "stable-caller-id",
|
||||
"card_uid": "optional-string",
|
||||
"card_number": "optional-digits",
|
||||
"result": "granted",
|
||||
"occurred_at": "2026-08-05T14:03:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`events` holds 1–100 items. `result` is one of `granted`, `denied`, `unknown`. Provide `card_uid` and/or `card_number` (digits only) to resolve the event to a member. Valid requests return `{ "received": true, ... }` with per-batch insert counts; malformed JSON or a schema failure returns `400`.
|
||||
|
||||
## Email transport (Resend)
|
||||
|
||||
`lib/email/send.ts` is the single transport seam. It talks to Resend's HTTP API (`https://api.resend.com/emails`) with `fetch`, no SDK, so a self-hosted deploy can swap it for SMTP without touching any caller. It classifies each failure as retryable (`429`, `5xx`, network) or terminal (unset config, invalid recipient, other `4xx`), which is what drives the outbox retry-vs-`failed` decision above.
|
||||
@@ -0,0 +1,84 @@
|
||||
A member is one person's record in your space, stored in the `space_members` table. This page lists every member status and tier, the fields on the member record, and the settings that control who can see the directory. Manage members at [/members](/members); members edit their own record at [/me](/me).
|
||||
|
||||
## Statuses
|
||||
|
||||
`status` is the `member_status` enum. It defaults to `unverified`. It governs whether a member can act in the app and whether they hold their role's privileges.
|
||||
|
||||
| Status | Can use the app | Holds role privileges | Shown on |
|
||||
| --- | --- | --- | --- |
|
||||
| `current` | Yes | Yes | All members |
|
||||
| `late` | Yes | Yes | All members (dues lapsed) |
|
||||
| `unverified` | Yes | No — pending approval | Pending Approval tab |
|
||||
| `inactive` | No — blocked from all actions | No | Inactive tab |
|
||||
|
||||
Notes:
|
||||
|
||||
- The active set is `current`, `unverified`, and `late`. Only these three are loaded as a member's active membership; `inactive` members are blocked from every server action.
|
||||
- `late` is a dues state, not an authorization downgrade — a late member keeps their role and privileges.
|
||||
- `unverified` is the approval gate. In a `require_approval` space, a new member stays `unverified` and holds **no** privileged capability until an admin approves them, even if they redeemed a role-bearing invite. Approving sets `status` to `current` and `approved` to `true`.
|
||||
- The [/members](/members) page tabs are All, Payment Issues, Pending Approval (`unverified`), and Inactive. The All tab hides `inactive` members.
|
||||
|
||||
## Tiers
|
||||
|
||||
`tier` is the `member_tier` enum. It defaults to `basic` and records the membership level. It is set by an admin when adding or editing a member; it does not by itself grant permissions (roles do that).
|
||||
|
||||
| Tier |
|
||||
| --- |
|
||||
| `plus` |
|
||||
| `basic` |
|
||||
| `associate` |
|
||||
|
||||
## Record fields
|
||||
|
||||
Key columns on `space_members`. `user_id` is nullable on purpose: admins can add "offline" members who have no auth account.
|
||||
|
||||
| Field | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `id` | uuid | Primary key |
|
||||
| `space_id` | uuid | Owning space |
|
||||
| `user_id` | uuid, nullable | Auth account; null for offline members |
|
||||
| `role` | `member_role` | Default `member`; see roles |
|
||||
| `tier` | `member_tier` | Default `basic` |
|
||||
| `tier_id` | uuid, nullable | FK to `space_tiers`; canonical custom-tier reference |
|
||||
| `status` | `member_status` | Default `unverified` |
|
||||
| `approved` | boolean | Default `true`; set true on approval |
|
||||
| `display_name` | text | Self- or admin-editable |
|
||||
| `handle` | text | Self- or admin-editable |
|
||||
| `email` | text | Unique per space when present |
|
||||
| `phone` | text | |
|
||||
| `bio` | text | Self-editable |
|
||||
| `avatar_url` | text | |
|
||||
| `has_card_access` | boolean | Door access flag |
|
||||
| `payment_status` | text | Non-`current` value drives the Payment Issues tab |
|
||||
| `payment_note` | text | |
|
||||
| `last_payment_at` / `last_paid_at` | timestamptz | |
|
||||
| `dues_paid_until` | timestamptz | Dues coverage end |
|
||||
| `joined_at` | timestamptz | Sort key on [/members](/members) |
|
||||
| `stripe_customer_id` | text | |
|
||||
| `skills` / `interests` / `willing_to` / `affiliations` | text[] | Self-editable profile arrays |
|
||||
| `coi_last_disclosed_at` | timestamptz | Set when affiliations are disclosed |
|
||||
| `onboarding_completed_at` | timestamptz, nullable | Set when onboarding is finished or skipped |
|
||||
| `onboarding_progress` | jsonb | Default `{}`; tracks completed onboarding step IDs |
|
||||
| `created_at` / `updated_at` | timestamptz | |
|
||||
|
||||
Constraints: unique on `(space_id, user_id)`, and a partial unique index on `(space_id, email)` when `email` is present.
|
||||
|
||||
### Who edits what
|
||||
|
||||
- **Self** (at [/me](/me)): `display_name`, `handle`, `phone`, `bio`, `skills`, `interests`, `willing_to`, and `affiliations`. Changing affiliations re-runs conflict-of-interest disclosure and updates `coi_last_disclosed_at`.
|
||||
- **Admin** (at [/members](/members)): `email`, `display_name`, `phone`, `handle`, `role`, `tier`, `status`, `has_card_access`, `payment_status`, and `payment_note`. `joined_at` is only set when adding a member; `updateMember` does not accept it.
|
||||
|
||||
On the [/members](/members) page, some per-member panels are gated by capability rather than role: the access-cards panel (card UID) requires `door.manage`, the certifications panel requires `certifications.grant`, and the submitted-forms panel requires `forms.manage`.
|
||||
|
||||
## Directory visibility
|
||||
|
||||
Who can see the member directory is set per space by `member_directory_visibility` (the `directory_visibility` enum) under Member directory visibility in [/settings](/settings). It defaults to `members_visible`.
|
||||
|
||||
| Value | Who sees the directory |
|
||||
| --- | --- |
|
||||
| `members_visible` | Any authenticated member (default) |
|
||||
| `public_members_visible` | Any authenticated member; behaves identically to `members_visible` (there is no public, unauthenticated directory route) |
|
||||
| `board_only` | Elevated roles only (`admin`, `board`, `treasurer`) |
|
||||
| `member_count_visible` | Elevated roles only; treated identically to `board_only` — non-elevated members get the same restricted notice (no count is shown) |
|
||||
|
||||
When a member is not permitted to see the directory, [/members](/members) shows a "restricted in this space" notice instead of the table.
|
||||
@@ -0,0 +1,103 @@
|
||||
The platform is organized into twelve modules, each covering one area of running a space. This page lists every module, what it does, and the primary route where you use it. The same twelve are shown on the [landing page](/) under "What it does."
|
||||
|
||||
## Module list
|
||||
|
||||
| # | Module | What it does | Primary route |
|
||||
|---|--------|--------------|---------------|
|
||||
| 01 | Members | The member roster with dues status and contact info. Membership tiers, custom roles, and the per-space permissions matrix are configured at `/customize`. | `/members` |
|
||||
| 02 | Tasks and chores | One-off and recurring work with assignments, due dates, and area tags. | `/tasks` |
|
||||
| 03 | Projects | A board of what the space is building, with progress, areas, and who is on it. | `/projects` |
|
||||
| 04 | Payments | Import from PayPal, Venmo, or Zeffy and reconcile transactions to members. | `/payments` |
|
||||
| 05 | Ops and knowledge | Markdown procedures and knowledge base, area leads, and an encrypted secrets vault. | `/ops` |
|
||||
| 06 | Governance | Proposals with quorum and voting, incident reports, a versioned policy library, and a member forum. | `/proposals` |
|
||||
| 07 | Forms and waivers | A builder for any form or signable waiver, with immutable per-submission snapshots. | `/my-forms` |
|
||||
| 08 | Certifications | Define certifications and let instructors award or revoke them, with expiry tracking. | `/certifications` |
|
||||
| 09 | Classes | Schedule sessions, take signups with waitlists, mark attendance, and grant certifications. | `/classes` |
|
||||
| 10 | Equipment | A tool registry with reservations, optionally gated behind a required certification. | `/equipment` |
|
||||
| 11 | Access control | Associate member cards, connect a door controller, and keep an immutable access log. | `/doors` |
|
||||
| 12 | Onboarding and invites | A configurable onboarding flow and role-granting invite links with usage caps. | `/customize` |
|
||||
|
||||
## Where each module lives
|
||||
|
||||
Several modules span more than one screen. The primary route above is the main entry point; the routes below are the related management and detail screens.
|
||||
|
||||
### Members
|
||||
|
||||
- `/members` — the member roster, dues statuses, and contact info.
|
||||
- `/customize` — where membership tiers, custom roles, and the permissions matrix are configured (admin only; board can open `/customize` but these panels are read-only for board).
|
||||
- `/contacts` — non-member contacts.
|
||||
- `/me` — a member's own membership record ("My membership").
|
||||
- `/recruitment` — prospective-member pipeline (admin only).
|
||||
|
||||
### Tasks, projects, and ops
|
||||
|
||||
- `/tasks` — tasks and chores.
|
||||
- `/projects` — the project board.
|
||||
- `/ops` — ops and facilities docs, area leads, and the secrets vault. Individual docs open at `/ops/[id]`.
|
||||
|
||||
### Payments and finance
|
||||
|
||||
- `/payments` — transaction import and reconciliation.
|
||||
- `/financials` — financial reporting.
|
||||
- `/import` — import and sync (admin only).
|
||||
|
||||
See [Connect payments](/docs/how-to/connect-payments) and [Import members](/docs/how-to/import-members).
|
||||
|
||||
### Governance
|
||||
|
||||
- `/proposals` — proposals, with detail at `/proposals/[id]` and creation at `/proposals/new`.
|
||||
- `/incidents` — incident reports (`/incidents/new`, `/incidents/[id]`).
|
||||
- `/policies` — the policy library (`/policies/new`, `/policies/[slug]`).
|
||||
- `/forum` — the member forum (`/forum/new`, `/forum/[id]`).
|
||||
|
||||
See [Run a proposal](/docs/how-to/run-a-proposal).
|
||||
|
||||
### Forms and waivers
|
||||
|
||||
- `/my-forms` — forms available to you to fill out and sign.
|
||||
- `/forms` — the form and waiver builder (`/forms/new`, `/forms/[id]`), gated by the `forms.manage` permission.
|
||||
|
||||
See [Build a form](/docs/how-to/build-a-form).
|
||||
|
||||
### Classes, certifications, and equipment
|
||||
|
||||
- `/classes` — the class schedule; `/classes/manage` for scheduling and rosters.
|
||||
- `/certifications` — certification records, gated by `certifications.manage`.
|
||||
- `/equipment` — the tool registry and reservations; `/equipment/manage` for the registry.
|
||||
- `/attendance` — attendance records.
|
||||
|
||||
See [Your first class](/docs/tutorials/first-class) and [Equipment reservations](/docs/how-to/equipment-reservations).
|
||||
|
||||
### Access control
|
||||
|
||||
- `/doors` — the member-facing door and card view.
|
||||
- `/door/manage` — door connections, cards, and the access log (`door.manage`).
|
||||
- `/door/buttons` — API buttons for door actions.
|
||||
|
||||
See [Connect a door](/docs/how-to/connect-a-door).
|
||||
|
||||
### Onboarding and invites
|
||||
|
||||
- `/customize` — the onboarding flow builder and invite-link management (admin only).
|
||||
- `/settings` — space settings (admin only).
|
||||
|
||||
See [Configure onboarding](/docs/how-to/configure-onboarding) and [Invite links](/docs/how-to/invite-links).
|
||||
|
||||
## Communication
|
||||
|
||||
Alongside the twelve modules, `/comms` provides in-app messaging, and `/dashboard` is the landing screen after sign-in. Both appear in the sidebar's Workspace section.
|
||||
|
||||
## Admin-only modules
|
||||
|
||||
Some routes are visible only to the `admin` and `board` roles, or to members granted the matching `*.manage` permission:
|
||||
|
||||
| Route | Requires |
|
||||
|-------|----------|
|
||||
| `/customize`, `/settings`, `/import`, `/recruitment` | admin or board |
|
||||
| `/forms` | `forms.manage` |
|
||||
| `/certifications` | `certifications.manage` |
|
||||
| `/classes/manage` | `classes.manage` |
|
||||
| `/equipment/manage` | `equipment.manage` |
|
||||
| `/door/manage`, `/door/buttons` | `door.manage` |
|
||||
|
||||
Members without the role or permission do not see these links in the sidebar.
|
||||
@@ -0,0 +1,93 @@
|
||||
The payments and dues subsystem records money that comes into your space and maps it to members. This page is the authoritative reference for the payment enums, the advance-only dues rule, admin-configured pay-here links, and the Stripe billing fields. For the day-to-day workflow, see [/payments](/payments) and the member self-view at [/me](/me).
|
||||
|
||||
## Payment platforms
|
||||
|
||||
The `payment_platform` enum tags every payment and every pay-here link with its origin. Only these five values exist.
|
||||
|
||||
| Value | Meaning | Pay-here link | In-app checkout |
|
||||
| --- | --- | --- | --- |
|
||||
| `paypal` | PayPal | Yes | No |
|
||||
| `zeffy` | Zeffy | Yes | No |
|
||||
| `venmo` | Venmo | Yes | No |
|
||||
| `cash` | Cash logged by a treasurer | No | No |
|
||||
| `stripe` | In-app recurring dues integration | No | Yes |
|
||||
|
||||
The url-based subset — `paypal`, `zeffy`, `venmo` — is the only set allowed for external pay-here links (`DUES_LINK_PLATFORMS`). `cash` and `stripe` cannot be configured as external dues links.
|
||||
|
||||
## Link status
|
||||
|
||||
Every row in `payments` carries a `payment_link_status`, which records whether the payment has been matched to a member.
|
||||
|
||||
| Value | Meaning |
|
||||
| --- | --- |
|
||||
| `unlinked` | Not yet matched to a member (the default) |
|
||||
| `linked` | Matched to a member in this space |
|
||||
|
||||
Two columns hold this value: `link_status` is canonical, and `status` is kept for backward compatibility. Both default to `unlinked`. A cash payment logged with a member is inserted as `linked`; imported CSV rows and unmatched platform payments start `unlinked`. Linking via [/payments](/payments) sets `link_status` to `linked` and stamps `member_id`. The unlinked count drives the alert badge on the [/dashboard](/dashboard).
|
||||
|
||||
## Advance-only dues rule
|
||||
|
||||
When a linked payment updates a member's dues state, the state can only move forward in time. This is enforced by `resolveDuesAdvance`, which compares the member's existing `last_paid_at` against the incoming payment's `transaction_date`:
|
||||
|
||||
- If the incoming payment is the same as or newer than what is on file, `last_paid_at` advances to the incoming date and the member's `payment_status` is set to `current`.
|
||||
- If the incoming payment is older than what is on file, `last_paid_at` is left unchanged and `payment_status` is not touched.
|
||||
|
||||
A backdated or historical payment therefore never moves a member's dues state backward. Comparison is by instant, not string order, so timezone offsets and `Z`-suffixed timestamps never disagree. The rule runs on both cash logging and payment linking.
|
||||
|
||||
## Pay-here links (`dues_payment_methods`)
|
||||
|
||||
Admins configure external pay-here links so members can pay dues off-platform; a treasurer reconciles the payment manually later through the [/payments](/payments) flow (the platform tag pre-types that reconcile). Configuration is link-only — no automated payment record is created.
|
||||
|
||||
| Column | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `platform` | `payment_platform` | One of `paypal`, `zeffy`, `venmo`; unique per space |
|
||||
| `url` | text | Must be an absolute `https://` URL (DB CHECK `url ~* '^https://'`) |
|
||||
| `instructions` | text | Optional memo hint, e.g. "put your member name in the note" |
|
||||
| `is_active` | boolean | Only active links render to members; defaults to `true` |
|
||||
| `sort_order` | integer | Display order, `0`–`999`; defaults to `0` |
|
||||
|
||||
There is at most one link per `(space, platform)`; saving is an idempotent upsert on that pair. The [/me](/me) self-view shows only active links, in `sort_order`; the [/settings](/settings) admin UI shows every configured method, active and inactive.
|
||||
|
||||
### URL safety
|
||||
|
||||
Because `url` is rendered as a member-clickable anchor and admins can write the row directly through RLS, the `https://`-only rule is enforced at three layers: the DB CHECK constraint, the Zod schema (`isSafeDuesUrl`, max 500 chars), and the read boundary that feeds the clickable card. `http://`, `javascript:`, `data:`, and other non-https schemes are rejected everywhere.
|
||||
|
||||
### Permissions
|
||||
|
||||
| Operation | Roles |
|
||||
| --- | --- |
|
||||
| View active links ([/me](/me)) | Any space member |
|
||||
| View all links, create, update, delete ([/settings](/settings)) | `admin`, `board` |
|
||||
|
||||
## Stripe billing fields (`member_billing`)
|
||||
|
||||
Stripe recurring dues uses per-space own keys (not Connect). Each member with a Stripe subscription has one row in `member_billing`.
|
||||
|
||||
| Column | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `stripe_customer_id` | text | The member's Stripe customer |
|
||||
| `stripe_subscription_id` | text | The active subscription |
|
||||
| `subscription_status` | text | Raw Stripe subscription status |
|
||||
| `current_period_end` | timestamptz | End of the paid period; drives the grace window |
|
||||
|
||||
Two service-client paths write this table: the Stripe webhook, and `startDuesCheckout`, which upserts `stripe_customer_id` when it first creates the member's Stripe customer. SELECT is limited to `admin`, `board`, and `treasurer` via RLS. A member reads their own row through a validated service-client action ([/me](/me)). The admin dues view lists every member's billing status.
|
||||
|
||||
### Status mapping
|
||||
|
||||
Raw Stripe `subscription.status` maps to a member status. Dues lapse tops out at `late` — it never auto-sets `inactive`; an admin decides inactivation manually.
|
||||
|
||||
| Stripe status | Member status |
|
||||
| --- | --- |
|
||||
| `active`, `trialing` | `current` |
|
||||
| `past_due` (within grace) | `current` |
|
||||
| `past_due` (grace exceeded) | `late` |
|
||||
| `canceled`, `unpaid`, `incomplete`, `incomplete_expired` | `late` |
|
||||
|
||||
The grace window is `current_period_end` plus the configured `grace_days` (default `7`). Stripe settings — `mode` (`test`/`live`), publishable key, the tier-to-price map, `grace_days`, and the secret/webhook refs — live in `integrations.config`; the secret key and webhook signing secret are stored in the [secrets vault](/docs/reference/secrets-vault), not in `config`. See [/settings](/settings) to configure Stripe.
|
||||
|
||||
## Related pages
|
||||
|
||||
- [/payments](/payments) — log, import, link, and reconcile payments
|
||||
- [/me](/me) — member dues self-view, pay-here links, and Stripe checkout
|
||||
- [/settings](/settings) — configure Stripe and pay-here links
|
||||
- [/docs/reference/members-and-roles](/docs/reference/members-and-roles) — member statuses and roles
|
||||
@@ -0,0 +1,69 @@
|
||||
Every space controls who can do what through two layers: a fixed set of five built-in roles that drive database-level access, and an additive per-space permission matrix that grants named capabilities to roles. This page catalogs both, plus the status gate that decides when a role or permission actually takes effect.
|
||||
|
||||
## Built-in roles
|
||||
|
||||
There are exactly five roles, defined by the `member_role` enum and listed here in privilege order. Every member holds exactly one built-in role. Roles cannot be added or removed, but a space can rename any of them and change its color in [Customize](/customize) (see [Customize your space](/docs/how-to/customize-space)); the rename is display-only and does not change access.
|
||||
|
||||
| Role | Default label | Notes |
|
||||
|------|---------------|-------|
|
||||
| `admin` | Admin | Implicitly holds every permission and can never be locked out |
|
||||
| `board` | Board | Broad operational access; default holder of most permissions |
|
||||
| `treasurer` | Treasurer | Finance-focused |
|
||||
| `member` | Member | Standard member |
|
||||
| `associate` | Associate | Limited access |
|
||||
|
||||
Two role groups are used throughout the app: `ADMIN_ROLES` (`admin`, `board`) gate most administrative writes, and `TREASURER_ROLES` (`admin`, `board`, `treasurer`) gate financial sign-off.
|
||||
|
||||
## Status and privilege eligibility
|
||||
|
||||
Holding a role is not enough to use it. A member's `member_status` must be privilege-eligible before any role or permission resolves. This is enforced at both the app layer and the RLS layer (`user_has_role_in_space` and `user_has_permission` both require it).
|
||||
|
||||
| Status | Can act in app | Privilege-eligible |
|
||||
|--------|:-:|:-:|
|
||||
| `current` | Yes | Yes |
|
||||
| `late` | Yes | Yes |
|
||||
| `unverified` | Yes (own `/me` and onboarding only) | No |
|
||||
| `inactive` | No | No |
|
||||
|
||||
`unverified` is deliberately excluded: in a space that requires approval, a new member stays `unverified` until an admin approves them and must hold no privileged capability in that window, even if they redeemed a role-bearing [invite code](/docs/how-to/invite-links). A dues lapse moves a member `current` → `late`; that is a grace state, not an authorization downgrade, so `late` keeps its role.
|
||||
|
||||
## Permission catalog
|
||||
|
||||
Permissions are app-owned capability codes (the set is fixed by the product). Which roles hold which permissions is stored per space in `space_role_permissions`. The table below lists every code with its group and the built-in roles that hold it in a newly seeded space. `admin` holds all of them implicitly and is never stored.
|
||||
|
||||
| Code | Group | Capability | Default holders |
|
||||
|------|-------|------------|-----------------|
|
||||
| `ops.kb.read` | Ops | Read knowledge base | board, treasurer, member, associate |
|
||||
| `ops.kb.write` | Ops | Write knowledge base | board |
|
||||
| `ops.process.read` | Ops | Read processes | board, treasurer, member |
|
||||
| `ops.process.write` | Ops | Write processes | board |
|
||||
| `ops.secrets.read` | Ops | Reveal secrets | board |
|
||||
| `ops.secrets.write` | Ops | Manage secrets | board |
|
||||
| `ops.arealeads.manage` | Ops | Manage area leads | board |
|
||||
| `members.manage` | People | Manage members | board |
|
||||
| `payments.manage` | Finance | Manage payments | board, treasurer |
|
||||
| `governance.manage` | Governance | Manage proposals, incidents, policies | board |
|
||||
| `forum.moderate` | Community | Moderate forum and comments | board |
|
||||
| `forms.manage` | Community | Manage forms and waivers | board |
|
||||
| `certifications.manage` | Certifications | Manage certification types | board |
|
||||
| `certifications.grant` | Certifications | Award and revoke certifications | board |
|
||||
| `classes.manage` | Classes | Manage classes and schedule sessions | board |
|
||||
| `classes.instruct` | Classes | Run classes: attendance, completion, attendees | board |
|
||||
| `equipment.manage` | Equipment | Manage equipment and reservations | board |
|
||||
| `door.manage` | Access | Configure door integrations, buttons, and member cards | board |
|
||||
| `door.operate` | Access | Operate doors: open/lock, push/revoke cards | board |
|
||||
| `apicall.invoke` | Access | Use API-call buttons (non-door automations) | board |
|
||||
| `customize.manage` | Admin | Customize roles, tiers, areas, invites, onboarding | board |
|
||||
| `settings.manage` | Admin | Space settings, integrations, webhooks | board |
|
||||
|
||||
## Custom roles
|
||||
|
||||
A space can also create custom roles (in the Roles section of [Customize](/customize)). A custom role has a slug, name, description, and color. On its own it is display-only: creating one adds a label for committees, mentors, or area leads and grants nothing. However, a custom role's slug appears as a subject in the Permissions matrix, so an admin can grant it any capability from the catalog. Custom roles are created and edited by `admin`/`board`; only `admin` can delete one.
|
||||
|
||||
## How permissions resolve
|
||||
|
||||
`user_effective_roles(uid, sid)` returns a member's built-in role, the slugs of any custom roles assigned to them, and an `area_lead:<id>` marker for each area they lead. `user_has_permission(uid, sid, perm)` then answers true when the member is privilege-eligible and either holds the `admin` role (implicit-all shortcut) or one of their effective roles has the permission granted in `space_role_permissions`.
|
||||
|
||||
This layer is strictly additive. A grant can only widen what a role can do through the surfaces that consult permissions; it never overrides the row-level security that isolates one space's data from another. The Permissions matrix is editable by `admin` and `board` — both the `setRolePermissions` server action and the `space_role_permissions` RLS policies authorize the two roles. The Customize UI, however, currently exposes the matrix as view-only to `board` and enables editing for `admin` only; a board member can still change permissions through the server action directly.
|
||||
|
||||
See also: [Modules reference](/docs/reference/modules) and [Customize your space](/docs/how-to/customize-space).
|
||||
Reference in New Issue
Block a user