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,78 @@
|
||||
Build a custom form or a signable waiver, choose who can submit it, share it, and read the responses. This recipe covers the whole lifecycle from the form builder at [/forms](/forms) through published results.
|
||||
|
||||
This is an admin-side task. You need the `forms.manage` permission, which is granted to the `admin` and `board` roles by default. If the Forms screen is not available to you, ask an admin to grant it.
|
||||
|
||||
## Create the form
|
||||
|
||||
1. Go to [/forms](/forms) and choose **New form**. You land in the builder at [/forms/new](/forms/new).
|
||||
2. Set the **Type**:
|
||||
- **Form** — a plain form (surveys, interest lists, equipment requests).
|
||||
- **Waiver** — a form that requires the signer to agree to legal/consent text before submitting.
|
||||
3. Enter a **Title**. As you type, a **Link slug** is generated for you; edit it if you want. The slug is lowercase letters, digits, and hyphens (1–80 characters) and must be unique within your space. It becomes the public address `/f/<space>/<slug>`. The slug and the Type cannot change after creation, so pick them deliberately.
|
||||
4. Optionally add a **Description** shown above the fields.
|
||||
5. For a **Waiver**, fill in **Waiver / consent text**. This is the exact text the signer must agree to, and it is snapshotted into every signature (see [Immutable submissions](#immutable-submissions)).
|
||||
|
||||
## Add fields
|
||||
|
||||
In the **Fields** section, add one field per answer you want to collect. The available field types are:
|
||||
|
||||
| Builder button | Type | Collects |
|
||||
| --- | --- | --- |
|
||||
| Text | `short_text` | A single line (up to 2000 chars) |
|
||||
| Paragraph | `long_text` | Multi-line text (up to 20000 chars) |
|
||||
| Email | `email` | A validated email address |
|
||||
| Number | `number` | A numeric value |
|
||||
| Date | `date` | A calendar date |
|
||||
| Checkbox | `checkbox` | A yes/no box |
|
||||
| Dropdown | `select` | One choice from a list |
|
||||
| Choice | `radio` | One choice from a list |
|
||||
|
||||
For each field:
|
||||
|
||||
- Edit the label in place.
|
||||
- For **Dropdown** and **Choice**, add or remove options with **Add option**.
|
||||
- Toggle **Required** to force an answer. A required checkbox must be checked to submit.
|
||||
- Reorder with the up/down arrows, or remove a field with the trash icon.
|
||||
|
||||
The **Live preview** panel on the right renders the form exactly as a submitter sees it, including the "I agree to the waiver above" checkbox for waivers.
|
||||
|
||||
When you are done, choose **Create form**. The form is created as a **draft** and you land on its edit page.
|
||||
|
||||
## Set who can submit
|
||||
|
||||
The **Who can submit** setting controls visibility:
|
||||
|
||||
| Option | Stored value | Who can submit | Public link |
|
||||
| --- | --- | --- | --- |
|
||||
| Members only | `members` | Signed-in members of your space | No — filled inside the app |
|
||||
| Anyone signed in | `public_auth` | Any signed-in user | Yes |
|
||||
| Anyone (no account) | `public_anon` | Anyone, no account needed | Yes |
|
||||
|
||||
You can change visibility any time from the builder. Members-only forms are never served on the public page, so their fields stay private to your space.
|
||||
|
||||
## Publish and share
|
||||
|
||||
A draft is not visible to anyone. On the form's edit page, choose **Publish** to make it live and start accepting responses.
|
||||
|
||||
- For a public form (`public_auth` or `public_anon`), the builder shows the public URL with a **Copy link** button. The list at [/forms](/forms) also has a copy-link action for published public forms. Share that link however you like.
|
||||
- For a **Members only** form, there is no public link. Members submit it from inside the app.
|
||||
|
||||
Use **Close** to stop accepting responses (existing responses are kept), or **Back to draft** to hide it again. **Re-publish** a closed form to reopen it.
|
||||
|
||||
## Read the results
|
||||
|
||||
Open **Results** from [/forms](/forms) or go to `/forms/<id>/results`. You get a table of every response, newest first, with the submitter (member, email, or Anonymous) and each field answer. Use **Export CSV** to download all responses. You can delete a single response, or delete the whole form.
|
||||
|
||||
Deleting a form is permanent and cascades to every response, including signed waivers. Export first if you need a record.
|
||||
|
||||
## Immutable submissions
|
||||
|
||||
Every submission is an append-only snapshot. At submit time the platform stores a copy of the field schema, the waiver text, and the form version alongside the answers. Later edits to the form never alter past submissions, so a signed waiver stays valid against exactly what that signer saw. Submissions cannot be edited by anyone — only deleted.
|
||||
|
||||
Editing a published waiver's legal text or fields bumps its version. Existing signatures remain valid against their own snapshot; only new signers see the new version.
|
||||
|
||||
## Related
|
||||
|
||||
- [Forms](/forms) — manage your forms and waivers
|
||||
- [Members](/members) — see a member's linked form submissions
|
||||
- [Invite links](/docs/how-to/invite-links)
|
||||
@@ -0,0 +1,81 @@
|
||||
Onboarding is the short, ordered flow a new member walks through the first time they open the app. You edit it under the **Onboarding** section of [/customize](/customize), in the **New member onboarding** card. This page shows you how to add, reorder, require, disable, and delete steps.
|
||||
|
||||
## Who can edit onboarding
|
||||
|
||||
Editing onboarding is role-gated:
|
||||
|
||||
| Action | Required role |
|
||||
| --- | --- |
|
||||
| Add a step (Custom or Form) | admin |
|
||||
| Edit title, body, config, order, toggles | admin |
|
||||
| Delete a step | admin (built-in steps can never be deleted) |
|
||||
|
||||
Board members can open [/customize](/customize) and see the onboarding steps, but the panel is read-only for them: the **+ Custom step** / **+ Form step** buttons are hidden and every field, toggle, and Delete control is disabled. Members who are neither admin nor board are redirected away from /customize entirely.
|
||||
|
||||
## Understand the built-in steps
|
||||
|
||||
Every space is seeded with four built-in steps when it is created. They are marked **built-in** and cannot be deleted — only disabled or reordered.
|
||||
|
||||
| Step | Type | Seeded as | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Welcome to *space* | `welcome` | enabled, optional | Info screen, markdown body, never blocking |
|
||||
| Code of Conduct | `code_of_conduct` | enabled, required | Body plus a required acknowledgement checkbox |
|
||||
| Complete your profile | `profile` | enabled, optional | Prompts the member for display name, handle, bio, skills |
|
||||
| Set up your dues | `payment` | enabled, optional | Dues nudge with an optional payment link button |
|
||||
|
||||
## The step types
|
||||
|
||||
There are six step types. The four above are built-in; the two you can add yourself are `content` and `form`.
|
||||
|
||||
| Type | Body editable | Extra config | Deletable |
|
||||
| --- | --- | --- | --- |
|
||||
| `welcome` | yes | — | no (built-in) |
|
||||
| `code_of_conduct` | yes | `require_ack`, `ack_label` | no (built-in) |
|
||||
| `profile` | no | — | no (built-in) |
|
||||
| `payment` | yes | `payment_url` (link button) | no (built-in) |
|
||||
| `content` | yes | — | yes |
|
||||
| `form` | yes | `form_id` (published form) | yes |
|
||||
|
||||
Body fields accept markdown and a safe subset of HTML.
|
||||
|
||||
## Add a custom step
|
||||
|
||||
1. Go to [/customize](/customize) and open the **Onboarding** section.
|
||||
2. Click **+ Custom step**. A new `content` step is appended at the bottom, titled "New step".
|
||||
3. Edit the **title** field and the body textarea inline. Changes save when the field loses focus.
|
||||
|
||||
## Add a form or waiver step
|
||||
|
||||
Use this to make members sign a waiver or fill out a form as part of joining.
|
||||
|
||||
1. First create and **publish** the form under [/forms](/forms) (Forms & waivers). Only published forms can be linked.
|
||||
2. In the **Onboarding** section, click **+ Form step**.
|
||||
3. In the new step, use the **Select a form…** dropdown to pick the published form. Waivers are labelled `(waiver)`. The choice is stored as `form_id` on the step.
|
||||
|
||||
If there are no published forms yet, the step shows a prompt to create one first instead of a dropdown.
|
||||
|
||||
## Reorder steps
|
||||
|
||||
Each step row starts with a small number field — its `sort_order`. Type a new number and click away; steps re-sort by ascending order. Lower numbers run first. New steps are added with an order one higher than the current maximum.
|
||||
|
||||
## Toggle required and enabled
|
||||
|
||||
Each step has two checkboxes:
|
||||
|
||||
- **Enabled** — unchecked steps are hidden from the flow entirely.
|
||||
- **Required** — the member cannot finish onboarding until every *enabled and required* step is completed.
|
||||
|
||||
Edge cases worth knowing:
|
||||
|
||||
- A member can only **skip** onboarding when no enabled step is required. If any enabled required step exists, skip is refused.
|
||||
- A required `form` step is satisfied once a submission for the linked form exists for that member. If the linked form is missing or unpublished, the step is treated as non-blocking so a misconfiguration cannot lock members out.
|
||||
|
||||
## Delete a step
|
||||
|
||||
Only custom (`content`) and `form` steps show a **Delete** button, and only for admins. Built-in steps have no delete control — disable them instead. Deleting asks for confirmation and is permanent.
|
||||
|
||||
## Related
|
||||
|
||||
- [Onboard members](/docs/tutorials/onboard-members)
|
||||
- [Invite links](/docs/how-to/invite-links)
|
||||
- [Import members](/docs/how-to/import-members)
|
||||
@@ -0,0 +1,59 @@
|
||||
A door connection ties your space to a physical door controller so the platform can push member cards, actuate the door, and record every action. This recipe walks you from an empty screen to a working connection with cards assigned and live events flowing into the access log.
|
||||
|
||||
You need the `door.manage` permission (held by `admin` and `board` by default) to create and configure connections. Operating the door and assigning cards additionally needs `door.operate`.
|
||||
|
||||
## Before you start
|
||||
|
||||
Store the controller's shared password in your encrypted Secrets vault under [/ops](/ops) first. The password is never stored on the connection itself — the connection only references a vault entry by title, and the server decrypts it at call time. If your controller has no password, you can skip this.
|
||||
|
||||
## Add the connection
|
||||
|
||||
1. Open [/door/manage](/door/manage) (the "Door access" screen) and click **New connection**.
|
||||
2. Enter a **Name** (for example, `Front door`).
|
||||
3. Pick the **adapter**:
|
||||
- **HeatSync / 23b Open Source Access Control (native)** — the verified Arduino query-string firmware. Verb encoding is built in; you configure nothing else.
|
||||
- **Other / generic HTTP controller** — any controller you can drive over HTTP, using per-verb templates (see below).
|
||||
4. Choose the vault password from the second dropdown, or leave **No password (auth: none)**.
|
||||
5. Set the **Base URL** (for example, `http://192.168.1.50/`) and the **Pinned host** (for example, `192.168.1.50`).
|
||||
6. Click **Create connection**.
|
||||
|
||||
The app is cloud-hosted, so the target can be a publicly reachable controller or proxy, or a VPN-reachable device on your LAN. The server only ever calls the exact pinned host, never follows redirects, caps time and response size, and blocks cloud-metadata and link-local addresses regardless of what you pin.
|
||||
|
||||
### Generic controller verbs
|
||||
|
||||
For a generic connection, fill the request templates the controller expects. Each is appended to the base URL and sent as a `GET`. Placeholders are URL-encoded and substituted:
|
||||
|
||||
| Placeholder | Meaning |
|
||||
| --- | --- |
|
||||
| `{slot}` | the member's integer card slot |
|
||||
| `{tag}` | the card UID / tag value |
|
||||
| `{perm}` | permission level (default 1) |
|
||||
| `{door}` | door identifier, for open/lock verbs |
|
||||
| `{pw}` | the shared password from your vault (server-side only) |
|
||||
|
||||
Leave any verb blank if the controller does not support it. Example: `?m{slot}&p001&t{tag}&e={pw}`.
|
||||
|
||||
## Verify it
|
||||
|
||||
Click **Test** on the connection. This runs the read-only `status` verb only — it never opens the door — and writes one redacted row to the access log. A success toast shows the HTTP status.
|
||||
|
||||
## Assign member cards
|
||||
|
||||
With `door.operate`, click **Cards** in the Operate row. You get every active card in the space. Click **Grant** to write a card to the controller; the platform allocates the lowest free slot (HeatSync uses slots `0`–`200`) and shows it. Click **Revoke** to remove it. Slots are per connection, so the same card can hold different slots on different controllers, and re-granting is idempotent.
|
||||
|
||||
The Operate row also gives you **Open** (momentary), **Unlock** (hold open), and **Lock**.
|
||||
|
||||
## Read real events back in (optional)
|
||||
|
||||
To pull actual entry and denied events into the log, click **Inbound**:
|
||||
|
||||
1. Choose a **webhook secret** from your vault (distinct from the door password) and click **Turn inbound on**.
|
||||
2. For a generic controller, POST events to the shown webhook URL, `/api/door/inbound/<connection-id>`, with header `Authorization: Bearer <your webhook secret>` and a JSON body like `{ "events": [{ "id": "<stable-id>", "card_uid": "<hex>", "result": "granted" }] }`. The `id` dedupes retries.
|
||||
3. For a HeatSync connection, a once-a-minute poll reads the controller's log automatically once inbound is on — no webhook wiring needed.
|
||||
|
||||
Ingested events resolve each presented card to a member and appear in the **Access log** at the bottom of the page. That log is append-only and immutable: only the server writes it, secrets are redacted first, and rows survive even if you later delete the connection.
|
||||
|
||||
## Related
|
||||
|
||||
- Enable member self-entry per connection with the **Self-entry on** toggle (elevated physical-security risk; off by default).
|
||||
- Define custom HTTP actions at [/door/buttons](/door/buttons) ("API buttons"), which members invoke from [/doors](/doors).
|
||||
@@ -0,0 +1,57 @@
|
||||
Dues that arrive off-platform — PayPal, Venmo, Zeffy, or cash handed to the treasurer — land in the [/payments](/payments) ledger as transactions, where you match each one to the member who paid. This recipe covers getting those transactions in and reconciling them by hand.
|
||||
|
||||
If you want dues that reconcile themselves, set up recurring Stripe billing instead — see [Dues end to end](/docs/tutorials/dues-end-to-end). This page is for everything that is *not* an automatic Stripe subscription.
|
||||
|
||||
## Roles you need
|
||||
|
||||
| Task | Where | Required role |
|
||||
| --- | --- | --- |
|
||||
| Connect a platform, add pay-here links | [/settings](/settings) | `admin` |
|
||||
| Log cash, import CSV, sync, link a payment | [/payments](/payments), [/import](/import) | `admin`, `board`, `treasurer` |
|
||||
|
||||
## Add a pay-here link (optional)
|
||||
|
||||
Give members a button to pay you directly. In [/settings](/settings) open the **Dues** tab, find **Other ways to pay dues**, and add a link per platform:
|
||||
|
||||
1. Paste an `https://` payment URL for **PayPal**, **Zeffy**, or **Venmo**.
|
||||
2. Add optional instructions, e.g. "put your member name in the note."
|
||||
3. Leave the toggle on **shown** and click **Save**.
|
||||
|
||||
Members see active links on their [/me](/me) page. These links do not record anything — money paid through them still has to be reconciled in [/payments](/payments) later.
|
||||
|
||||
## Connect PayPal to sync transactions
|
||||
|
||||
To pull PayPal transactions automatically, connect the API credentials once. In [/settings](/settings), open the **Integrations** tab, click **Connect** on **PayPal**, and enter your Client ID, Client Secret, and mode (`sandbox` or `live`). Zeffy and Venmo have their own credential fields on the same tab.
|
||||
|
||||
Once PayPal shows **LIVE** on the [/payments](/payments) summary card, a **Sync Now** button appears. Click it to import incoming transactions as **unlinked** payments. The sync deduplicates on the PayPal transaction id, so re-syncing will not create duplicates.
|
||||
|
||||
## Import transactions from a CSV
|
||||
|
||||
For any platform, upload a spreadsheet export at [/import](/import):
|
||||
|
||||
1. Choose **payments** as the import type.
|
||||
2. Drop your CSV file. Columns auto-map by header name.
|
||||
3. Confirm the mapping. Required fields: **Amount**, **Sender / From Name**, **Date**. Optional: **Platform** (`venmo`/`paypal`/`cash`) and **Note / Memo**.
|
||||
4. Click **Preview Import**, then **Import**.
|
||||
|
||||
Rows with a bad amount, date, or platform are skipped and counted in the results — they are not silently dropped. Imported rows land **unlinked**.
|
||||
|
||||
## Log a cash payment
|
||||
|
||||
For cash handed over in person, click **Log Cash** on [/payments](/payments). Enter the amount, a note (e.g. "John Smith — March dues"), an optional date, and optionally pick the member from **Link to Member** to link it immediately.
|
||||
|
||||
## Reconcile: match payments to members
|
||||
|
||||
New transactions arrive **unlinked**; the header shows an `N unlinked` count. To clear them:
|
||||
|
||||
1. Use the **Platform** and **Status** filters to narrow to **Unlinked**.
|
||||
2. For each row, read the **From / Note** column. Synced PayPal rows carry the payer's email there, which you can compare against the member emails shown in the picker.
|
||||
3. Click **+ Link member**, then choose the member. The picker lists each member's display name and email.
|
||||
|
||||
Linking sets the payment to **linked** and advances that member's dues. Reconciliation is *advance-only*: linking a payment moves the member's last-paid date forward and marks them `current` only if it is their most recent payment. A backdated payment older than what is already on file will not push a member's status backward.
|
||||
|
||||
### Notes
|
||||
|
||||
- Linking is one payment to one member; there is no bulk or automatic email match. Work down the unlinked list by hand.
|
||||
- Only `current` and `late` members appear in the picker.
|
||||
- The ledger shows the 100 most recent transactions across `paypal`, `zeffy`, `venmo`, and `cash`.
|
||||
@@ -0,0 +1,83 @@
|
||||
The [/customize](/customize) screen is where you bend the platform's built-in vocabulary to fit your space. From one place you can rename and recolor the five built-in roles, tune the per-space permission matrix, add display-only custom roles, define membership tiers, and set up shop areas and area leads.
|
||||
|
||||
Operational settings (space identity, integrations, webhooks) live under [Settings](/docs/how-to/space-settings), not here.
|
||||
|
||||
## Before you start
|
||||
|
||||
You need the **board** or **admin** role to open [/customize](/customize) at all. Most panels let board members read the current configuration, but the changes that alter authorization — editing the permission matrix, creating roles, changing tiers, assigning area leads — are **admin-only**. When you are a board member, those controls render as view-only.
|
||||
|
||||
Open [/customize](/customize) and use the left-hand nav to move between panels: Roles, Permissions, Membership tiers, Areas, Area leads, Invite codes, and Onboarding. This page covers the first five.
|
||||
|
||||
## Rename and recolor the built-in roles
|
||||
|
||||
The five roles — `admin`, `board`, `treasurer`, `member`, `associate` — are fixed and drive every permission check. You cannot add or remove them, but you can change how they are displayed everywhere in the app.
|
||||
|
||||
1. Go to the **Roles** panel.
|
||||
2. Under **Built-in roles**, each row shows the fixed role code, an editable display name, a badge color, and an optional short description.
|
||||
3. Edit the name, pick a color, or type a description. Each field saves when it loses focus (on blur); you do not submit a form.
|
||||
|
||||
The new label appears anywhere the role is shown. The underlying role code never changes, so permissions and RLS are unaffected.
|
||||
|
||||
## Add a custom role
|
||||
|
||||
Custom roles are extra labels for org structure — committees, mentors, working groups. They are **display-only and do not grant any permissions** on their own. (They can, however, appear as subjects in the permission matrix; see below.)
|
||||
|
||||
1. In the **Roles** panel, under **Custom roles**, choose **+ Custom role** (admin only).
|
||||
2. Enter a `slug` (lowercase, kebab/underscore), a display name, a color, and an optional description.
|
||||
3. Choose **Create**. Existing rows are edited in place on blur, and **Delete** removes a role permanently.
|
||||
|
||||
## Edit the permission matrix
|
||||
|
||||
The **Permissions** panel is a grid: capabilities down the side, role subjects across the top. Checking a box grants that permission to that role for this space.
|
||||
|
||||
- **`admin` is never listed** — it implicitly holds every permission and cannot be locked out.
|
||||
- Subjects are the built-in roles (except admin) plus any custom roles you created.
|
||||
- Toggles save immediately per subject; a "…" indicator shows while saving, and a failed save reverts.
|
||||
|
||||
Permissions are **additive**. A grant only widens what a role can do through the surfaces that consult permissions; it never overrides the database tenant isolation (RLS) that keeps spaces separate. The available capabilities include, among others:
|
||||
|
||||
| Code | What it allows |
|
||||
| --- | --- |
|
||||
| `members.manage` | Manage members |
|
||||
| `payments.manage` | Manage payments |
|
||||
| `governance.manage` | Manage proposals, incidents, policies |
|
||||
| `ops.kb.write` | Write knowledge base |
|
||||
| `ops.secrets.read` | Reveal secrets |
|
||||
| `classes.instruct` | Run classes: attendance, completion |
|
||||
| `door.operate` | Open/lock doors, push/revoke cards |
|
||||
| `customize.manage` | Customize roles, tiers, areas, invites, onboarding |
|
||||
|
||||
The full catalog is grouped by area (Ops, People, Finance, Governance, Community, Certifications, Classes, Equipment, Access, Admin) in the panel itself.
|
||||
|
||||
## Define membership tiers
|
||||
|
||||
Every space is seeded with three built-in tiers: **Plus**, **Basic**, and **Associate**. Tiers set the plans a member can be placed on (see [members](/members)).
|
||||
|
||||
1. Go to the **Membership tiers** panel.
|
||||
2. Choose **+ New tier**, then set a `slug`, display name, price in USD, and a billing cadence: `monthly`, `quarterly`, `annual`, `one_time`, or `custom`.
|
||||
3. Choose **Create tier**. For any tier, edit the price inline (saves on blur).
|
||||
|
||||
Built-in tiers are marked **built-in** and can be **archived** (hidden) but not deleted. Custom tiers can be deleted outright.
|
||||
|
||||
## Set up areas and area leads
|
||||
|
||||
Areas tag tasks, projects, and [knowledge base](/docs/reference/knowledge-base) entries by shop space. New spaces come seeded with areas like `woodshop`, `electronics`, `laser`, and `3d-printing`.
|
||||
|
||||
To manage areas:
|
||||
|
||||
1. Open the **Areas** panel and choose **+ New area**.
|
||||
2. Enter a `code`, a display name, and an optional emoji icon.
|
||||
3. Reorder with the sort-order field, **Archive** to hide an area from pickers without losing history, or **Delete** to remove it.
|
||||
|
||||
To manage area leads:
|
||||
|
||||
1. Open the **Area leads** panel. Each row is an **area-lead role**; unfilled roles show as **Vacant**.
|
||||
2. Choose **+ Area-lead role**, give it an area code and a role name (e.g. `woodshop` / "Woodshop Lead").
|
||||
3. Use the per-row dropdown to assign a current member, or set it back to **Vacant** to unassign.
|
||||
|
||||
The assigned member effectively holds that area-lead role, so any Ops item whose access list includes it becomes available to them. You can also assign leads from the member directory.
|
||||
|
||||
## Related
|
||||
|
||||
- [Invite codes](/docs/how-to/invite-links) and [Onboarding](/docs/how-to/configure-onboarding) — the remaining Customize panels.
|
||||
- [Members](/members) — where roles and tiers are applied to people.
|
||||
@@ -0,0 +1,64 @@
|
||||
Register your tools and machines, let members book time on them without double-booking, and optionally require a certification before a tool can be reserved. This recipe covers the manager side (the registry) and the member side (browsing and reserving).
|
||||
|
||||
## Before you start
|
||||
|
||||
Managing the registry requires the `equipment.manage` permission. By default the **board** role holds it; a space can grant it to other roles under [/customize](/customize). Reserving a tool needs no special permission — any member in good standing (status `current`, `unverified`, or `late`) can book from the catalog.
|
||||
|
||||
If you plan to gate a tool behind a certification, create that certification first at [/certifications](/certifications) so it appears in the requirement dropdown.
|
||||
|
||||
## Register a piece of equipment
|
||||
|
||||
1. Go to [/equipment/manage](/equipment/manage) and choose **New equipment**.
|
||||
2. Give it a **Name** (required, up to 200 characters, e.g. `Laser Cutter #1`).
|
||||
3. Optionally fill in **Description**, **Location**, and **Asset tag**.
|
||||
4. Set the operational **status**. New items default to **Available**.
|
||||
5. Optionally pick a required certification (see below), then choose **Add equipment**.
|
||||
|
||||
The item appears immediately in the manager list and, once it is available, in the member-facing catalog at [/equipment](/equipment).
|
||||
|
||||
### Equipment status
|
||||
|
||||
Status controls whether a tool can be booked at all. Only `available` equipment accepts reservations.
|
||||
|
||||
| Status | Label | Reservable? |
|
||||
| --- | --- | --- |
|
||||
| `available` | Available | Yes |
|
||||
| `maintenance` | Under maintenance | No |
|
||||
| `retired` | Retired | No |
|
||||
|
||||
To take a machine offline temporarily, edit it and switch the status to **Under maintenance**; switch it back to **Available** when repairs are done. Use **Retired** for equipment that is permanently out of service.
|
||||
|
||||
## Gate a tool behind a certification
|
||||
|
||||
To require that a member hold a certification before they can reserve or use a tool:
|
||||
|
||||
1. Edit the equipment (or set this while creating it) on [/equipment/manage](/equipment/manage).
|
||||
2. In the certification dropdown, choose **Requires: `<certification>`** instead of **No required certification**.
|
||||
3. Save.
|
||||
|
||||
The certification must belong to your space or the save is rejected. Once set, members who do not hold an active certification see a **Requires `<name>`** badge on [/equipment](/equipment) and the **Reserve** button is disabled for them. Members who hold it see a **Certified: `<name>`** badge and can book normally.
|
||||
|
||||
A revoked or expired certification does not count as active, so it will not satisfy the gate. An `equipment.manage` holder can override the certification gate — including booking on another member's behalf — but the override never bypasses an operational status block (a `maintenance` or `retired` tool still cannot be reserved).
|
||||
|
||||
## Reserve a tool (member view)
|
||||
|
||||
1. From [/equipment](/equipment), find the tool and choose **Reserve**.
|
||||
2. Pick a **Starts** and **Ends** time. The end must be after the start, and the start cannot be in the past.
|
||||
3. Optionally add **Notes**, then choose **Confirm reservation**.
|
||||
|
||||
Members can review their own bookings on [/me](/me). A member can cancel their own reservation; an `equipment.manage` holder can cancel anyone's.
|
||||
|
||||
## How overlaps are prevented
|
||||
|
||||
Two reservations for the same tool cannot overlap. Time windows are half-open, so a booking that ends exactly when the next one begins is allowed (they touch but do not conflict). Only active (`reserved`) reservations block a slot — `cancelled` and `completed` ones never do.
|
||||
|
||||
This is enforced two ways: the action checks for conflicts before inserting, and a database exclusion constraint (`equipment_reservations_no_overlap`) is the final arbiter, so two people booking the same slot at the same instant cannot both succeed. The losing request sees `That time overlaps an existing reservation.`
|
||||
|
||||
## Edit, archive, or delete
|
||||
|
||||
On [/equipment/manage](/equipment/manage) each item offers:
|
||||
|
||||
- **Reservations** — view the booking list for that tool.
|
||||
- **Edit** — change any field, including status and the required certification.
|
||||
- **Archive** / **Restore** — archived items are hidden from the member catalog but keep their history. Use this instead of deleting once a tool has bookings.
|
||||
- **Delete** — permanently removes the item. This is blocked if the tool has any reservations; archive it instead.
|
||||
@@ -0,0 +1,71 @@
|
||||
Incidents are how your space records code-of-conduct complaints, safety reports, and conflicts, then moves each one from a report to a decision. Any member can file; admins and board members triage. This recipe walks the whole path, including the anonymous option and the appeal-to-a-proposal exit.
|
||||
|
||||
## Before you start
|
||||
|
||||
- **File a report:** any member of the space.
|
||||
- **Change status, record a disposition, scope update visibility, appeal:** `admin` or `board` role.
|
||||
- A reporter always sees their own incident and its non-board updates, even without a privileged role.
|
||||
|
||||
## File a report
|
||||
|
||||
1. Go to [/incidents](/incidents) and click **File a report** (or open [/incidents/new](/incidents/new) directly).
|
||||
2. Enter a **Title** (short summary, up to 200 characters) and describe **What happened** in the body — when, where, who was present, what was said or done.
|
||||
3. Set **Category** (free text, e.g. `general`, `safety`, `harassment`, `theft`) and **Severity**.
|
||||
4. Optionally check any **Subjects** — members the report concerns. Subjects are stored on the record but are not notified and do not gain read access by default.
|
||||
5. Optionally check **File anonymously** (see below).
|
||||
6. Click **File report**. A named report opens at its detail page; the incident starts in status `received`.
|
||||
|
||||
Severity is one of:
|
||||
|
||||
| Value | |
|
||||
|---|---|
|
||||
| `low` | |
|
||||
| `medium` | default |
|
||||
| `high` | |
|
||||
| `critical` | |
|
||||
|
||||
## File anonymously
|
||||
|
||||
Checking **File anonymously** omits your name from the record and the activity log. In exchange you get a one-time **tracking token** shown on screen.
|
||||
|
||||
- The token is the only way to look the report up later — it is never emailed or displayed again. Save it immediately.
|
||||
- Check status any time at [/track](/track) by entering the token. That view shows the report, its status timeline, a disposition once one exists, and updates that are not board-only.
|
||||
- This is not cryptographic anonymity: an admin with direct database access can still see the row exists.
|
||||
|
||||
## Triage and move the incident
|
||||
|
||||
On the incident detail page (`/incidents/[id]`), admins and board members get an **Update status** control. Statuses:
|
||||
|
||||
| Status | Meaning | Timestamp stamped |
|
||||
|---|---|---|
|
||||
| `received` | Filed, not yet acknowledged | — |
|
||||
| `under_review` | Acknowledged, being handled | `acknowledged_at` |
|
||||
| `decided` | A disposition has been reached | `decided_at` |
|
||||
| `appealed` | Reporter has appealed the decision | — |
|
||||
| `closed` | Resolved and archived | `closed_at` |
|
||||
|
||||
When you select `decided` or `closed`, a **Disposition / reasoning** box appears. What you write there is visible to the reporter and is surfaced on the public tracking view. Deciding also records you in the incident's decision-makers.
|
||||
|
||||
## Post updates with scoped visibility
|
||||
|
||||
Use **Post an update** to add information, ask a question, or share a decision. Admins and board members choose a visibility for each note; reporters post at `all_parties`.
|
||||
|
||||
| Visibility | Who can read it |
|
||||
|---|---|
|
||||
| `reporter_only` | Admin/board and the reporter |
|
||||
| `all_parties` | Admin/board and the reporter |
|
||||
| `board_only` | Admin/board only — hidden from the reporter and the tracking view |
|
||||
|
||||
Each incident also has a discussion thread at the bottom of the page for members who can see the record.
|
||||
|
||||
## Appeal a decision
|
||||
|
||||
Once an incident is `decided`, the reporter sees a **Request membership appeal** form. Filing an appeal:
|
||||
|
||||
1. Creates a **draft** proposal of type `membership_vote` with a simple-majority threshold, linked back to the incident.
|
||||
2. Flips the incident to `appealed`.
|
||||
3. The action itself requires `admin` or `board` access.
|
||||
|
||||
The proposal opens as a draft so the language can be refined before voting — you still open it for voting yourself. From then on it behaves like any other proposal; see [Run a proposal](/docs/how-to/run-a-proposal). A link to the appeal proposal appears on the incident under **Appeal**.
|
||||
|
||||
Incidents are never deleted — the platform has no delete path, so the record is retained for the space's history.
|
||||
@@ -0,0 +1,63 @@
|
||||
Bring an existing roster into your space in one pass. The [/import](/import) screen (titled **Import & Sync**) reads a CSV, lets you map its columns to member fields, previews the result, and upserts everyone into [/members](/members) with a tier and a status.
|
||||
|
||||
## Before you start
|
||||
|
||||
- **Role required:** the Import & Sync screen opens for **admin**, **board**, and **treasurer**. The member import itself is gated tighter — the server action accepts only **admin** or **board**. A treasurer who runs a member import gets `Admin access required`.
|
||||
- **File format:** a `.csv` (or `.txt`) file. There is no paste box — you upload or drag-and-drop a file. Quoted fields and embedded commas are handled.
|
||||
- **Row limit:** 5000 members per import.
|
||||
- One row per member; the first row must be a header row.
|
||||
|
||||
## Columns
|
||||
|
||||
Only two columns are required. Header names are auto-detected on upload, so sensible names map themselves; you can override any mapping in the next step.
|
||||
|
||||
| App field | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| Display Name | Yes | Non-empty, max 100 chars |
|
||||
| Email | Yes | Must be a valid email; lowercased on import; the dedup key |
|
||||
| Phone | No | Max 20 chars |
|
||||
| Tier / Membership Type | No | Normalized to `plus`, `basic`, or `associate` (see below); defaults to `basic` |
|
||||
| Join Date | No | Any parseable date; defaults to the import time if blank |
|
||||
| Last Paid Date | No | Any parseable date |
|
||||
| Card Access | No | `yes`/`true`/`1`/`y` becomes true; anything else false |
|
||||
|
||||
The mapping dropdown also offers **Handle / Username**, but that column is not written to the member record — leave it on **Skip this column** or don't rely on it.
|
||||
|
||||
Tier values are matched loosely and collapsed to the three real tiers:
|
||||
|
||||
| Your value | Stored tier |
|
||||
| --- | --- |
|
||||
| `plus`, `premium`, `full` | `plus` |
|
||||
| `basic`, `member`, `standard` | `basic` |
|
||||
| `associate`, `visitor`, `guest` | `associate` |
|
||||
| anything else | `basic` |
|
||||
|
||||
## Steps
|
||||
|
||||
1. Go to [/import](/import). Leave the **Import type** toggle on **members**.
|
||||
2. Drop your CSV onto the upload area, or click to browse. The wizard advances to **Map Columns** and shows the row count.
|
||||
3. Check each column's mapping. Your header sits on the left with a sample value; pick the matching app field on the right, or **Skip this column**. Required fields are marked with `*`. If **Display Name** or **Email** is unmapped, a warning lists them and **Preview Import** stays disabled.
|
||||
4. Click **Preview Import**. Review the first 5 rows as they will land, then click **Import _N_ rows**.
|
||||
5. On the results screen, read how many imported and how many failed. Up to 10 per-row errors are listed. Click **Import Another File** to start over.
|
||||
|
||||
## What each imported member gets
|
||||
|
||||
Every valid row is upserted into `space_members` with these fixed defaults:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| `role` | `member` |
|
||||
| `status` | `current` |
|
||||
| `approved` | `true` |
|
||||
| `user_id` | left empty (an offline member with no login) |
|
||||
|
||||
Imported members start as **current** and unlinked to any login account. They link a login later by signing up with the same email or being invited.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Re-importing updates, not duplicates.** Rows upsert on `(space_id, email)`. Importing an email that already exists in your space overwrites that member's mapped fields — a safe way to bulk-update. Rows with no matching email are inserted new.
|
||||
- **Bad rows are skipped, not fatal.** Each row is validated individually (invalid email, missing name, unknown tier, or unparseable date). Skipped rows are counted and reported; the rest still import.
|
||||
- **A row missing name or email** is dropped before the server call and reported as `Row N: missing name or email` (N is the spreadsheet row).
|
||||
- **Card access is granted in the record only.** Importing `has_card_access` marks the flag; it does not program a physical door card or card reader.
|
||||
|
||||
Importing payments instead? The same [/import](/import) screen has a **payments** mode with its own required columns (amount, sender, date).
|
||||
@@ -0,0 +1,51 @@
|
||||
Invite codes let new people join your space through a shareable code or one-click link. Each code can grant a specific role, cap how many times it is used, and expire on a date you choose — so you can hand out a permanent public code and a single-use admin link from the same screen.
|
||||
|
||||
## Before you start
|
||||
|
||||
- You need the **admin** role. In the UI, creating, editing, disabling, and deleting invites is admin-only. A **board** member can open the **Invite codes** panel — it is visible to admin and board — but sees only the read-only **Copy code** and **Copy link** buttons; the **+ New invite**, **Disable/Enable**, and **Delete** controls are hidden. (The underlying `createInvite` server action also permits board, but the panel does not expose it.)
|
||||
- As the admin, you can grant any role with an invite, including **admin**.
|
||||
- Invite codes live in the **Invite codes** panel on the [`/customize`](/customize) screen.
|
||||
|
||||
## Create an invite code
|
||||
|
||||
1. Go to [`/customize`](/customize) and find the **Invite codes** panel.
|
||||
2. Click **+ New invite**.
|
||||
3. Fill in the fields (all optional except the granted role, which defaults to **member**):
|
||||
|
||||
| Field | What it does |
|
||||
| --- | --- |
|
||||
| Code | The code people redeem. Leave blank to auto-generate an 8-character code. Typed codes are uppercased and limited to `A–Z`, `0–9`, and `-`, 4–32 characters, and must be unique across all spaces. |
|
||||
| Label | A private note to remember what the code is for (for example, "Open house 2026"). |
|
||||
| Expiry | A date and time after which the code stops working. Leave blank for no expiry. |
|
||||
| Max uses | How many times the code may be redeemed. Leave blank for unlimited. Must be a positive number. |
|
||||
| Grants | The role a member receives when they join with this code. The dropdown only lists roles you are allowed to assign. |
|
||||
| Single use | Sets max uses to 1 and auto-disables the code after one join. |
|
||||
|
||||
4. Click **Create invite**. The new code appears at the top of the list with a `grants <role>` badge.
|
||||
|
||||
## Share the code or link
|
||||
|
||||
Each row has two buttons:
|
||||
|
||||
- **Copy code** — copies the raw code (for example, `K7P2R9QT`) to paste into a message.
|
||||
- **Copy link** — copies a one-click join link in the form:
|
||||
|
||||
```
|
||||
https://your-space.example/join/<space-slug>?code=<CODE>
|
||||
```
|
||||
|
||||
The link lands new people on a space-scoped join page that carries the code into account creation. Anyone who redeems it becomes a member at the role the invite grants.
|
||||
|
||||
## Revoke or remove a code
|
||||
|
||||
These controls are admin-only.
|
||||
|
||||
- **Disable** turns the code off without deleting it. Redemptions stop immediately; you can **Enable** it again later. This is the quickest way to revoke a leaked or expired code.
|
||||
- **Delete** removes the code permanently after a confirmation prompt.
|
||||
|
||||
A code also stops working on its own once it is disabled, past its expiry, or at its use cap. The list flags these with `disabled`, `expired`, and `at cap` markers, and each row shows its usage count (for example, `uses 3 / 10`).
|
||||
|
||||
## Notes and edge cases
|
||||
|
||||
- Disabling and deleting affect only future joins. Members who already joined keep their role; change it from [`/members`](/members).
|
||||
- If your space requires approval, new joiners still land as unverified pending an admin's review. See [`/docs/reference/roles`](/docs/reference/roles) for what each granted role can do.
|
||||
@@ -0,0 +1,64 @@
|
||||
Policies are your space's versioned governance documents: bylaws, code of conduct, station rules. Each policy has a stable slug and a full version history, and only one version at a time is active. This recipe walks you through drafting a policy, publishing it, and later superseding it with a new version.
|
||||
|
||||
You need the `admin` or `board` role. Every member can read policies at [/policies](/policies), but the `New policy` button and the management controls only appear for admins and board.
|
||||
|
||||
## Draft a new policy
|
||||
|
||||
1. Go to [/policies](/policies) and click `New policy` (top right). This opens [/policies/new](/policies/new).
|
||||
2. Fill in the fields:
|
||||
|
||||
| Field | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| Slug | Yes | Lowercase letters, numbers, and hyphens only; max 80 characters (e.g. `bylaws`, `code-of-conduct`). This is the stable identifier that carries across every version. |
|
||||
| Section reference | No | An optional citation label such as `Article III §2`, shown next to the version. |
|
||||
| Title | Yes | Human-readable name, max 200 characters. |
|
||||
| Plain-language summary | No | A short, readable gloss of the formal text. Rendered as Markdown. |
|
||||
| Formal text | Yes in practice | The authoritative body. Rendered as Markdown. |
|
||||
|
||||
3. Click `Save as draft v1`. The policy is created at `version` 1 with status `draft` and you land on its detail page at `/policies/<slug>`.
|
||||
|
||||
A draft is not yet in force. It has no `effective_at` date and does not count as the space's governing text until you activate it.
|
||||
|
||||
The slug must be unique within your space. If you try to create a second policy with a slug that already exists, you get an error telling you to supersede the existing one instead — that is how new versions are made.
|
||||
|
||||
## Publish (activate) the policy
|
||||
|
||||
1. Open the policy at `/policies/<slug>`.
|
||||
2. In the `Manage` panel, click `Activate v1`.
|
||||
|
||||
Activating does two things atomically:
|
||||
|
||||
- Sets the policy's status to `active` and stamps `effective_at` with the current time.
|
||||
- Marks any previously `active` version of the same slug as `superseded`, so exactly one version is ever active.
|
||||
|
||||
The detail page's primary view always shows the active version (falling back to the latest version if none is active), and the status badge on [/policies](/policies) reflects it.
|
||||
|
||||
## Supersede with a new version
|
||||
|
||||
When the text needs to change, you do not edit the active version in place — you publish a new version and swap it in.
|
||||
|
||||
1. Open the active policy and click `Supersede with new version` in the `Manage` panel.
|
||||
2. The formal text and plain-language fields open pre-filled with the current version's content. Edit them.
|
||||
3. Click `Create draft v<n+1>`. This inserts a new row that keeps the same slug, increments `version`, records `prior_version_id` pointing at the version you superseded, and starts at status `draft`.
|
||||
4. Review the new draft, then click `Activate v<n+1>`. Activation flips the new version to `active` and marks the prior active version as `superseded` in the same step.
|
||||
|
||||
The full lineage stays visible in the `Version history` section of the detail page, newest first, with each version's status and creation date. Nothing is ever deleted.
|
||||
|
||||
## Other status transitions
|
||||
|
||||
The `Manage` panel also exposes the remaining statuses. Policy status is one of:
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `draft` | Written but not in force; no effective date. |
|
||||
| `active` | The current governing version. At most one per slug. |
|
||||
| `deprecated` | Retired without a replacement (e.g. a rule you no longer enforce). Use the `Deprecate` button on an active policy. |
|
||||
| `superseded` | Replaced by a newer version. Set automatically when a newer version is activated. |
|
||||
|
||||
You can also set `draft`, `deprecated`, or `superseded` directly with the `Set <status>` buttons, but the automatic transitions during activation handle the common case.
|
||||
|
||||
## Notes
|
||||
|
||||
- Every action here requires the `admin` or `board` role; the underlying create, supersede, and status actions reject anyone else.
|
||||
- A version can record the proposal that adopted it; when set, the version history links straight to that proposal. See [Run a proposal](/docs/how-to/run-a-proposal) for putting a bylaw change to a vote before you activate it.
|
||||
- Both body fields render Markdown, so headings, lists, and emphasis display formatted on the detail page.
|
||||
@@ -0,0 +1,60 @@
|
||||
Proposals give your space an auditable, async way to decide things: a member drafts a question, opens it for voting, members record a position, and the app computes quorum and pass/fail for you. This recipe walks the full lifecycle from draft to decided.
|
||||
|
||||
## Before you start
|
||||
|
||||
- Any current member can create a proposal and open it for voting. A member whose status is still `unverified` (pending approval) cannot create, open, or vote.
|
||||
- Only an `admin` or `board` member can mark an open proposal **decided**. A proposer can **withdraw** their own proposal only while it is still a **draft**; an `admin` or `board` member can withdraw a draft **or** an open proposal. The Withdraw button also appears to the proposer on an open proposal, but clicking it there is a silent no-op — row-level security blocks the change and no error is shown.
|
||||
- Quorum and the voting window are not entered per proposal. They are computed from your space's governance defaults at the moment voting opens. You choose only the pass **threshold**.
|
||||
|
||||
## Create the proposal
|
||||
|
||||
1. Go to [/proposals](/proposals) and click **New proposal** (this opens [/proposals/new](/proposals/new)).
|
||||
2. Fill in **Title** and **Body**. The body accepts Markdown and is rendered on the detail page.
|
||||
3. Pick a **Type**. This is a label describing the kind of decision:
|
||||
|
||||
| Type | Use for |
|
||||
|------|---------|
|
||||
| `advisory_poll` | Non-binding sense of the room (the default) |
|
||||
| `board_action` | A specific action for the board to take |
|
||||
| `membership_vote` | A decision put to the membership |
|
||||
| `bylaw_change` | Amending a policy or bylaw |
|
||||
| `budget` | Spending or budget approval |
|
||||
| `recall` | A vote of confidence / recall |
|
||||
|
||||
4. Pick a **Threshold to pass**. This sets how the yes/no split is judged when the proposal is decided:
|
||||
|
||||
| Threshold | Passes when |
|
||||
|-----------|-------------|
|
||||
| `simple_majority` | Yes votes exceed no votes |
|
||||
| `two_thirds` | Yes is at least two-thirds of yes+no |
|
||||
| `three_fourths` | Yes is at least three-fourths of yes+no |
|
||||
| `unanimous` | No "no" votes and at least one "yes" |
|
||||
|
||||
5. To go straight to voting, tick **Open voting immediately**. Its label notes that quorum and the voting window come from space defaults. Leave it unchecked to save a draft you can open later.
|
||||
6. Click **Save proposal**. You land on the proposal's detail page.
|
||||
|
||||
## Open voting
|
||||
|
||||
If you saved a draft, open it when you are ready. On the proposal page under **Manage**, click **Open for voting**. At that moment the app:
|
||||
|
||||
- counts your active members and computes `quorum_required` from the space's default quorum percent (with a floor), and
|
||||
- sets the voting window using the default window length (shipped default is 216 hours, or 9 days) unless you supplied a close time.
|
||||
|
||||
Once open, the proposal moves to the **Open for voting** section on [/proposals](/proposals) and shows its close time.
|
||||
|
||||
## Let members vote
|
||||
|
||||
While voting is open, every member sees a **Cast your vote** panel with four positions: `yes`, `no`, `abstain`, `recused`. Choosing `recused` requires a recusal reason. A public **Comment** is optional. Members can change their vote until the window closes; votes are never deleted. All votes and voter names are visible to the space for transparency.
|
||||
|
||||
The **Tally** section updates live: yes / no / abstain / recused counts, plus a quorum bar showing votes counted against `quorum_required` and whether quorum is **met**. Quorum counts yes, no, and abstain; recused votes do not count toward quorum.
|
||||
|
||||
## Record the outcome
|
||||
|
||||
When the discussion has run its course, an `admin` or `board` member clicks **Mark decided** under **Manage**. This freezes the tally, stamps the decision time, and sets the outcome. The proposal page then shows **PASSED** or **DID NOT PASS**, and it moves to the **Archive** section on [/proposals](/proposals).
|
||||
|
||||
A proposal passes only if quorum was met **and** the threshold you chose is satisfied on the yes/no split (abstentions do not count toward the threshold). If quorum was never met, the result is **DID NOT PASS** regardless of the vote split.
|
||||
|
||||
## Related
|
||||
|
||||
- [Governance model](/docs/explanation/governance-model) — why proposals, incidents, and policies are built together.
|
||||
- [Governance reference](/docs/reference/governance) — every enum, field, and status.
|
||||
Reference in New Issue
Block a user