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,57 @@
|
||||
hackerspace.sh is a Next.js 16 App Router app written in TypeScript, backed by Supabase (Postgres plus Auth). Every space is a tenant, and the whole platform is built around one idea: reads flow through row-level security, and writes flow through server actions that authorize and scope themselves. This page explains that shape and why it is drawn this way.
|
||||
|
||||
## The stack
|
||||
|
||||
The app is a single Next.js codebase. The UI is React Server Components under `app/(app)/`, the database is Postgres managed by Supabase, and authentication runs on Supabase Auth (GoTrue). There is no separate API service — the browser talks to Next.js, and Next.js talks to Postgres. In production, both the app and a self-hosted Supabase stack run on one DigitalOcean droplet behind Caddy; see [Deployment](/docs/explanation/deployment) for how that is provisioned and how `main` deploys on every push.
|
||||
|
||||
## Route groups
|
||||
|
||||
The `app/` directory uses App Router route groups — parenthesized folders that organize routes without adding a URL segment:
|
||||
|
||||
- `app/(app)/` holds the authenticated product: `/members`, `/customize`, `/proposals`, `/door`, and the rest. Its `layout.tsx` is the gate every one of those screens renders inside.
|
||||
- `app/(landing)/` holds the public marketing page at `/`.
|
||||
|
||||
A screen under `app/(app)/<route>/` follows one convention: a server `page.tsx` fetches data and runs its guard, then hands the data as props to a thin `<route>-client.tsx` orchestrator that composes `panels/` and `components/`. Authorization and data-loading live in the server component; the client component only orchestrates interaction.
|
||||
|
||||
## Three Supabase clients
|
||||
|
||||
There are three ways to reach Postgres, and choosing the right one is the core security decision in any change.
|
||||
|
||||
| Client | Factory | Key | RLS | Use for |
|
||||
|---|---|---|---|---|
|
||||
| Server | `lib/supabase/server.ts` → `createClient()` | anon | Enforced | Server components and most server actions |
|
||||
| Browser | `lib/supabase/client.ts` → `createClient()` | anon | Enforced | Auth (login/signup) and the comms realtime subscription only |
|
||||
| Admin | `lib/supabase/admin.ts` → `createAdminClient()` | service role | **Bypassed** | Trusted server-only work that must cross a normal RLS boundary |
|
||||
|
||||
The server client is cookie-bound: it reads the caller's Supabase session from cookies and runs every query as that user, so RLS decides what rows come back. The browser client uses the same anon key and is deliberately confined — client components never write to the database directly, because a direct browser write skips validation, the authorization gate, the activity log, and (for encrypted data) encryption.
|
||||
|
||||
The admin client uses the service-role key and bypasses RLS entirely. That makes code-side scoping non-negotiable: **every read, update, and delete on the admin client must be constrained with `.eq('space_id', member.space_id)`** (or an id already proven to belong to that space). RLS will not catch a mistake there — the scope is the only thing standing between a query and a cross-tenant leak.
|
||||
|
||||
## The request path
|
||||
|
||||
A request to an authenticated screen passes two gates before it renders:
|
||||
|
||||
1. `proxy.ts` at the repo root is the Next 16 middleware (the framework renamed `middleware` to `proxy`). It refreshes the Supabase session cookie on every request and checks the path against a `PUBLIC_ROUTES` allowlist. Anything not on the list requires a session, or the request is redirected to `/login`. Public routes — webhooks, cron endpoints, the `/f/` public forms, `/track`, the resources subsite — are exempt from the session check because each one self-authenticates with its own signature, bearer secret, or token.
|
||||
2. `app/(app)/layout.tsx` is the second gate, defense in depth. It re-checks the user with `auth.getUser()`, loads the active `space_members` row, and redirects to `/signup` when there is no space or `/onboarding` when onboarding is incomplete. It is marked `force-dynamic` so it never caches — the membership check runs on every request.
|
||||
|
||||
## Server actions are the write path
|
||||
|
||||
Every mutation is a `'use server'` action under `lib/actions/`, and they all follow one contract:
|
||||
|
||||
```ts
|
||||
const v = parseInput(schema, input) // 1. validate with Zod
|
||||
if (!v.ok) return { error: v.error }
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES) // 2. authorize
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
// 3. mutate, scoped by member.space_id
|
||||
await logActivity(supabase, member, 'updated', 'thing', id) // 4. audit
|
||||
revalidatePath('/things') // 5. revalidate
|
||||
```
|
||||
|
||||
The shared helpers in `lib/auth-helpers.ts` make that uniform: `parseInput` runs a Zod schema and returns a discriminated result; `requireMember` loads the caller's active membership; `requireMemberWithRole` additionally checks role and rejects a member whose status is `unverified` (pending approval holds no privileged capability); `logActivity` writes an advisory row to `activity_log`. `getAuthMember` selects the single active membership with `.single()` — the platform is one active space per user today, and that call fails closed rather than guess if a user somehow has two.
|
||||
|
||||
## Why it is drawn this way
|
||||
|
||||
The design exists to make cross-tenant isolation hard to get wrong. RLS on all tables is the backstop, but RLS alone is fragile: the moment code reaches for the admin client to do something legitimate, the backstop is gone. So the platform pairs RLS with an explicit, repeated pattern — validate, authorize, scope by `space_id`, audit, revalidate — that a reviewer can check at a glance and a test can lock in. Keeping writes in server actions rather than the browser makes that pattern the *only* way data changes, so validation, permission checks, and the activity trail can never be skipped. The uniformity is the point: every feature is built the same way, so the security-critical steps stay boring and visible.
|
||||
@@ -0,0 +1,41 @@
|
||||
Most hackerspaces collect dues through tools they already use — PayPal, Venmo, Zeffy, cash, and increasingly Stripe. Reconciliation is the work of turning that scattered money trail into a trustworthy answer to one question: is this member paid up? This page explains the two ideas that make that answer safe — dues that only ever advance, and payments that link to members by hand or automatically.
|
||||
|
||||
## Two separate ledgers
|
||||
|
||||
The platform keeps money and membership state in two places on purpose.
|
||||
|
||||
A row in `payments` is an immutable record of a transaction: an amount, a `platform` (`paypal`, `zeffy`, `venmo`, `cash`, or `stripe`), a `transaction_date`, and identifying scraps like `from_identifier`, `from_note`, and `payer_email`. It answers "money moved."
|
||||
|
||||
A member row answers "membership state" — the `status` enum (`current`, `late`, `inactive`, `unverified`), a `payment_status` field, and `last_paid_at`. Reconciliation is the bridge between the two: attaching a payment to a member, then letting that payment update the member's dues state.
|
||||
|
||||
Keeping them separate means a payment is never rewritten to fit a member. See the [payments & dues reference](/docs/reference/payments-and-dues) for every field.
|
||||
|
||||
## Why dues only advance
|
||||
|
||||
The core rule lives in `resolveDuesAdvance` (`lib/dues-payments-logic.ts`): a payment may move a member's dues state *forward* in time, never backward.
|
||||
|
||||
When a payment is applied, the logic compares the payment's `transaction_date` against the member's existing `last_paid_at` and keeps whichever is later. Only when the incoming payment is genuinely the most recent one on file does it also set the member's `payment_status` to `current`. A payment older than what is already recorded changes nothing — it returns the existing date and leaves the status untouched.
|
||||
|
||||
This matters because reconciliation is frequently retroactive. A treasurer imports six months of PayPal history, or belatedly links a cash payment from March. If applying an old payment could overwrite `last_paid_at`, a routine cleanup would rewind a member who is actually current back to "late." Advance-only makes the order of data entry irrelevant: you can link payments in any sequence and the member's standing always reflects their most recent real payment.
|
||||
|
||||
The comparison is done by instant, not by string, so timestamps from different sources (a PostgREST `+00:00` offset versus a JavaScript `Z` suffix) never disagree about which came first.
|
||||
|
||||
## Hand-matching and auto-matching
|
||||
|
||||
An imported or logged payment starts `unlinked` — its `link_status` is `unlinked` and it has no `member_id`. The `link_status` enum has exactly two values, `linked` and `unlinked`, and it is the canonical signal of whether a transaction has been reconciled. (An older `status` column mirrors it for backwards compatibility.)
|
||||
|
||||
**Hand-matching** is the treasurer's job. On [/payments](/payments), the unmatched pile shows each payment's amount, platform, and `from_identifier`, and the "Link to Member" action lets a treasurer, board member, or admin pick the right person. Linking sets `member_id` and flips `link_status` to `linked`, then runs the same advance-only logic so a historical payment cannot rewind dues. See [connect payments & reconcile dues](/docs/how-to/connect-payments) for the walkthrough.
|
||||
|
||||
**Auto-matching** happens for Stripe. When a Stripe invoice is paid, the webhook resolves the member automatically — from the subscription's `member_id` metadata, or by mapping the Stripe customer id to a member's stored `member_billing.stripe_customer_id` — and writes the payment already `linked`. The payer's email is recorded as `payer_email` only so a human can verify the match; it is never the match key. The member is identified from identifiers Stripe already carries, so no manual linking is needed.
|
||||
|
||||
Everything else is deliberately manual. Imports and cash payments land as `unlinked` rather than guessing, because a wrong auto-link silently credits the wrong member.
|
||||
|
||||
## Who is allowed to write dues state
|
||||
|
||||
Because dues state gates access and standing, only trusted actors may touch it. Migration `044` blocks a member from changing their own `payment_status`, `dues_paid_until`, `last_paid_at`, and related financial columns — even though row-level security otherwise lets members edit their own profile. A member cannot forge a "dues good" signal by writing directly to their row.
|
||||
|
||||
The only legitimate writers are the Stripe webhook and treasurer actions running as a privileged service client, or a privileged member (admin, board, or treasurer) editing *another* member. The [members reference](/docs/reference/members) covers how status flows through to access.
|
||||
|
||||
## Pay-here links, not payment processing
|
||||
|
||||
The `dues_payment_methods` table stores admin-configured pay-here links for PayPal, Zeffy, and Venmo — the URL a member clicks on [/me](/me) to pay off-platform. The platform never handles that money; it only hands the member a button. Because that URL is admin-entered and shown to members, it is constrained to absolute `https://` at three layers — a database `CHECK`, the admin form in [/settings](/settings), and one more check (`isSafeDuesUrl`) at the boundary that renders the clickable card — so a bad value can never become a downgrade or injection vector. After the member pays through the link, the transaction still comes back as an `unlinked` payment for a treasurer to reconcile by hand.
|
||||
@@ -0,0 +1,42 @@
|
||||
Governance in a hackerspace is mostly procedure: who gets to decide, how many people have to weigh in, and what counts as a pass. The platform's governance kernel encodes those procedures as data so they can't be argued about after the fact. This page explains why quorum, thresholds, recusal, anonymity, and SLAs are modeled the way they are.
|
||||
|
||||
The whole design follows one rule: **privacy default for individuals, transparency default for institutions.** A member's identity when reporting harassment is protected; the vote a board member cast on a proposal is public forever. Every schema choice below falls out of that principle plus a second one — no hardcoded politics. Quorum percentages, voting windows, thresholds, and SLAs are per-space **DB defaults** — columns on the `spaces` table (`default_quorum_percent`, `default_voting_window_hours`, `default_threshold`, `incident_sla_hours`) that ship sensible values but are not currently editable in the UI. Each proposal sets its own quorum, threshold, and window at creation, seeded from those defaults.
|
||||
|
||||
## Why quorum is a floor plus a percent
|
||||
|
||||
A single percentage breaks at the extremes. Ten percent of 400 members is a reasonable 40-vote bar; ten percent of 6 members is 0.6, which rounds to nothing and lets two people pass a bylaw change. So quorum is computed as the **greater of an absolute floor and a percentage**:
|
||||
|
||||
```
|
||||
quorum_required = GREATEST(quorum_floor, CEIL(member_count * quorum_percent / 100))
|
||||
```
|
||||
|
||||
The member count is not "everyone in the database" — it counts only members with status `current` or `late` who are `approved`. Unverified, inactive, and unapproved rows don't inflate the denominator. Defaults are `default_quorum_percent = 10` and `default_quorum_floor = 1`; a real space typically raises the floor to something like 8 ("10% or 8, whichever is greater").
|
||||
|
||||
Crucially, quorum is **computed once, when the proposal opens, and frozen** onto the proposal row (`quorum_required`, `quorum_percent`, `quorum_floor`). If three people join mid-vote, the bar doesn't move under the voters. That freezing is what makes the number un-disputable: it's stated on the proposal page before anyone votes.
|
||||
|
||||
## Why thresholds are separate from quorum
|
||||
|
||||
Quorum asks "did enough people show up?" Thresholds ask "of those who took a side, did enough agree?" They're independent, and abstentions reveal why. An abstain **counts toward quorum** (the person showed up) but is **excluded from the threshold math** (they took no side). Recused votes count toward neither.
|
||||
|
||||
| Threshold | 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 |
|
||||
|
||||
The denominator is always `yes + no`, never total voters. Using integer cross-multiplication instead of floating-point division avoids rounding disputes on the exact boundary. A proposal only passes if quorum is met *and* the threshold clears — both are recomputed on every vote so the proposal page shows a live, honest tally. See [Run a proposal](/docs/how-to/run-a-proposal) for the operator's view and [Governance reference](/docs/reference/governance) for the full enum tables.
|
||||
|
||||
## Why recusal is a first-class position, not a silence
|
||||
|
||||
A board member with a conflict of interest could just not vote — but then there's no record of *why* they stayed out, and the count can't distinguish a principled recusal from apathy. So `recused` is one of four vote positions (`yes`, `no`, `abstain`, `recused`), and a database constraint requires a non-empty `recusal_reason` to use it. The recusal is logged permanently and visible to every member, because a recusal record is exactly the kind of institutional transparency the model protects. Votes are never deleted; a member's voting history is a query, not a leak.
|
||||
|
||||
## Why anonymity is honest, not cryptographic
|
||||
|
||||
Incident reports (`/incidents`) can be filed anonymously, but the platform deliberately does **not** promise cryptographic anonymity — the database admin can always read the row. What it provides instead is an opaque, server-generated `reporter_token` that lets an anonymous reporter track their own case without exposing their identity in any list. When `is_anonymous` is set, `reporter_id` is left null. Named subjects of a report don't see it by default; dispositions stay board-only until a policy says otherwise, enforced by row-level security and the `incident_updates` visibility levels (`reporter_only`, `all_parties`, `board_only`). Promising more than the storage model can deliver would be dishonest, so the model promises exactly what it enforces. See [Handle incidents](/docs/how-to/handle-incidents).
|
||||
|
||||
## Why every incident has an SLA clock
|
||||
|
||||
The failure mode the incident system exists to prevent is the complaint that vanishes. So an incident isn't just filed — on insert it gets an `sla_response_by` deadline (`created_at + incident_sla_hours`, default 72 hours / 3 days). The status ladder (`received → under_review → decided → appealed → closed`) means action requires an explicit, timestamped transition, and a dismissed report exposes an appeal path that becomes a normal proposal with `parent_incident_id` set. Silence stops being a way to avoid a decision, because the schema records the absence of one.
|
||||
|
||||
The through-line: institutional memory is retained and public — proposals, votes, and policy versions are immutable and cross-referenced — while individual reporters keep the privacy the model was built to give them.
|
||||
@@ -0,0 +1,68 @@
|
||||
hackerspace.sh is multi-tenant: every space is an isolated tenant, and almost every row in the database belongs to exactly one space. This page explains how that isolation is enforced, how a member's role becomes a concrete set of things they can do, and why the right to exercise a role depends on the member's dues status.
|
||||
|
||||
## Everything is space-scoped
|
||||
|
||||
Every tenant table (spaces, members, payments, the knowledge base, secrets, and the rest) carries a `space_id`. Access is decided by one question: *is the current user a member of that space?*
|
||||
|
||||
That question is answered in Postgres by a `SECURITY DEFINER` helper, `get_user_space_ids(uid)`, which returns every `space_id` a user belongs to. Row Level Security (RLS) policies on read-heavy tables reduce to a single predicate:
|
||||
|
||||
```sql
|
||||
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())))
|
||||
```
|
||||
|
||||
RLS is enabled on all tenant tables. A logged-in user's queries are silently filtered to their own space — a member of space A cannot read, update, or delete a row belonging to space B, because that row's `space_id` is never in their set. The helper is `SECURITY DEFINER` with a fixed `search_path`, which both prevents recursive policy evaluation on `space_members` and closes the usual privilege-escalation vectors.
|
||||
|
||||
### Two enforcement layers
|
||||
|
||||
RLS is the backstop, not the only guard. The RLS-bound `server` and `client` Supabase clients are filtered automatically, but the `admin` (service-role) client bypasses RLS entirely. Any action that uses it must scope every query itself:
|
||||
|
||||
```ts
|
||||
.eq('space_id', member.space_id)
|
||||
```
|
||||
|
||||
So tenant isolation is enforced twice: once by Postgres RLS, and once by explicit `space_id` scoping in every server action. The redundancy is deliberate — the code-side scope is the *only* thing preventing a cross-tenant (IDOR) leak on the service-role path, where RLS will not save you. See [The security model](/docs/explanation/security-model) for the broader picture.
|
||||
|
||||
## From role to effective permission
|
||||
|
||||
Membership carries a `role` (one of `admin`, `board`, `treasurer`, `member`, `associate`), defined once in `lib/permissions.ts` in privilege order. Roles are the coarse gate; write-guarded server actions check them directly through helpers like `requireMemberWithRole(supabase, ADMIN_ROLES)`.
|
||||
|
||||
On top of roles sits a finer, per-space **permission** layer. The set of capabilities is fixed product data (`lib/permissions-catalog.ts` — codes like `ops.secrets.read`, `payments.manage`, `door.operate`, `classes.instruct`). Which subjects hold which permission is per-space, stored in `space_role_permissions` and editable at [/customize](/customize).
|
||||
|
||||
A "subject" is broader than a built-in role. `user_effective_roles(uid, sid)` unions three things:
|
||||
|
||||
| Subject kind | Source |
|
||||
|---|---|
|
||||
| Built-in role | the member's `role` |
|
||||
| Custom role | slugs from `space_custom_roles` assigned to the member |
|
||||
| Area lead | `area_lead:<id>` for each area the member leads |
|
||||
|
||||
`user_has_permission(uid, sid, perm)` then returns true when the member holds a permission grant whose subject is any of their effective roles. `admin` is the exception: it implicitly holds every permission and is never stored in the grant table, so it can never be accidentally revoked or locked out.
|
||||
|
||||
This layer is strictly **additive**. A permission grant can only *widen* what a subject can do through the surfaces that consult it (the Ops ACL, the customize UI). It never overrides RLS, so it cannot cross a tenant boundary.
|
||||
|
||||
## Why eligibility depends on status
|
||||
|
||||
A member also has a `status`: `current`, `late`, `inactive`, or `unverified`. Holding a privileged role is not enough — the member must be *privilege-eligible*, which means `current` or `late` only:
|
||||
|
||||
```ts
|
||||
export const PRIVILEGE_STATUSES = ['current', 'late']
|
||||
```
|
||||
|
||||
Both the role guard (`requireMemberWithRole` short-circuits when the member is not eligible) and the SQL functions (`user_has_permission`, `user_has_role_in_space`, `members_with_permission` all filter `status IN ('current','late')`) enforce this identically.
|
||||
|
||||
Two decisions are encoded here:
|
||||
|
||||
- **`unverified` holds nothing.** In a space with `require_approval` on, a new member is `unverified` until an admin approves them — even if they redeemed a role-bearing invite code. Without this gate, redeeming an admin-role invite into an approval-gated space would grant instant admin, silently voiding approval.
|
||||
- **`late` keeps its role.** A dues lapse is a grace state, not an authorization downgrade. A treasurer who is one day late on dues does not lose the ability to do treasurer work.
|
||||
|
||||
## Tradeoffs
|
||||
|
||||
- **Single active space per user (today).** `getAuthMember` uses `.single()` and fails closed, so a user acts in exactly one space. True multi-space would need an explicit active-space selector before the isolation model changes.
|
||||
- **Redundant scoping is verbose but safe.** Requiring `.eq('space_id', …)` on every admin-client query is easy to forget; the project locks it in with action-layer tests rather than trusting RLS alone.
|
||||
- **Permissions widen, never narrow.** Because the permission layer is additive over role-based RLS, you cannot use it to take a capability *away* from a role that RLS already grants — only to extend a capability to additional subjects.
|
||||
|
||||
## Related
|
||||
|
||||
- [Roles and permissions reference](/docs/reference/roles-and-permissions)
|
||||
- [Member statuses](/docs/reference/member-statuses)
|
||||
- [Customizing roles and permissions](/docs/how-to/customize-permissions)
|
||||
@@ -0,0 +1,70 @@
|
||||
hackerspace.sh treats every space as an isolated tenant and assumes the network is hostile: the browser is never trusted to say who you are or what you may do. This page explains the layers that enforce that assumption — Postgres Row Level Security, server-derived identity, the encrypted secrets vault, the authorization gates inside every server action, and input validation — and why each layer exists even when another already covers it.
|
||||
|
||||
## Defense in depth, not a single wall
|
||||
|
||||
There is no one place where security "happens." A write travels through several independent checks, and any one of them can reject it:
|
||||
|
||||
```
|
||||
browser → proxy (session) → (app) layout (re-check) → server action
|
||||
→ validate → authorize → space-scope → Postgres RLS
|
||||
```
|
||||
|
||||
No layer trusts the layer above it. The proxy gates routes, the layout re-checks the user, the action re-derives identity and re-authorizes, and Postgres RLS filters rows one last time. If a bug opens a hole in one layer, the next one is still standing.
|
||||
|
||||
## Server-derived identity
|
||||
|
||||
The browser never tells the server who the caller is. Every server action starts from the session cookie and asks Supabase Auth directly:
|
||||
|
||||
```ts
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
```
|
||||
|
||||
From that verified `user.id`, `getAuthMember` (in `lib/auth-helpers.ts`) loads the caller's active `space_members` row and returns their real `role`, `status`, and `space_id`. Client code cannot supply any of these. If a request body contained a `space_id` or `role` field, it would be ignored — the action uses the derived membership, not the payload.
|
||||
|
||||
Two details harden this:
|
||||
|
||||
- **Active-only.** `getAuthMember` accepts only the `ACTIVE_STATUSES` (`current`, `unverified`, `late`); an `inactive` member is blocked from every action.
|
||||
- **Fail closed.** The lookup uses `.single()`. If a user somehow had two active memberships, the query errors and no action runs, rather than silently picking a space.
|
||||
|
||||
See [The permissions model](/docs/explanation/permissions-model) for how a role becomes a concrete set of capabilities.
|
||||
|
||||
## Authorization gates in server actions
|
||||
|
||||
Once identity is derived, the action authorizes it. `requireMemberWithRole(supabase, allowed)` enforces two conditions before returning the member:
|
||||
|
||||
1. The member's status is privilege-eligible — `PRIVILEGE_STATUSES` is `current` and `late` only. An `unverified` member awaiting approval in a `require_approval` space holds **no** privileged capability, even if they redeemed a role-bearing invite code. Without this gate, redeeming an admin invite would grant instant admin.
|
||||
2. The member's role is in the `allowed` set.
|
||||
|
||||
Only then does the action run its query, and it must scope that query to the caller's own space with `.eq('space_id', member.space_id)`. This scope is mandatory on the `admin` (service-role) client, which bypasses RLS entirely — there, the code-side scope is the only thing standing between the caller and a cross-tenant (IDOR) leak.
|
||||
|
||||
## Row Level Security as the backstop
|
||||
|
||||
RLS is enabled on every tenant table. The RLS-bound `server` and `client` clients are filtered by Postgres regardless of what the application code does. Read policies reduce to one predicate, evaluated by a `SECURITY DEFINER` helper:
|
||||
|
||||
```sql
|
||||
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())))
|
||||
```
|
||||
|
||||
Sensitive tables narrow further by role via `user_has_role_in_space(...)` — `secrets` are admin/board only, `payments` is treasurer and up, and the `knowledge_base` `visibility` column (`all_members`, `board`, `admin_only`) is enforced in the SELECT policy itself.
|
||||
|
||||
### The self-role-change trap
|
||||
|
||||
RLS deliberately lets a member update *their own* `space_members` row so they can fix their display name or handle. That opens an escalation path: a member could issue a direct PostgREST `PATCH` setting `role = 'admin'` on themselves. The `prevent_member_self_role_change` trigger closes it. On every self-update by a non-privileged member, it rejects any change to `role`, `tier`, `status`, `approved`, `has_card_access`, or `space_id` with a `42501` error. Only a member who already holds `admin`/`board`/`treasurer` in that space may touch those columns.
|
||||
|
||||
## The encrypted secrets vault
|
||||
|
||||
Third-party credentials — Stripe keys, door-controller bearer tokens, API keys — are never stored in plaintext. `lib/secrets/crypto.ts` encrypts them with **AES-256-GCM**:
|
||||
|
||||
- The master key comes from `SECRETS_ENCRYPTION_KEY` (64 hex characters, 32 bytes). The module is server-only; the key is never reachable from the browser.
|
||||
- Each secret gets a fresh 12-byte IV on every encrypt. The stored `encrypted_value` is `iv (12) || ciphertext || authTag (16)`, tagged with `encryption_version = 1`.
|
||||
- GCM's authentication tag means decryption fails on any tampered ciphertext; payloads shorter than the IV-plus-tag minimum are rejected before the crypto primitive runs.
|
||||
|
||||
Secrets are written only through server actions and the `storeSecret`/`readSecret` vault helpers, and are never returned to a client. Fields are routed into the vault by naming convention (`isSecretConfigField`): keys like `api_key`, `client_secret`, or anything ending in `_secret`, `_token`, or `_password` are encrypted, while derived `_set`/`_ref` markers are not.
|
||||
|
||||
## Input validation
|
||||
|
||||
Before authorization even runs, every action validates its input with a Zod schema through `parseInput`, which returns a discriminated result and rejects malformed data with a first-error message. Validation is the outermost gate: bad input never reaches the authorization check or the database.
|
||||
|
||||
## Public endpoints self-authenticate
|
||||
|
||||
A handful of routes are intentionally session-less — Stripe webhooks, inbound door events, cron jobs, public forms under `/f/*`, and `/track`. Each carries its own proof instead of a cookie: Stripe verifies a per-space signing secret with a replay guard, door callbacks compare a per-connection vault bearer with `timingSafeEqual`, crons check `CRON_SECRET`, and `/track` uses a 192-bit token.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -0,0 +1,72 @@
|
||||
In this tutorial you will set up an external pay-here link, watch a member pay through it, then record that payment and reconcile it to the member so their dues advance. By the end you will have taken one dues payment all the way from "add a link" to a member marked `current`.
|
||||
|
||||
You will use three screens: [/settings](/settings) to add the link, [/import](/import) to record the payment, and [/payments](/payments) to reconcile it.
|
||||
|
||||
## Before you start
|
||||
|
||||
You need the right role for each part:
|
||||
|
||||
| Task | Required role |
|
||||
| --- | --- |
|
||||
| Add or edit a pay-here link | admin or board |
|
||||
| Import and reconcile payments | admin, board, or treasurer |
|
||||
|
||||
This example uses Venmo, but PayPal and Zeffy work exactly the same way.
|
||||
|
||||
## Step 1: Add a pay-here link
|
||||
|
||||
1. Go to [/settings](/settings) and open the **Dues** tab.
|
||||
2. Under **Other ways to pay dues** you will see a card for each platform: PayPal, Zeffy, and Venmo. Find the **Venmo** card.
|
||||
3. Paste your payment link into the **URL** field. It must be an absolute `https://` URL — anything else is rejected.
|
||||
4. Optionally add an instruction, for example `Put your member name in the note`. This is a hint that helps you match the payment later.
|
||||
5. Leave the toggle set to **shown** so members can see it.
|
||||
6. Click **Save**.
|
||||
|
||||
The link is now live for your space. You can add all three platforms, and hide any one later by flipping its toggle to **hidden**.
|
||||
|
||||
## Step 2: The member pays externally
|
||||
|
||||
There is nothing for you to do here — this is what the member sees.
|
||||
|
||||
1. The member opens their [/me](/me) page and looks at the **Dues** section.
|
||||
2. While they still owe dues, the card shows your active links under **Other ways to pay** (or **Pay your dues** if your space has no card billing set up).
|
||||
3. They click **Pay with Venmo**, which opens your external payment page in a new tab, and they pay you there — adding their name in the note if you asked them to.
|
||||
|
||||
The platform does not record this payment automatically. That is the next step.
|
||||
|
||||
## Step 3: Record the payment
|
||||
|
||||
When your Venmo activity is available, export it as a CSV and import it.
|
||||
|
||||
1. Go to [/import](/import) and switch the **Import type** to **Payments**.
|
||||
2. Upload your CSV file by dropping it on the upload area or clicking to browse.
|
||||
3. On **Map Columns**, confirm the required fields are mapped: **Amount**, **Sender / From Name**, and **Date**. Set the **Platform** column to `venmo` so the payment is tagged correctly, and map the note column if you have one.
|
||||
4. Click **Preview Import**, review the first few rows, then click **Import**.
|
||||
|
||||
Every imported row lands as an `unlinked` payment. Rows with a bad amount, platform, or date are skipped and counted, so nothing invalid is silently added.
|
||||
|
||||
> Recording a single hand-received payment instead? Use the **Log Cash** button on [/payments](/payments) — but note it always records the platform as `cash`.
|
||||
|
||||
## Step 4: Reconcile the payment to the member
|
||||
|
||||
1. Go to [/payments](/payments). The header shows a count like **1 unlinked** whenever payments still need a member.
|
||||
2. Find your Venmo row. You can narrow the list with the **All Platforms** and **All Status** filters at the top of the transactions table.
|
||||
3. Click **+ Link member** on that row.
|
||||
4. In the **Link to Member** dialog, pick the member who paid.
|
||||
|
||||
The row flips to `linked` and shows the member's name with a check mark.
|
||||
|
||||
## Step 5: Confirm the dues advanced
|
||||
|
||||
Linking a payment does two things automatically:
|
||||
|
||||
- It advances the member's **last paid** date to the payment's date.
|
||||
- It sets the member's status to `current` — as long as this payment is the most recent one on file for them.
|
||||
|
||||
This is **advance-only**: recording an older or backdated payment never moves a member's dues backward. If the member already has a newer payment recorded, linking an older one leaves their status untouched.
|
||||
|
||||
To verify, open the member on [/members](/members) and check their status, or have the member reload [/me](/me), where the payment now appears under **My payments**.
|
||||
|
||||
## What you did
|
||||
|
||||
You added an external pay-here link, a member paid through it, you imported the payment as an `unlinked` row, and you reconciled it to the member — advancing their dues to `current`. Repeat Step 3 and Step 4 whenever new external payments come in.
|
||||
@@ -0,0 +1,69 @@
|
||||
This tutorial walks you through the full lifecycle of a class in your space: you will create a class offering, schedule a session with a capacity, watch members sign up (with a waitlist when it fills), mark attendance as the instructor, and complete the session so it can award a certification. By the end you will have run one class from start to finish.
|
||||
|
||||
Set aside about fifteen minutes. You will need the `classes.manage` permission to create classes and sessions, and `classes.instruct` to run the session and mark attendance. Both are granted to the `board` role by default. To have completion award a certification, the instructor also needs `certifications.grant`.
|
||||
|
||||
## Before you start
|
||||
|
||||
- Sign in as a member with `classes.manage` (a board member by default).
|
||||
- Optional: if you want the class to award a certification, create the certification type first at [/certifications](/certifications). Note its name.
|
||||
|
||||
## Step 1: Create a class
|
||||
|
||||
A class is the reusable offering ("Woodshop Basics"). Sessions are the individual scheduled occurrences you will add later.
|
||||
|
||||
1. Go to [/classes/manage](/classes/manage).
|
||||
2. Select **New class**.
|
||||
3. Enter a **Title**, for example `Woodshop Basics`. Add an optional description.
|
||||
4. Optionally set a **Default capacity**. Leave it blank for unlimited. Each session can override this later.
|
||||
5. Optionally, in the certification dropdown, choose **Grants: `<your cert>`** so completing a session awards that certification to attendees.
|
||||
6. Select **Create class**.
|
||||
|
||||
You will see a "Class created" confirmation and the class listed with any `cap` and `grants` badges you set.
|
||||
|
||||
Two optional fields you can ignore for this tutorial: a **Payment link** (a plain external URL shown to members, with no live payment processing) and a **required form** that gates signup until the member has it on file (for example a liability waiver published under [/forms](/forms)).
|
||||
|
||||
## Step 2: Schedule a session
|
||||
|
||||
1. On the class card, select **+ Schedule session**.
|
||||
2. Set **Starts** (and optionally **Ends**). Ends must not be before Starts.
|
||||
3. Optionally add a **Location**, a **Capacity override**, and **Notes**.
|
||||
4. Select **Schedule**.
|
||||
|
||||
The session appears under the class with its start time and a **Scheduled** status. The effective capacity is the session's capacity override if set, otherwise the class default; if both are blank, the session is open (unlimited).
|
||||
|
||||
## Step 3: Members sign up
|
||||
|
||||
Members do not need any special permission to sign up, only membership in the space.
|
||||
|
||||
1. As a member, go to [/classes](/classes). Upcoming, non-cancelled sessions appear with "X of N spots left" (or "open" when unlimited).
|
||||
2. Select **Sign up**. You will see "You are signed up" and a **Registered** badge.
|
||||
|
||||
When registered signups reach capacity, the button reads **Join waitlist** instead, and new signups get a **Waitlisted** status. A member can select **Cancel signup** at any time; if a registered member cancels, the earliest waitlisted member is automatically promoted to registered and emailed. If the class requires a form the member has not submitted, the button becomes **Complete required form** and links to the form first.
|
||||
|
||||
## Step 4: Mark attendance
|
||||
|
||||
Now switch back to someone with `classes.instruct`. (A user with only `classes.manage` can open the roster and see who signed up, but cannot check **attended** or run **Complete session** — both require `classes.instruct`.)
|
||||
|
||||
1. On [/classes/manage](/classes/manage), find the session and select **Signups**. (Instructors browsing [/classes](/classes) can use **Attendees** on the session instead.)
|
||||
2. The roster lists each signup with its status. Check the **attended** box next to each member who showed up.
|
||||
|
||||
Attendance is what determines who receives a certification in the next step, so check it before completing.
|
||||
|
||||
## Step 5: Complete the session and award the certification
|
||||
|
||||
1. In the same attendees panel, select **Complete session**.
|
||||
2. The session status changes to **Completed**.
|
||||
|
||||
If the class grants a certification, completion awards it to every member you marked as attended. The result confirms how many certificates were issued. If the instructor lacks `certifications.grant`, the session still completes but you will see "Certificates were NOT issued (requires the certifications.grant permission)" — grant that permission and the members can be certified through [/certifications](/certifications).
|
||||
|
||||
Members can see their signups and completed sessions on their own page at [/me](/me), and any certification they earned appears in their certifications.
|
||||
|
||||
## What you built
|
||||
|
||||
You now have a class with one completed session, an attendance record, and (optionally) certifications awarded to attendees. From here you can:
|
||||
|
||||
- Schedule more sessions of the same class — the offering is reusable.
|
||||
- **Archive** a class you no longer offer instead of deleting it (deleting is only allowed when a class has no sessions).
|
||||
- **Cancel** a session instead of deleting it to keep the signup history; every signed-up member is emailed when you do.
|
||||
|
||||
See the [classes reference](/docs/reference/classes) for every field, status, and permission, and [manage certifications](/docs/how-to/manage-certifications) for setting up the certification types classes can grant.
|
||||
@@ -0,0 +1,74 @@
|
||||
This tutorial walks you from a blank signup form to a working space where you land as an admin, then gives you a quick tour of the dashboard and left navigation so you know where everything lives. It takes about five minutes.
|
||||
|
||||
By the end you will have a space of your own, an admin membership inside it, and a mental map of the sidebar.
|
||||
|
||||
## Before you start
|
||||
|
||||
You need an email address and a password of at least 8 characters. If someone has already set up a space for your group and given you an invite code, skip ahead to [Joining an existing space](#joining-an-existing-space) instead.
|
||||
|
||||
## Step 1: Create your account
|
||||
|
||||
1. Go to `/signup`.
|
||||
2. Under **Create your account**, fill in **Full Name**, **Email**, and **Password** (minimum 8 characters).
|
||||
3. Under **What are you doing?**, click the **Create a Space** card.
|
||||
4. Click **Continue →**.
|
||||
|
||||
## Step 2: Name your space
|
||||
|
||||
Now you are on the **Set up your Space** step.
|
||||
|
||||
1. Enter a **Space Name**, such as `HeatSync Labs`. As you type, a **Slug** is filled in for you automatically — it is the lowercase, letters-and-numbers-only handle for your space (for example `heatsynclabs`). You can edit it.
|
||||
2. Optionally enter a **City / Location**.
|
||||
3. Read the note: you will be the admin of this space, and default channels (general, announcements, ops) are created for you.
|
||||
4. Click **Create Space →**.
|
||||
|
||||
Depending on how email confirmation is configured, one of two things happens:
|
||||
|
||||
- You go straight to `/dashboard`, already signed in.
|
||||
- You are sent to `/signup/confirm` to verify your email first. Open the link in that email, and you will land on the dashboard afterward.
|
||||
|
||||
That is it — your space exists. As the founder you are set up as an **admin** on the **plus** tier with a **current** status, and you skip the member onboarding flow entirely.
|
||||
|
||||
## Step 3: Get your bearings on the dashboard
|
||||
|
||||
The first time you open `/dashboard` in an empty space, you see a **"Let's set up your space"** panel instead of a grid of zeros. It offers four first steps:
|
||||
|
||||
| Step | Where it goes |
|
||||
| --- | --- |
|
||||
| Invite or add members | `/members` |
|
||||
| Set up onboarding | `/customize` |
|
||||
| Create your first task or chore | `/tasks` |
|
||||
| Connect payments | `/settings` |
|
||||
|
||||
That panel disappears on its own once there is real activity. After that, the dashboard shows a stat row — **Active Members**, **Dues Current**, **Open Tasks**, **Unlinked Payments** — plus quick chores, active projects, who is present now, and recent activity. The **Quick Task** button in the header jumps to `/tasks`.
|
||||
|
||||
## Step 4: Explore the left navigation
|
||||
|
||||
The sidebar groups every screen into sections. As an admin you see all of them:
|
||||
|
||||
- **Workspace** — Dashboard, Tasks & Chores, Projects, Ops & Facilities, Comms, Forum
|
||||
- **Governance** — Proposals, Incidents, Policies
|
||||
- **Access & Resources** — Classes, Equipment, Doors, Forms
|
||||
- **People** — Members, Attendance, Contacts, Recruitment
|
||||
- **Finance** — Payments, Financials
|
||||
- **Account** — My membership
|
||||
- **Admin** — Customize, Settings, Import / Sync, and the management screens for forms, certifications, classes, equipment, and doors
|
||||
|
||||
The **Admin** section only appears for admins, board members, or members you have granted a specific manage permission. There is also a **Jump to…** button at the top of the sidebar (or press ⌘K) that opens a command palette for fast navigation.
|
||||
|
||||
A good next stop is [inviting your first members](/members), or `/customize` to shape what new members see when they join.
|
||||
|
||||
## Joining an existing space
|
||||
|
||||
If you were given an invite code instead of starting a new space:
|
||||
|
||||
1. Go to `/signup` (or open the `/signup?invite=CODE` link your admin shared, which prefills the code for you).
|
||||
2. Fill in your account details and click the **Join a Space** card, then **Continue →**.
|
||||
3. Enter the **Invite Code** — it looks like `HSL-2025-XXXX`. Ask your space admin for it if you do not have one; they can find the space's default code under Settings.
|
||||
4. Click **Join Space →**.
|
||||
|
||||
You join as a **member**. If the space requires approval (the default), your status starts as **unverified** and an admin approves you before you gain full access — until then the dashboard is still yours to look around. If the space does not require approval, you are **current** right away.
|
||||
|
||||
## What you did
|
||||
|
||||
You created (or joined) a space, landed with the right role, and learned where the dashboard and sidebar put things. From here, follow the dashboard's setup steps to add members, build your onboarding flow, and start capturing tasks.
|
||||
@@ -0,0 +1,92 @@
|
||||
In this tutorial you will bring your first people into your space two ways — a shareable join link and a manual add — shape what a new member sees when they first open the app, and move everyone to `current` status. Work through it as a space admin; by the end you will have a repeatable way to grow your roster.
|
||||
|
||||
## Before you start
|
||||
|
||||
You need the **admin** role (a **board** member can copy and share existing invite codes, but not create, disable, or delete them). Everything below happens on the [/members](/members) screen and the onboarding card in [/customize](/customize).
|
||||
|
||||
There are two ways in:
|
||||
|
||||
- **Invite code / join link** — people sign themselves up, then you approve them.
|
||||
- **Add member** — you enter someone directly and they are active immediately.
|
||||
|
||||
You will use both.
|
||||
|
||||
## Step 1: Create an invite code
|
||||
|
||||
1. Open [/members](/members) and find the **Invite codes** card.
|
||||
2. Click **+ New invite**.
|
||||
3. Leave the code blank to auto-generate one, or type your own. Optionally set a **Label**, an expiry, and a **Max uses** cap. Leave both blank for a permanent code.
|
||||
4. Pick what the code **grants** — for new members, choose `member`.
|
||||
5. Tick **Single use** if the code should auto-disable after one join.
|
||||
6. Click **Create invite**.
|
||||
|
||||
Your code appears in the list with a `grants member` tag and a live use count.
|
||||
|
||||
## Step 2: Share the join link
|
||||
|
||||
On the new code's row you have two buttons:
|
||||
|
||||
- **Copy code** — copies just the code (for people who will type it during signup).
|
||||
- **Copy link** — copies the one-click join link, which looks like:
|
||||
|
||||
```
|
||||
https://your-space.example/join/your-space?code=ABC23XYZ
|
||||
```
|
||||
|
||||
Send the link. Whoever opens it lands on a **Join** page for your space, creates an account, and is added as a member with `unverified` status — waiting for your approval.
|
||||
|
||||
If a code is ever shared too widely, an admin can click **Disable** to revoke it, or **Delete** to remove it outright — both controls are admin-only.
|
||||
|
||||
## Step 3: Approve people who joined
|
||||
|
||||
New self-serve joiners do not get in automatically.
|
||||
|
||||
1. Back on [/members](/members), open the **Pending Approval** tab. Everyone who used your link is here.
|
||||
2. Click **APPROVE** on a row to move that member to `current`.
|
||||
3. To clear a batch at once, select the rows and click **Approve selected**.
|
||||
|
||||
Approving flips the member to `current` and marks them approved, so they count toward your active roster.
|
||||
|
||||
## Step 4: Add a member manually
|
||||
|
||||
Some people you will enter yourself — a founding member, someone who paid at the door.
|
||||
|
||||
1. On [/members](/members), click **Add Member**.
|
||||
2. Fill in **Full Name** and **Email** (both required). Phone and handle are optional.
|
||||
3. Choose a **Tier** (`Plus`, `Basic`, `Associate`, or `Admin`) and a **Role** (`Member`, `Board`, `Treasurer`, or `Admin`).
|
||||
4. Optionally set **Joined At** and tick **Card Access**.
|
||||
5. Click **Add Member**.
|
||||
|
||||
A manually added member starts at `current` immediately — no approval step needed.
|
||||
|
||||
## Step 5: Shape the onboarding flow
|
||||
|
||||
Now decide what a member sees the first time they open the app.
|
||||
|
||||
1. Go to [/customize](/customize) and find the **New member onboarding** card.
|
||||
2. Every new space starts with four built-in steps in order: **Welcome**, **Code of Conduct** (a required acknowledgement), **Complete your profile**, and **Set up your dues**.
|
||||
3. Edit any step's title inline, and its body where the step has one (body supports Markdown) — the built-in **Complete your profile** step has only an editable title, no body. Change the order with the number box, and untick **Enabled** to hide a step.
|
||||
4. On the **Set up your dues** step, paste your **Payment link** so the member gets a working "Set up payment" button.
|
||||
5. Add your own with **+ Custom step**, or use **+ Form step** to make members sign a published form or waiver as part of onboarding.
|
||||
|
||||
Built-in steps can be disabled and reordered but not deleted. Only steps you mark **Required** block a member from finishing.
|
||||
|
||||
## Step 6: See what the new member sees
|
||||
|
||||
The first time an approved member opens the app they are routed to [/onboarding](/onboarding) and walk your enabled steps in order — a progress bar tracks **Step X of Y**. They must tick the code-of-conduct box and any other required step before the final **Finish** button lets them through to the [/dashboard](/dashboard). If no step is required, a **Skip for now** link is available.
|
||||
|
||||
## You are done
|
||||
|
||||
You now have members arriving two ways and a first-run flow that greets them. From here:
|
||||
|
||||
- Keep an eye on the **Pending Approval** and **Payment Issues** tabs on [/members](/members).
|
||||
- Refine your onboarding copy and required steps in [/customize](/customize) as your space's norms settle.
|
||||
|
||||
### Member statuses at a glance
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `current` | Active member in good standing |
|
||||
| `late` | Active but behind on dues |
|
||||
| `inactive` | No longer participating |
|
||||
| `unverified` | Joined via a link, awaiting approval |
|
||||
Reference in New Issue
Block a user