feat: governance kernel, deployment hardening, local-dev stack
Adds proposals + proposal_votes + policies + incidents + incident_updates
tables with full RLS, async voting flow, dismissal-appeal pathway,
versioned policies with provenance, and an anonymous-incident token mode.
Adds per-space configurable areas, financial visibility settings, member
self-profile + COI disclosure, and a mission statement that renders in
the app footer.
Reorganises lib/actions.ts into per-domain files (tasks, projects,
members, payments, contacts, knowledge-base, secrets, area-leads,
settings, imports, proposals, incidents, policies, areas) plus a barrel.
Adds lib/permissions.ts and lib/auth-helpers.ts so action bodies stop
duplicating the user-to-member-to-role check. Wires every action that has
a Zod schema; introduces a flexibleDateTime preprocessor so HTML date
pickers no longer hit "Invalid datetime".
Hardens RLS: drops NOT NULL on space_members.user_id for offline members,
adds a privilege-escalation trigger that blocks self-edits to role / tier
/ status / approved / has_card_access / space_id, closes four
cross-tenant holes in the governance kernel, and adds a partial unique
index so two admins cannot concurrently activate two drafts of the same
policy slug.
Ships every deployment artifact: Dockerfile, docker-compose,
.do/app.yaml, .env.example, /api/health, plus a managed-Supabase guide
(docs/DEPLOYMENT.md), an end-to-end self-hosted Droplet guide
(docs/DEPLOY_DO_SELFHOSTED.md), and a Supabase-CLI local-dev quickstart
(docs/LOCAL_DEV.md). Replaces middleware.ts with proxy.ts for Next.js 16.
Adds scripts/schema.sql as the canonical idempotent full deploy plus
fourteen numbered incremental migrations.
Adds scripts/seed.sql so a fresh local DB renders something instead of
empty pages: a demo space, five members across all roles, a v1 code of
conduct, three proposals in different states with seven votes already
cast, an open incident, a project, three tasks, a KB entry, and matching
activity-log rows.
Replaces v0-generated test scaffolding with 266 real tests across
__tests__/{actions,auth-helpers,components,governance,permissions,
security,utils,validations}.test.ts. Adds react-markdown + remark-gfm
for proposal / incident / policy body rendering. Deletes four dead v0
fix-* scripts, the unused components/app-shell.tsx, and the duplicate
app/auth/login/page.tsx.
Routes (31 total): /api/health, /api/paypal/sync, /auth/callback, /,
/login, /signup, /signup/confirm, /dashboard, /tasks, /projects,
/members, /payments, /financials, /contacts, /ops, /ops/[id], /ops/new,
/proposals, /proposals/[id], /proposals/new, /incidents, /incidents/[id],
/incidents/new, /policies, /policies/[slug], /policies/new, /profile,
/recruitment, /settings, /comms, /import.
Full pass-by-pass history is in docs/HANDOFF.md; the roadmap is in
docs/GOVERNANCE_FEATURES.md.
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# DigitalOcean App Platform spec for hackerspace-management.
|
||||
# -----------------------------------------------------------------------------
|
||||
# Apply with:
|
||||
# doctl apps create --spec .do/app.yaml
|
||||
# Or import via the App Platform UI: Create App -> "From an existing app spec".
|
||||
# Replace REPO_OWNER/REPO_NAME and the placeholder env values before applying.
|
||||
# -----------------------------------------------------------------------------
|
||||
name: hackerspace-management
|
||||
region: nyc
|
||||
features:
|
||||
- buildpack-stack=ubuntu-22
|
||||
|
||||
services:
|
||||
- name: web
|
||||
github:
|
||||
repo: REPO_OWNER/REPO_NAME
|
||||
branch: main
|
||||
deploy_on_push: true
|
||||
source_dir: /
|
||||
build_command: corepack enable && pnpm install --frozen-lockfile && pnpm build
|
||||
run_command: pnpm start
|
||||
http_port: 3000
|
||||
instance_size_slug: basic-xs
|
||||
instance_count: 1
|
||||
health_check:
|
||||
http_path: /api/health
|
||||
initial_delay_seconds: 20
|
||||
period_seconds: 30
|
||||
timeout_seconds: 5
|
||||
failure_threshold: 3
|
||||
routes:
|
||||
- path: /
|
||||
envs:
|
||||
- key: NODE_ENV
|
||||
scope: RUN_TIME
|
||||
value: production
|
||||
- key: NEXT_PUBLIC_SUPABASE_URL
|
||||
scope: RUN_AND_BUILD_TIME
|
||||
value: https://your-project-ref.supabase.co
|
||||
- key: NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
scope: RUN_AND_BUILD_TIME
|
||||
value: replace-with-anon-key
|
||||
- key: SUPABASE_SERVICE_ROLE_KEY
|
||||
scope: RUN_TIME
|
||||
type: SECRET
|
||||
value: replace-with-service-role-key
|
||||
- key: NEXT_PUBLIC_APP_URL
|
||||
scope: RUN_AND_BUILD_TIME
|
||||
value: https://your-app.ondigitalocean.app
|
||||
@@ -0,0 +1,18 @@
|
||||
.git
|
||||
.gitignore
|
||||
.next
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.env*.local
|
||||
.env
|
||||
README.md
|
||||
CLAUDE.md
|
||||
docs
|
||||
e2e
|
||||
__tests__
|
||||
test-results
|
||||
playwright-report
|
||||
coverage
|
||||
.vercel
|
||||
.do
|
||||
*.log
|
||||
@@ -0,0 +1,33 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# hackerspace-management — environment variables
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Copy this file to .env.local for development. For deployed environments,
|
||||
# set the same keys in the host dashboard (Vercel, DigitalOcean, etc).
|
||||
# .env.local is git-ignored. Never commit real secrets.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Supabase (required)
|
||||
# Project Settings → API in the Supabase dashboard.
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOi...replace-with-anon-key
|
||||
|
||||
# Server-side admin key. Bypasses RLS. Required for createSpace / joinSpace.
|
||||
# Treat as a secret. Never expose to the browser. Never log it.
|
||||
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...replace-with-service-role-key
|
||||
|
||||
# App URL (required)
|
||||
# Public-facing base URL of this deployment. Used in OAuth redirects.
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
# Node env (optional, defaults to "development" locally)
|
||||
NODE_ENV=development
|
||||
|
||||
# Docker build flag (only when building the standalone image)
|
||||
# Set to "1" in the Dockerfile so next.config.mjs emits standalone output.
|
||||
# DOCKER_BUILD=1
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Per-space integration credentials (PayPal, Zeffy, Venmo, Stripe)
|
||||
# are stored in the database under the `integrations` table, not in env.
|
||||
# The /api/paypal/sync endpoint reads them from there.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -19,3 +19,10 @@ playwright-report/
|
||||
|
||||
# Vitest
|
||||
.vitest/
|
||||
|
||||
# TypeScript incremental build cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Supabase CLI local state
|
||||
supabase/.branches/
|
||||
supabase/.temp/
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Working Agreement for Claude
|
||||
|
||||
This file is the standing brief for any Claude session in this repository. Read it before touching code, every session.
|
||||
|
||||
## Identity rules
|
||||
|
||||
- Never include Claude attribution in commits. No `Co-Authored-By: Claude`, no `Generated with Claude Code`, no emoji robot signatures. Commits must look like a human wrote them.
|
||||
- Never include Claude attribution in pull request descriptions, code comments, or generated documents unless the user explicitly asks for it.
|
||||
|
||||
## Writing rules
|
||||
|
||||
- No emojis anywhere: not in code, comments, commits, PR bodies, docs, or chat replies. The user reads them as AI tells.
|
||||
- No em dashes (`—`). Use a period, a colon, a semicolon, or parentheses depending on the sentence.
|
||||
- No "AI language" patterns: avoid "Let me", "I'll go ahead and", "Sure!", "Certainly!", "delve", "leverage", "robust", "seamless", "comprehensive", "best practices", filler hedges, and breathless intros. Be direct and specific.
|
||||
- No hype, no sales copy. Describe what something does, not how great it is.
|
||||
- Code comments only when the WHY is non-obvious. Never comments that say WHAT the code does.
|
||||
|
||||
## Engineering rules
|
||||
|
||||
- Be cautious. Be careful. Be smart.
|
||||
- Smart separation of concerns: server actions in `lib/`, UI in `components/` and `app/(app)/*-client.tsx`, data access through `lib/supabase/*`. Do not cross these lines without a reason you can defend in a sentence.
|
||||
- Test-driven where practical: when changing a server action or a utility, write or update the test first. Tests live in `__tests__/` (Vitest) and `e2e/` (Playwright). New tests must run with `pnpm test` and `pnpm test:e2e` without extra config.
|
||||
- Match the existing patterns. If you find yourself inventing a new abstraction, stop and ask why the current one is wrong.
|
||||
- Do not refactor while fixing a bug. Land the fix. Refactor in a separate change.
|
||||
- Do not introduce backwards-compat shims for code you wrote in the same session.
|
||||
- Use parameterized queries through the Supabase client. Never build raw SQL with string concatenation.
|
||||
- Validate input at the server-action boundary with Zod schemas from `lib/validations.ts`. Reject before you touch the database.
|
||||
|
||||
## Database rules
|
||||
|
||||
- The canonical schema lives in `scripts/schema.sql`. It must remain idempotent and runnable top-to-bottom on a clean Supabase project.
|
||||
- When you add a column, table, enum, or policy, add it to `scripts/schema.sql` AND add a numbered incremental migration in `scripts/` named `NNN_short_description.sql` so existing deployments can upgrade.
|
||||
- When you change a column on an existing deployment, the incremental migration runs first, then `scripts/schema.sql` is updated to match.
|
||||
- Never break RLS. Every new table starts with `ENABLE ROW LEVEL SECURITY` and explicit policies for SELECT, INSERT, UPDATE, DELETE before it is merged.
|
||||
- Enum changes require a migration, never a re-create. `ALTER TYPE ... ADD VALUE` is the only safe path.
|
||||
- After any schema change, refresh `types/database.ts` and update `DB_SCHEMA_MAP.md`.
|
||||
|
||||
## Deployment rules
|
||||
|
||||
- A fresh clone must produce a working app with no more than: clone, `pnpm install`, copy `.env.example` to `.env.local` with real values, run `scripts/schema.sql` in Supabase, `pnpm dev`.
|
||||
- A fresh production deploy (Vercel, DigitalOcean App Platform, Droplet) must be documented in `docs/DEPLOYMENT.md` step by step, no implicit knowledge required.
|
||||
- Any new environment variable must be added to `.env.example` in the same change that introduces it. Never let a deploy fail because an env var was only documented in a Slack thread.
|
||||
- The Dockerfile and docker-compose configuration in the repo are the source of truth for self-hosted deploys. Update them when the runtime requirements change.
|
||||
|
||||
## Documentation rules
|
||||
|
||||
- Always document. If you add a feature, a server action, or a table, update the relevant doc in the same change:
|
||||
- `docs/ARCHITECTURE.md` for structural changes
|
||||
- `docs/API_REFERENCE.md` for new server actions
|
||||
- `docs/DATABASE_SCHEMA.md` and `DB_SCHEMA_MAP.md` for schema changes
|
||||
- `docs/DEPLOYMENT.md` for new env vars or new deploy steps
|
||||
- Documentation describes the system as it is, not as it might be. Mark known gaps with a clear "Not implemented" or "Planned" so readers do not mistake intent for reality.
|
||||
|
||||
## Handoff rules
|
||||
|
||||
- For long sessions (anything over ~30 minutes of active work, or any session that touched more than three files), update `docs/HANDOFF.md` at the end of the session.
|
||||
- A handoff entry includes: date in `YYYY-MM-DD`, branch name, what changed, why, what is still open, and any pending decisions the user needs to make.
|
||||
- A handoff is a one-screen read. Bullets, not paragraphs.
|
||||
|
||||
## Authorization rules
|
||||
|
||||
- Do not run destructive git operations without explicit user confirmation: `push --force`, `reset --hard`, `branch -D`, `clean -f`.
|
||||
- Do not commit, push, or open a pull request unless the user asked for it. Authorization is per-request, not standing.
|
||||
- Do not skip pre-commit hooks (`--no-verify`). If a hook fails, fix the underlying problem.
|
||||
|
||||
## When in doubt
|
||||
|
||||
Ask. The cost of a clarifying question is small. The cost of a wrong action is large.
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
# syntax=docker/dockerfile:1.6
|
||||
# -----------------------------------------------------------------------------
|
||||
# Multi-stage build for Next.js (standalone output)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Stage 1: deps — install production + dev deps for the build.
|
||||
# Stage 2: builder — run `next build` with DOCKER_BUILD=1 to emit standalone.
|
||||
# Stage 3: runner — minimal runtime image, copies standalone server only.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
ARG NODE_VERSION=20-alpine
|
||||
|
||||
FROM node:${NODE_VERSION} AS base
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
# ----- deps ------------------------------------------------------------------
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# ----- builder ---------------------------------------------------------------
|
||||
FROM base AS builder
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# next.config.mjs keys `output: 'standalone'` off this env var.
|
||||
ENV DOCKER_BUILD=1
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# NEXT_PUBLIC_* values are inlined at build time. Pass them as build args
|
||||
# when the URL differs per-environment, or rely on runtime values for the
|
||||
# anon key (Supabase ssr handles cookie-based auth without baking secrets).
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ARG NEXT_PUBLIC_APP_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
# ----- runner ----------------------------------------------------------------
|
||||
FROM node:${NODE_VERSION} AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Non-root user for the runtime process
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy the standalone server output and static assets
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,35 +1,85 @@
|
||||
# hackerspace-management
|
||||
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [v0](https://v0.app).
|
||||
Multi-tenant member, payments, tasks, projects, ops, and comms platform for hackerspaces. Each tenant (a "space") owns its members, tasks, projects, payments, knowledge base, and chat channels. All access is row-level secured at the database.
|
||||
|
||||
## Built with v0
|
||||
## Stack
|
||||
|
||||
This repository is linked to a [v0](https://v0.app) project. You can continue developing by visiting the link below -- start new chats to make changes, and v0 will push commits directly to this repo. Every merge to `main` will automatically deploy.
|
||||
- Next.js 16 (App Router, React 19, TypeScript)
|
||||
- Tailwind v4 + shadcn/ui
|
||||
- Supabase (Postgres, Auth, Realtime)
|
||||
- Vitest + Playwright
|
||||
|
||||
[Continue working on v0 →](https://v0.app/chat/projects/prj_WPJ8g7vA0mDSvNLcVg9Znrye43yM)
|
||||
## Quick start (local)
|
||||
|
||||
## Getting Started
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd hackerspace-management
|
||||
pnpm install
|
||||
cp .env.example .env.local
|
||||
# Fill in Supabase URL, anon key, service role key in .env.local
|
||||
```
|
||||
|
||||
First, run the development server:
|
||||
Apply the database schema in your Supabase project:
|
||||
|
||||
1. Supabase dashboard, SQL Editor, New query.
|
||||
2. Paste the contents of `scripts/schema.sql`.
|
||||
3. Run.
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
Open `http://localhost:3000`, sign up, create a space, land on `/dashboard`.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
Health probe: `curl http://localhost:3000/api/health`.
|
||||
|
||||
## Learn More
|
||||
## Documentation
|
||||
|
||||
To learn more, take a look at the following resources:
|
||||
| File | What it covers |
|
||||
|------|----------------|
|
||||
| [CLAUDE.md](./CLAUDE.md) | Working agreement for AI assistants in this repo |
|
||||
| [docs/AUDIT.md](./docs/AUDIT.md) | Latest deep audit (2026-05-13): findings, fixes applied, open issues |
|
||||
| [docs/HANDOFF.md](./docs/HANDOFF.md) | Session-by-session change log |
|
||||
| [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md) | Local, Vercel, DigitalOcean App Platform, DigitalOcean Droplet (with managed Supabase) |
|
||||
| [docs/DEPLOY_DO_SELFHOSTED.md](./docs/DEPLOY_DO_SELFHOSTED.md) | End-to-end self-hosted on a single DigitalOcean Droplet (Supabase + app) |
|
||||
| [docs/LOCAL_DEV.md](./docs/LOCAL_DEV.md) | Fresh-clone to running app locally in five minutes, using the Supabase CLI |
|
||||
| [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | System design, project structure, auth flows |
|
||||
| [docs/DATABASE_SCHEMA.md](./docs/DATABASE_SCHEMA.md) | Full schema reference |
|
||||
| [DB_SCHEMA_MAP.md](./DB_SCHEMA_MAP.md) | Quick column-by-column lookup |
|
||||
| [docs/API_REFERENCE.md](./docs/API_REFERENCE.md) | Server actions |
|
||||
| [docs/COMPONENT_REFERENCE.md](./docs/COMPONENT_REFERENCE.md) | UI components |
|
||||
| [TESTING.md](./TESTING.md) | How to run tests |
|
||||
| [docs/GOVERNANCE_FEATURES.md](./docs/GOVERNANCE_FEATURES.md) | Diagnostic-to-feature roadmap for governance modules (proposals, incidents, policies) |
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
- [v0 Documentation](https://v0.app/docs) - learn about v0 and how to use it.
|
||||
## Deployment
|
||||
|
||||
<a href="https://v0.app/chat/api/kiro/clone/virgilvox/hackerspace-management" alt="Open in Kiro"><img src="https://pdgvvgmkdvyeydso.public.blob.vercel-storage.com/open%20in%20kiro.svg?sanitize=true" /></a>
|
||||
Four supported targets:
|
||||
|
||||
- **Vercel** (managed Supabase): zero-config, connect the GitHub repo, set env vars. See [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md).
|
||||
- **DigitalOcean App Platform** (managed Supabase): spec ships at [.do/app.yaml](./.do/app.yaml). See [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md).
|
||||
- **DigitalOcean Droplet, app only** (managed Supabase): Dockerfile and docker-compose.yml ship in the repo root. See [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md).
|
||||
- **DigitalOcean Droplet, fully self-hosted** (your own Supabase, your own Postgres, your own data): full end-to-end guide in [docs/DEPLOY_DO_SELFHOSTED.md](./docs/DEPLOY_DO_SELFHOSTED.md).
|
||||
|
||||
Required environment variables are in [.env.example](./.env.example).
|
||||
|
||||
## Database migrations
|
||||
|
||||
`scripts/schema.sql` is the canonical, idempotent, full-schema deploy. Run it once on a fresh Supabase project. For existing deployments, only run new incremental files (`scripts/NNN_description.sql`).
|
||||
|
||||
## Scripts
|
||||
|
||||
```bash
|
||||
pnpm dev # development server
|
||||
pnpm build # production build
|
||||
pnpm start # serve production build
|
||||
pnpm lint # eslint
|
||||
pnpm test # vitest watch mode
|
||||
pnpm test:ui # vitest UI
|
||||
pnpm test:e2e # playwright
|
||||
```
|
||||
|
||||
## Project status
|
||||
|
||||
The system is feature-functional but pre-production. See [docs/AUDIT.md](./docs/AUDIT.md) section 4 for known gaps (validation schema enum drift, dead scripts, UI-only OAuth buttons, no secrets encryption at rest, CSV import not wired, etc.).
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import { parseInput } from '@/lib/auth-helpers'
|
||||
|
||||
describe('parseInput', () => {
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(10, 'Name is too long'),
|
||||
age: z.number().int().nonnegative(),
|
||||
})
|
||||
|
||||
it('returns parsed data on success', () => {
|
||||
const r = parseInput(schema, { name: 'Alice', age: 30 })
|
||||
expect(r.ok).toBe(true)
|
||||
if (r.ok) {
|
||||
expect(r.data).toEqual({ name: 'Alice', age: 30 })
|
||||
}
|
||||
})
|
||||
|
||||
it('returns the first validation message on failure', () => {
|
||||
const r = parseInput(schema, { name: '', age: 30 })
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) {
|
||||
expect(r.error).toBe('Name is required')
|
||||
}
|
||||
})
|
||||
|
||||
it('returns an error for completely invalid input', () => {
|
||||
const r = parseInput(schema, null)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) {
|
||||
expect(r.error).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns a fallback message when zod has no error message', () => {
|
||||
const minimal = z.string()
|
||||
const r = parseInput(minimal, 42)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) {
|
||||
expect(r.error).toBeDefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,444 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
createProposalSchema,
|
||||
castVoteSchema,
|
||||
decideProposalSchema,
|
||||
withdrawProposalSchema,
|
||||
fileIncidentSchema,
|
||||
updateIncidentStatusSchema,
|
||||
addIncidentUpdateSchema,
|
||||
appealIncidentSchema,
|
||||
createPolicySchema,
|
||||
supersedePolicySchema,
|
||||
updatePolicyStatusSchema,
|
||||
proposalTypes,
|
||||
thresholdRules,
|
||||
votePositions,
|
||||
incidentSeverities,
|
||||
incidentStatuses,
|
||||
policyStatuses,
|
||||
} from '@/lib/validations'
|
||||
|
||||
const UUID = '11111111-2222-3333-4444-555555555555'
|
||||
|
||||
// =============================================================================
|
||||
// Proposals
|
||||
// =============================================================================
|
||||
|
||||
describe('createProposalSchema', () => {
|
||||
it('accepts minimal valid input', () => {
|
||||
const r = createProposalSchema.safeParse({ title: 'Approve new laser station rules' })
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults proposal_type to advisory_poll', () => {
|
||||
const r = createProposalSchema.safeParse({ title: 'X' })
|
||||
expect(r.success).toBe(true)
|
||||
if (r.success) expect(r.data.proposal_type).toBe('advisory_poll')
|
||||
})
|
||||
|
||||
it('accepts every database proposal_type', () => {
|
||||
for (const t of proposalTypes) {
|
||||
const r = createProposalSchema.safeParse({ title: 'X', proposal_type: t })
|
||||
expect(r.success, `proposal_type=${t}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unknown proposal_type', () => {
|
||||
const r = createProposalSchema.safeParse({ title: 'X', proposal_type: 'referendum' })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty title', () => {
|
||||
const r = createProposalSchema.safeParse({ title: '' })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects title over 200 chars', () => {
|
||||
const r = createProposalSchema.safeParse({ title: 'x'.repeat(201) })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts every threshold rule', () => {
|
||||
for (const t of thresholdRules) {
|
||||
const r = createProposalSchema.safeParse({ title: 'X', threshold: t })
|
||||
expect(r.success, `threshold=${t}`).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('castVoteSchema', () => {
|
||||
it('accepts a yes vote without recusal reason', () => {
|
||||
const r = castVoteSchema.safeParse({ proposalId: UUID, position: 'yes' })
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a no, abstain vote without recusal reason', () => {
|
||||
expect(castVoteSchema.safeParse({ proposalId: UUID, position: 'no' }).success).toBe(true)
|
||||
expect(castVoteSchema.safeParse({ proposalId: UUID, position: 'abstain' }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a recused vote without a reason', () => {
|
||||
const r = castVoteSchema.safeParse({ proposalId: UUID, position: 'recused' })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a recused vote with an empty-string reason', () => {
|
||||
const r = castVoteSchema.safeParse({ proposalId: UUID, position: 'recused', recusal_reason: ' ' })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a recused vote with a real reason', () => {
|
||||
const r = castVoteSchema.safeParse({
|
||||
proposalId: UUID,
|
||||
position: 'recused',
|
||||
recusal_reason: 'I am a named party in the underlying incident.',
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts every vote position', () => {
|
||||
for (const p of votePositions) {
|
||||
const r = castVoteSchema.safeParse({
|
||||
proposalId: UUID,
|
||||
position: p,
|
||||
recusal_reason: p === 'recused' ? 'reason' : undefined,
|
||||
})
|
||||
expect(r.success, `position=${p}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid proposal id', () => {
|
||||
expect(castVoteSchema.safeParse({ proposalId: 'nope', position: 'yes' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decideProposalSchema and withdrawProposalSchema', () => {
|
||||
it('require a uuid', () => {
|
||||
expect(decideProposalSchema.safeParse({ proposalId: UUID }).success).toBe(true)
|
||||
expect(decideProposalSchema.safeParse({ proposalId: 'bad' }).success).toBe(false)
|
||||
expect(withdrawProposalSchema.safeParse({ proposalId: UUID }).success).toBe(true)
|
||||
expect(withdrawProposalSchema.safeParse({ proposalId: 'bad' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Incidents
|
||||
// =============================================================================
|
||||
|
||||
describe('fileIncidentSchema', () => {
|
||||
it('accepts minimal valid input', () => {
|
||||
const r = fileIncidentSchema.safeParse({
|
||||
title: 'Unsafe handling at laser station',
|
||||
body: 'On Tuesday, I observed...',
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults severity to medium and category to general', () => {
|
||||
const r = fileIncidentSchema.safeParse({ title: 'x', body: 'y' })
|
||||
expect(r.success).toBe(true)
|
||||
if (r.success) {
|
||||
expect(r.data.severity).toBe('medium')
|
||||
expect(r.data.category).toBe('general')
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts every severity', () => {
|
||||
for (const s of incidentSeverities) {
|
||||
const r = fileIncidentSchema.safeParse({ title: 'x', body: 'y', severity: s })
|
||||
expect(r.success, `severity=${s}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects empty title and body', () => {
|
||||
expect(fileIncidentSchema.safeParse({ title: '', body: 'y' }).success).toBe(false)
|
||||
expect(fileIncidentSchema.safeParse({ title: 'x', body: '' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts an array of subject UUIDs', () => {
|
||||
const r = fileIncidentSchema.safeParse({
|
||||
title: 'x',
|
||||
body: 'y',
|
||||
subjects: [UUID, '22222222-2222-2222-2222-222222222222'],
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-uuid subject entries', () => {
|
||||
const r = fileIncidentSchema.safeParse({ title: 'x', body: 'y', subjects: ['not-a-uuid'] })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateIncidentStatusSchema', () => {
|
||||
it('accepts every status', () => {
|
||||
for (const s of incidentStatuses) {
|
||||
const r = updateIncidentStatusSchema.safeParse({ incidentId: UUID, status: s })
|
||||
expect(r.success, `status=${s}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('allows a disposition when deciding', () => {
|
||||
const r = updateIncidentStatusSchema.safeParse({
|
||||
incidentId: UUID,
|
||||
status: 'decided',
|
||||
disposition: 'No violation found.',
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unknown status', () => {
|
||||
expect(updateIncidentStatusSchema.safeParse({ incidentId: UUID, status: 'pending' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addIncidentUpdateSchema', () => {
|
||||
it('requires a body', () => {
|
||||
expect(addIncidentUpdateSchema.safeParse({ incidentId: UUID, body: '' }).success).toBe(false)
|
||||
expect(addIncidentUpdateSchema.safeParse({ incidentId: UUID, body: 'note' }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults visibility to all_parties', () => {
|
||||
const r = addIncidentUpdateSchema.safeParse({ incidentId: UUID, body: 'note' })
|
||||
expect(r.success).toBe(true)
|
||||
if (r.success) expect(r.data.visibility).toBe('all_parties')
|
||||
})
|
||||
|
||||
it('rejects unknown visibility', () => {
|
||||
const r = addIncidentUpdateSchema.safeParse({
|
||||
incidentId: UUID,
|
||||
body: 'note',
|
||||
visibility: 'whisper',
|
||||
})
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('appealIncidentSchema', () => {
|
||||
it('accepts a valid appeal payload', () => {
|
||||
const r = appealIncidentSchema.safeParse({
|
||||
incidentId: UUID,
|
||||
title: 'Appeal of dismissed complaint',
|
||||
body: 'I disagree with the board...',
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty title', () => {
|
||||
expect(appealIncidentSchema.safeParse({ incidentId: UUID, title: '', body: '' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Policies
|
||||
// =============================================================================
|
||||
|
||||
describe('createPolicySchema', () => {
|
||||
it('accepts minimal valid input', () => {
|
||||
const r = createPolicySchema.safeParse({ slug: 'code-of-conduct', title: 'Code of Conduct' })
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('enforces slug character set', () => {
|
||||
expect(createPolicySchema.safeParse({ slug: 'CoC', title: 'X' }).success).toBe(false)
|
||||
expect(createPolicySchema.safeParse({ slug: 'code of conduct', title: 'X' }).success).toBe(false)
|
||||
expect(createPolicySchema.safeParse({ slug: 'code-of-conduct-2', title: 'X' }).success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty slug and title', () => {
|
||||
expect(createPolicySchema.safeParse({ slug: '', title: 'X' }).success).toBe(false)
|
||||
expect(createPolicySchema.safeParse({ slug: 'x', title: '' }).success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a plain-language body alongside formal', () => {
|
||||
const r = createPolicySchema.safeParse({
|
||||
slug: 'bylaws',
|
||||
title: 'Bylaws',
|
||||
body_formal: 'WHEREAS...',
|
||||
body_plain: 'In short: be excellent to each other.',
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('supersedePolicySchema', () => {
|
||||
it('requires a policy id and accepts new bodies', () => {
|
||||
const r = supersedePolicySchema.safeParse({
|
||||
policyId: UUID,
|
||||
body_formal: 'updated...',
|
||||
adopted_by_proposal_id: UUID,
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects bad policy id', () => {
|
||||
expect(supersedePolicySchema.safeParse({ policyId: 'nope' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updatePolicyStatusSchema', () => {
|
||||
it('accepts every policy_status value', () => {
|
||||
for (const s of policyStatuses) {
|
||||
const r = updatePolicyStatusSchema.safeParse({ policyId: UUID, status: s })
|
||||
expect(r.success, `status=${s}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unknown status', () => {
|
||||
expect(updatePolicyStatusSchema.safeParse({ policyId: UUID, status: 'archived' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Cross-cutting: type contracts as documentation
|
||||
// =============================================================================
|
||||
|
||||
// =============================================================================
|
||||
// Member profile and visibility (Tier 2 + Tier 3)
|
||||
// =============================================================================
|
||||
|
||||
import {
|
||||
updateMyProfileSchema,
|
||||
discloseAffiliationsSchema,
|
||||
updateSpaceVisibilitySchema,
|
||||
meetingMinutesSchema,
|
||||
financialVisibilities,
|
||||
directoryVisibilities,
|
||||
} from '@/lib/validations'
|
||||
|
||||
describe('updateMyProfileSchema', () => {
|
||||
it('accepts empty input (all fields optional)', () => {
|
||||
expect(updateMyProfileSchema.safeParse({}).success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a typical profile update', () => {
|
||||
const r = updateMyProfileSchema.safeParse({
|
||||
display_name: 'Alex',
|
||||
handle: 'alex',
|
||||
skills: ['woodworking', 'electronics'],
|
||||
willing_to: ['board_candidate', 'host_volunteer'],
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects skills array longer than 40', () => {
|
||||
const r = updateMyProfileSchema.safeParse({
|
||||
skills: Array.from({ length: 41 }, (_, i) => `skill-${i}`),
|
||||
})
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a willing_to entry over 60 chars', () => {
|
||||
const r = updateMyProfileSchema.safeParse({ willing_to: ['x'.repeat(61)] })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('discloseAffiliationsSchema', () => {
|
||||
it('accepts an empty disclosure (still valid; user has no affiliations)', () => {
|
||||
const r = discloseAffiliationsSchema.safeParse({ affiliations: [] })
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a list of affiliations', () => {
|
||||
const r = discloseAffiliationsSchema.safeParse({
|
||||
affiliations: ['ACME Robotics, contractor', 'Friends of the Library, board'],
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects too-long affiliation strings', () => {
|
||||
const r = discloseAffiliationsSchema.safeParse({ affiliations: ['x'.repeat(201)] })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateSpaceVisibilitySchema', () => {
|
||||
it('accepts every financial_visibility value', () => {
|
||||
for (const v of financialVisibilities) {
|
||||
const r = updateSpaceVisibilitySchema.safeParse({ financial_visibility: v })
|
||||
expect(r.success, `financial_visibility=${v}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts every directory_visibility value', () => {
|
||||
for (const v of directoryVisibilities) {
|
||||
const r = updateSpaceVisibilitySchema.safeParse({ member_directory_visibility: v })
|
||||
expect(r.success, `member_directory_visibility=${v}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unknown visibility', () => {
|
||||
expect(
|
||||
updateSpaceVisibilitySchema.safeParse({ financial_visibility: 'public' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('meetingMinutesSchema', () => {
|
||||
it('accepts a typical input', () => {
|
||||
const r = meetingMinutesSchema.safeParse({
|
||||
entryId: UUID,
|
||||
is_meeting_minutes: true,
|
||||
meeting_date: '2026-05-14T18:00:00Z',
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults is_meeting_minutes to true', () => {
|
||||
const r = meetingMinutesSchema.safeParse({ entryId: UUID })
|
||||
expect(r.success).toBe(true)
|
||||
if (r.success) expect(r.data.is_meeting_minutes).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid entry id', () => {
|
||||
expect(meetingMinutesSchema.safeParse({ entryId: 'nope' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
import { updateSpaceSettingsSchema } from '@/lib/validations'
|
||||
|
||||
describe('updateSpaceSettingsSchema (mission statement)', () => {
|
||||
it('accepts a mission statement', () => {
|
||||
const r = updateSpaceSettingsSchema.safeParse({
|
||||
mission_statement: 'A community-driven hackerspace committed to radical inclusivity.',
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a null mission statement (clears the column)', () => {
|
||||
const r = updateSpaceSettingsSchema.safeParse({ mission_statement: null })
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a mission statement longer than 5000 chars', () => {
|
||||
const r = updateSpaceSettingsSchema.safeParse({ mission_statement: 'x'.repeat(5001) })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('still accepts the other space-settings fields', () => {
|
||||
const r = updateSpaceSettingsSchema.safeParse({
|
||||
name: 'New name',
|
||||
city: 'Mesa, AZ',
|
||||
require_approval: false,
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('governance contract surface', () => {
|
||||
it('exports the expected proposal_type enum', () => {
|
||||
expect([...proposalTypes].sort()).toEqual(
|
||||
['advisory_poll', 'board_action', 'budget', 'bylaw_change', 'membership_vote', 'recall'].sort(),
|
||||
)
|
||||
})
|
||||
|
||||
it('exports the expected threshold rules in privilege order', () => {
|
||||
expect([...thresholdRules]).toEqual(['simple_majority', 'two_thirds', 'three_fourths', 'unanimous'])
|
||||
})
|
||||
|
||||
it('exports the expected incident statuses in workflow order', () => {
|
||||
expect([...incidentStatuses]).toEqual(['received', 'under_review', 'decided', 'appealed', 'closed'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
ROLES,
|
||||
ADMIN_ROLES,
|
||||
TREASURER_ROLES,
|
||||
ACTIVE_STATUSES,
|
||||
hasRole,
|
||||
type Role,
|
||||
} from '@/lib/permissions'
|
||||
|
||||
describe('ROLES', () => {
|
||||
it('lists every database role', () => {
|
||||
expect([...ROLES].sort()).toEqual(
|
||||
['admin', 'associate', 'board', 'member', 'treasurer'].sort(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ADMIN_ROLES', () => {
|
||||
it('is admin + board', () => {
|
||||
expect([...ADMIN_ROLES].sort()).toEqual(['admin', 'board'])
|
||||
})
|
||||
|
||||
it('does not include member, treasurer, or associate', () => {
|
||||
for (const r of ['member', 'treasurer', 'associate'] as Role[]) {
|
||||
expect(ADMIN_ROLES.includes(r as never)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('TREASURER_ROLES', () => {
|
||||
it('is admin + board + treasurer', () => {
|
||||
expect([...TREASURER_ROLES].sort()).toEqual(['admin', 'board', 'treasurer'])
|
||||
})
|
||||
|
||||
it('does not include plain member', () => {
|
||||
expect(TREASURER_ROLES.includes('member' as never)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ACTIVE_STATUSES', () => {
|
||||
it('includes current, unverified, late', () => {
|
||||
expect([...ACTIVE_STATUSES].sort()).toEqual(
|
||||
['current', 'late', 'unverified'].sort(),
|
||||
)
|
||||
})
|
||||
|
||||
it('does NOT include inactive', () => {
|
||||
expect(ACTIVE_STATUSES.includes('inactive' as never)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasRole', () => {
|
||||
it('returns true when role is in the allowed list', () => {
|
||||
expect(hasRole('admin', ADMIN_ROLES)).toBe(true)
|
||||
expect(hasRole('board', ADMIN_ROLES)).toBe(true)
|
||||
expect(hasRole('treasurer', TREASURER_ROLES)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when role is not allowed', () => {
|
||||
expect(hasRole('member', ADMIN_ROLES)).toBe(false)
|
||||
expect(hasRole('treasurer', ADMIN_ROLES)).toBe(false)
|
||||
expect(hasRole('associate', TREASURER_ROLES)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for null or undefined role', () => {
|
||||
expect(hasRole(null, ADMIN_ROLES)).toBe(false)
|
||||
expect(hasRole(undefined, ADMIN_ROLES)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
sanitizeString,
|
||||
stripHtml,
|
||||
escapeHtml,
|
||||
sanitizeEmail,
|
||||
sanitizeUrl,
|
||||
sanitizeSlug,
|
||||
hasSqlInjectionPatterns,
|
||||
isValidUuid,
|
||||
truncate,
|
||||
checkRateLimit,
|
||||
validateContentLength,
|
||||
} from '@/lib/security'
|
||||
|
||||
describe('sanitizeString', () => {
|
||||
it('strips HTML brackets', () => {
|
||||
expect(sanitizeString('<script>alert(1)</script>')).toBe('scriptalert(1)/script')
|
||||
})
|
||||
it('strips javascript: protocol', () => {
|
||||
expect(sanitizeString('javascript:alert(1)')).toBe('alert(1)')
|
||||
})
|
||||
it('strips event handlers', () => {
|
||||
expect(sanitizeString('onclick="x"')).toBe('"x"')
|
||||
})
|
||||
it('trims whitespace', () => {
|
||||
expect(sanitizeString(' hello ')).toBe('hello')
|
||||
})
|
||||
it('returns empty string for non-strings', () => {
|
||||
expect(sanitizeString(null as unknown as string)).toBe('')
|
||||
expect(sanitizeString(undefined as unknown as string)).toBe('')
|
||||
expect(sanitizeString(42 as unknown as string)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('escapes the six dangerous characters', () => {
|
||||
expect(escapeHtml(`<>&"'/`)).toBe('<>&"'/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripHtml', () => {
|
||||
it('removes tags but keeps content', () => {
|
||||
expect(stripHtml('<p>hello <b>world</b></p>')).toBe('hello world')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeEmail', () => {
|
||||
it('lowercases and validates', () => {
|
||||
expect(sanitizeEmail('Foo@Bar.com')).toBe('foo@bar.com')
|
||||
})
|
||||
it('rejects malformed addresses', () => {
|
||||
expect(sanitizeEmail('not-an-email')).toBeNull()
|
||||
expect(sanitizeEmail('foo@')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeUrl', () => {
|
||||
it('passes through https', () => {
|
||||
expect(sanitizeUrl('https://example.com')).toBe('https://example.com/')
|
||||
})
|
||||
it('rejects javascript: and file:', () => {
|
||||
expect(sanitizeUrl('javascript:alert(1)')).toBeNull()
|
||||
expect(sanitizeUrl('file:///etc/passwd')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeSlug', () => {
|
||||
it('lowercases and collapses to alphanumeric+hyphen', () => {
|
||||
expect(sanitizeSlug('Hello World!')).toBe('helloworld')
|
||||
expect(sanitizeSlug('hello---world')).toBe('hello-world')
|
||||
})
|
||||
it('caps length at 50', () => {
|
||||
const result = sanitizeSlug('a'.repeat(100))
|
||||
expect(result.length).toBeLessThanOrEqual(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasSqlInjectionPatterns', () => {
|
||||
it('flags common keywords', () => {
|
||||
expect(hasSqlInjectionPatterns("'; DROP TABLE users; --")).toBe(true)
|
||||
expect(hasSqlInjectionPatterns('OR 1=1')).toBe(true)
|
||||
})
|
||||
it('does not flag normal text', () => {
|
||||
expect(hasSqlInjectionPatterns('Hello, world')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidUuid', () => {
|
||||
it('accepts a v4 UUID', () => {
|
||||
expect(isValidUuid('11111111-2222-4333-8444-555555555555')).toBe(true)
|
||||
})
|
||||
it('rejects garbage', () => {
|
||||
expect(isValidUuid('hello')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('truncate', () => {
|
||||
it('shortens and appends ellipsis', () => {
|
||||
expect(truncate('hello world', 8)).toBe('hello...')
|
||||
})
|
||||
it('returns the original if under the limit', () => {
|
||||
expect(truncate('hello', 50)).toBe('hello')
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkRateLimit', () => {
|
||||
beforeEach(() => {
|
||||
// Reset the in-memory map between tests by churning fresh identifiers.
|
||||
})
|
||||
|
||||
it('allows requests under the limit', () => {
|
||||
const id = `t1-${Date.now()}`
|
||||
expect(checkRateLimit(id, 3, 60000).allowed).toBe(true)
|
||||
expect(checkRateLimit(id, 3, 60000).allowed).toBe(true)
|
||||
expect(checkRateLimit(id, 3, 60000).allowed).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks once the limit is hit', () => {
|
||||
const id = `t2-${Date.now()}`
|
||||
checkRateLimit(id, 2, 60000)
|
||||
checkRateLimit(id, 2, 60000)
|
||||
expect(checkRateLimit(id, 2, 60000).allowed).toBe(false)
|
||||
})
|
||||
|
||||
it('reports remaining count', () => {
|
||||
const id = `t3-${Date.now()}`
|
||||
expect(checkRateLimit(id, 3, 60000).remaining).toBe(2)
|
||||
expect(checkRateLimit(id, 3, 60000).remaining).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateContentLength', () => {
|
||||
it('passes when length is in range', () => {
|
||||
expect(validateContentLength('hello', 1, 10).valid).toBe(true)
|
||||
})
|
||||
it('fails when too short', () => {
|
||||
expect(validateContentLength('', 1, 10).valid).toBe(false)
|
||||
})
|
||||
it('fails when too long', () => {
|
||||
expect(validateContentLength('x'.repeat(11), 1, 10).valid).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,327 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
flexibleDateTime,
|
||||
createTaskSchema,
|
||||
taskIdSchema,
|
||||
createProjectSchema,
|
||||
updateProjectStatusSchema,
|
||||
addMemberSchema,
|
||||
updateMemberSchema,
|
||||
createContactSchema,
|
||||
updateContactSchema,
|
||||
createKbEntrySchema,
|
||||
updateKbEntrySchema,
|
||||
createSecretSchema,
|
||||
logCashPaymentSchema,
|
||||
linkPaymentSchema,
|
||||
updateSpaceSettingsSchema,
|
||||
saveIntegrationSchema,
|
||||
upsertAreaLeadSchema,
|
||||
uuidSchema,
|
||||
} from '@/lib/validations'
|
||||
|
||||
const VALID_UUID = '11111111-2222-3333-4444-555555555555'
|
||||
|
||||
describe('flexibleDateTime', () => {
|
||||
const schema = z.object({ when: flexibleDateTime().optional() })
|
||||
|
||||
it('accepts a YYYY-MM-DD date-only string from <input type="date">', () => {
|
||||
const r = schema.safeParse({ when: '2026-05-22' })
|
||||
expect(r.success).toBe(true)
|
||||
if (r.success) {
|
||||
expect(r.data.when).toBe('2026-05-22T00:00:00.000Z')
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts a YYYY-MM-DDTHH:MM datetime-local string', () => {
|
||||
const r = schema.safeParse({ when: '2026-05-22T14:30' })
|
||||
expect(r.success).toBe(true)
|
||||
if (r.success && r.data.when) {
|
||||
// JS Date interprets "2026-05-22T14:30" as local time; the helper
|
||||
// outputs UTC ISO. We just assert it's a valid ISO datetime.
|
||||
expect(r.data.when).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('passes through a full RFC 3339 datetime unchanged', () => {
|
||||
const iso = '2026-05-22T14:30:00.000Z'
|
||||
const r = schema.safeParse({ when: iso })
|
||||
expect(r.success).toBe(true)
|
||||
if (r.success) expect(r.data.when).toBe(iso)
|
||||
})
|
||||
|
||||
it('maps empty string to null (treats it as unset)', () => {
|
||||
const r = schema.safeParse({ when: '' })
|
||||
expect(r.success).toBe(true)
|
||||
if (r.success) expect(r.data.when).toBeNull()
|
||||
})
|
||||
|
||||
it('maps undefined to undefined / null', () => {
|
||||
const r = schema.safeParse({})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects obvious garbage', () => {
|
||||
const r = schema.safeParse({ when: 'tomorrow at three' })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createTaskSchema', () => {
|
||||
it('accepts minimal valid input', () => {
|
||||
const r = createTaskSchema.safeParse({ title: 'Fix laser cooling' })
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty title', () => {
|
||||
const r = createTaskSchema.safeParse({ title: '' })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects title over 200 chars', () => {
|
||||
const r = createTaskSchema.safeParse({ title: 'x'.repeat(201) })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts every database recurrence value, including biweekly', () => {
|
||||
for (const recurrence of ['none', 'daily', 'weekly', 'biweekly', 'monthly']) {
|
||||
const r = createTaskSchema.safeParse({ title: 'x', recurrence })
|
||||
expect(r.success, `recurrence=${recurrence}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts due_date as YYYY-MM-DD (the bug fix from pass 9)', () => {
|
||||
const r = createTaskSchema.safeParse({ title: 'x', due_date: '2026-05-22' })
|
||||
expect(r.success).toBe(true)
|
||||
if (r.success) expect(r.data.due_date).toBe('2026-05-22T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('rejects unknown recurrence values', () => {
|
||||
const r = createTaskSchema.safeParse({ title: 'x', recurrence: 'fortnightly' })
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts task and chore as type, rejects other strings', () => {
|
||||
expect(createTaskSchema.safeParse({ title: 'x', type: 'task' }).success).toBe(true)
|
||||
expect(createTaskSchema.safeParse({ title: 'x', type: 'chore' }).success).toBe(true)
|
||||
expect(createTaskSchema.safeParse({ title: 'x', type: 'errand' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('taskIdSchema', () => {
|
||||
it('accepts a valid UUID', () => {
|
||||
expect(taskIdSchema.safeParse(VALID_UUID).success).toBe(true)
|
||||
})
|
||||
it('rejects non-UUID strings', () => {
|
||||
expect(taskIdSchema.safeParse('not-a-uuid').success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uuidSchema', () => {
|
||||
it('accepts a valid UUID', () => {
|
||||
expect(uuidSchema.safeParse(VALID_UUID).success).toBe(true)
|
||||
})
|
||||
it('rejects empty string', () => {
|
||||
expect(uuidSchema.safeParse('').success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createProjectSchema', () => {
|
||||
it('accepts title only', () => {
|
||||
expect(createProjectSchema.safeParse({ title: 'Build CNC mount' }).success).toBe(true)
|
||||
})
|
||||
it('accepts optional tags', () => {
|
||||
expect(
|
||||
createProjectSchema.safeParse({ title: 'x', tags: ['fab', 'urgent'] }).success,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateProjectStatusSchema', () => {
|
||||
it('accepts every real project_status enum value', () => {
|
||||
for (const status of ['backlog', 'in_progress', 'review', 'done', 'blocked']) {
|
||||
const r = updateProjectStatusSchema.safeParse({ projectId: VALID_UUID, status })
|
||||
expect(r.success, `status=${status}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects legacy status names that the database does not have', () => {
|
||||
for (const status of ['planning', 'active', 'paused', 'completed', 'cancelled']) {
|
||||
const r = updateProjectStatusSchema.safeParse({ projectId: VALID_UUID, status })
|
||||
expect(r.success, `status=${status}`).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects invalid projectId', () => {
|
||||
expect(
|
||||
updateProjectStatusSchema.safeParse({ projectId: 'nope', status: 'done' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addMemberSchema', () => {
|
||||
it('accepts the associate role (added in pass 2)', () => {
|
||||
const r = addMemberSchema.safeParse({
|
||||
email: 'a@b.com',
|
||||
display_name: 'Alex',
|
||||
role: 'associate',
|
||||
tier: 'basic',
|
||||
})
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unknown role', () => {
|
||||
const r = addMemberSchema.safeParse({
|
||||
email: 'a@b.com',
|
||||
display_name: 'Alex',
|
||||
role: 'overlord',
|
||||
tier: 'basic',
|
||||
})
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects invalid email', () => {
|
||||
const r = addMemberSchema.safeParse({
|
||||
email: 'not-an-email',
|
||||
display_name: 'Alex',
|
||||
})
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateMemberSchema', () => {
|
||||
it('accepts a partial update with just memberId', () => {
|
||||
expect(
|
||||
updateMemberSchema.safeParse({ memberId: VALID_UUID }).success,
|
||||
).toBe(true)
|
||||
})
|
||||
it('rejects unknown status', () => {
|
||||
expect(
|
||||
updateMemberSchema.safeParse({ memberId: VALID_UUID, status: 'banned' }).success,
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createContactSchema', () => {
|
||||
it('accepts every database contact_type', () => {
|
||||
for (const t of ['vendor', 'supplier', 'partner', 'landlord', 'city']) {
|
||||
const r = createContactSchema.safeParse({ name: 'X', contact_type: t })
|
||||
expect(r.success, `contact_type=${t}`).toBe(true)
|
||||
}
|
||||
})
|
||||
it('rejects legacy contact_type values', () => {
|
||||
for (const t of ['sponsor', 'media', 'other']) {
|
||||
const r = createContactSchema.safeParse({ name: 'X', contact_type: t })
|
||||
expect(r.success, `contact_type=${t}`).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateContactSchema', () => {
|
||||
it('requires contactId', () => {
|
||||
expect(updateContactSchema.safeParse({ name: 'X' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createKbEntrySchema', () => {
|
||||
it('accepts the real visibility enum', () => {
|
||||
for (const v of ['all_members', 'board', 'admin_only']) {
|
||||
const r = createKbEntrySchema.safeParse({ title: 't', content: 'c', visibility: v })
|
||||
expect(r.success, `visibility=${v}`).toBe(true)
|
||||
}
|
||||
})
|
||||
it('rejects legacy visibility values', () => {
|
||||
for (const v of ['public', 'members', 'admin']) {
|
||||
const r = createKbEntrySchema.safeParse({ title: 't', content: 'c', visibility: v })
|
||||
expect(r.success, `visibility=${v}`).toBe(false)
|
||||
}
|
||||
})
|
||||
it('defaults visibility to all_members', () => {
|
||||
const r = createKbEntrySchema.safeParse({ title: 't', content: 'c' })
|
||||
expect(r.success).toBe(true)
|
||||
if (r.success) expect(r.data.visibility).toBe('all_members')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateKbEntrySchema', () => {
|
||||
it('requires entryId', () => {
|
||||
expect(updateKbEntrySchema.safeParse({ title: 't' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSecretSchema', () => {
|
||||
it('requires title and value', () => {
|
||||
expect(createSecretSchema.safeParse({ title: 't', value: 'v' }).success).toBe(true)
|
||||
expect(createSecretSchema.safeParse({ title: 't' }).success).toBe(false)
|
||||
expect(createSecretSchema.safeParse({ value: 'v' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('logCashPaymentSchema', () => {
|
||||
it('accepts a positive amount and a non-empty note', () => {
|
||||
const r = logCashPaymentSchema.safeParse({ amount: 25, from_note: 'May dues' })
|
||||
expect(r.success).toBe(true)
|
||||
})
|
||||
it('rejects zero or negative amounts', () => {
|
||||
expect(logCashPaymentSchema.safeParse({ amount: 0, from_note: 'x' }).success).toBe(false)
|
||||
expect(logCashPaymentSchema.safeParse({ amount: -5, from_note: 'x' }).success).toBe(false)
|
||||
})
|
||||
it('member_id must be a UUID if present', () => {
|
||||
expect(
|
||||
logCashPaymentSchema.safeParse({ amount: 1, from_note: 'x', member_id: 'nope' }).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
logCashPaymentSchema.safeParse({ amount: 1, from_note: 'x', member_id: VALID_UUID }).success,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('linkPaymentSchema', () => {
|
||||
it('requires both UUIDs', () => {
|
||||
expect(
|
||||
linkPaymentSchema.safeParse({ paymentId: VALID_UUID, memberId: VALID_UUID }).success,
|
||||
).toBe(true)
|
||||
expect(linkPaymentSchema.safeParse({ paymentId: 'x', memberId: VALID_UUID }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateSpaceSettingsSchema', () => {
|
||||
it('accepts a partial update', () => {
|
||||
expect(updateSpaceSettingsSchema.safeParse({ name: 'New name' }).success).toBe(true)
|
||||
})
|
||||
it('enforces slug character set when slug is present', () => {
|
||||
expect(updateSpaceSettingsSchema.safeParse({ slug: 'good-slug' }).success).toBe(false)
|
||||
expect(updateSpaceSettingsSchema.safeParse({ slug: 'goodslug' }).success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveIntegrationSchema', () => {
|
||||
it('requires platform and a config record', () => {
|
||||
expect(
|
||||
saveIntegrationSchema.safeParse({ platform: 'paypal', config: { client_id: 'x' } }).success,
|
||||
).toBe(true)
|
||||
expect(saveIntegrationSchema.safeParse({ platform: 'paypal' }).success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('upsertAreaLeadSchema', () => {
|
||||
it('accepts the real area_lead_status values', () => {
|
||||
for (const s of ['active', 'vacant', 'handoff']) {
|
||||
const r = upsertAreaLeadSchema.safeParse({
|
||||
area_code: 'LASER',
|
||||
area_name: 'Laser',
|
||||
status: s,
|
||||
})
|
||||
expect(r.success, `status=${s}`).toBe(true)
|
||||
}
|
||||
})
|
||||
it('rejects unknown status', () => {
|
||||
const r = upsertAreaLeadSchema.safeParse({
|
||||
area_code: 'LASER',
|
||||
area_name: 'Laser',
|
||||
status: 'pending',
|
||||
})
|
||||
expect(r.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -26,7 +26,7 @@ export default async function DashboardPage() {
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members')
|
||||
.select('space_id, display_name, role')
|
||||
.select('id, space_id, display_name, role')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.maybeSingle()
|
||||
@@ -34,6 +34,8 @@ export default async function DashboardPage() {
|
||||
if (!member?.space_id) redirect('/signup')
|
||||
|
||||
const spaceId = member.space_id
|
||||
const memberId = (member as { id: string }).id
|
||||
const isAdminOrBoard = member.role === 'admin' || member.role === 'board'
|
||||
|
||||
const [
|
||||
{ count: activeMembers },
|
||||
@@ -42,15 +44,47 @@ export default async function DashboardPage() {
|
||||
{ data: tasks },
|
||||
{ data: projects },
|
||||
{ data: activity },
|
||||
{ data: openProposalsRaw },
|
||||
{ data: myVotesRaw },
|
||||
{ data: pendingIncidentsRaw },
|
||||
] = await Promise.all([
|
||||
supabase.from('space_members').select('*', { count: 'exact', head: true }).eq('space_id', spaceId).eq('status', 'current'),
|
||||
supabase.from('tasks').select('*', { count: 'exact', head: true }).eq('space_id', spaceId).in('status', ['open', 'claimed', 'in_progress']),
|
||||
supabase.from('payments').select('*', { count: 'exact', head: true }).eq('space_id', spaceId).eq('link_status', 'unlinked'),
|
||||
supabase.from('tasks').select('*').eq('space_id', spaceId).neq('status', 'done').order('due_date', { ascending: true, nullsFirst: false }).limit(5),
|
||||
supabase.from('tasks').select('*').eq('space_id', spaceId).in('status', ['open', 'claimed', 'in_progress', 'overdue', 'due_today', 'blocked']).order('due_date', { ascending: true, nullsFirst: false }).limit(5),
|
||||
supabase.from('projects').select('*').eq('space_id', spaceId).in('status', ['in_progress', 'blocked', 'review']).limit(3),
|
||||
supabase.from('activity_log').select('*').eq('space_id', spaceId).order('created_at', { ascending: false }).limit(6),
|
||||
supabase.from('proposals').select('*').eq('space_id', spaceId).eq('status', 'open').order('voting_closes_at', { ascending: true, nullsFirst: false }).limit(10),
|
||||
supabase.from('proposal_votes').select('proposal_id').eq('member_id', memberId),
|
||||
isAdminOrBoard
|
||||
? supabase.from('incidents').select('*').eq('space_id', spaceId).in('status', ['received', 'under_review']).order('created_at', { ascending: false }).limit(5)
|
||||
: Promise.resolve({ data: [] as unknown[] }),
|
||||
])
|
||||
|
||||
const votedProposalIds = new Set(
|
||||
((myVotesRaw ?? []) as Array<{ proposal_id: string }>).map(v => v.proposal_id),
|
||||
)
|
||||
type ProposalSummary = {
|
||||
id: string
|
||||
title: string
|
||||
voting_closes_at: string | null
|
||||
proposal_type: string
|
||||
quorum_required: number
|
||||
total_voters: number
|
||||
quorum_met: boolean | null
|
||||
}
|
||||
const pendingProposals = ((openProposalsRaw ?? []) as unknown as ProposalSummary[])
|
||||
.filter(p => !votedProposalIds.has(p.id))
|
||||
.slice(0, 5)
|
||||
const pendingIncidents = (pendingIncidentsRaw ?? []) as unknown as Array<{
|
||||
id: string
|
||||
title: string
|
||||
severity: string
|
||||
status: string
|
||||
sla_response_by: string | null
|
||||
created_at: string
|
||||
}>
|
||||
|
||||
const overdueTasks = (tasks ?? []).filter((t) => t.due_date && new Date(t.due_date) < new Date())
|
||||
|
||||
const stats = [
|
||||
@@ -84,6 +118,70 @@ export default async function DashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(pendingProposals.length > 0 || pendingIncidents.length > 0) && (
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{pendingProposals.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Open proposals awaiting your vote</p>
|
||||
<Link href="/proposals" className="font-sans text-xs text-primary hover:underline">All</Link>
|
||||
</div>
|
||||
<div className="bg-card rounded border border-border divide-y divide-border">
|
||||
{pendingProposals.map(p => {
|
||||
const totalCounted = p.total_voters
|
||||
const quorumPct = p.quorum_required > 0
|
||||
? Math.min(100, Math.round((totalCounted / p.quorum_required) * 100))
|
||||
: 0
|
||||
return (
|
||||
<Link key={p.id} href={`/proposals/${p.id}`} className="block px-4 py-3 hover:bg-muted transition">
|
||||
<p className="font-sans text-sm text-foreground truncate">{p.title}</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
|
||||
{p.proposal_type.replace(/_/g, ' ')}
|
||||
{p.voting_closes_at ? ` · closes ${new Date(p.voting_closes_at).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}` : ''}
|
||||
{' · '}
|
||||
<span className={p.quorum_met ? 'text-primary' : 'text-orange-600'}>
|
||||
quorum {quorumPct}%
|
||||
</span>
|
||||
</p>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdminOrBoard && pendingIncidents.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Incidents needing response</p>
|
||||
<Link href="/incidents" className="font-sans text-xs text-primary hover:underline">All</Link>
|
||||
</div>
|
||||
<div className="bg-card rounded border border-border divide-y divide-border">
|
||||
{pendingIncidents.map(i => {
|
||||
const overdueSla = i.sla_response_by && new Date(i.sla_response_by) < new Date()
|
||||
return (
|
||||
<Link key={i.id} href={`/incidents/${i.id}`} className="block px-4 py-3 hover:bg-muted transition">
|
||||
<p className="font-sans text-sm text-foreground truncate">{i.title}</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
|
||||
{i.severity} · {i.status.replace(/_/g, ' ')}
|
||||
{i.sla_response_by && (
|
||||
<>
|
||||
{' · '}
|
||||
<span className={overdueSla ? 'text-red-600' : 'text-muted-foreground'}>
|
||||
respond by {new Date(i.sla_response_by).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid lg:grid-cols-[1fr_280px] gap-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { FinancialVisibility, Payment, Space } from '@/lib/types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const DOLLAR = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
|
||||
|
||||
function isVisible(viz: FinancialVisibility, role: string | null): boolean {
|
||||
if (viz === 'all_members_visible') return true
|
||||
if (viz === 'board_visible') return role === 'admin' || role === 'board' || role === 'treasurer'
|
||||
// treasurer_only
|
||||
return role === 'admin' || role === 'board' || role === 'treasurer'
|
||||
}
|
||||
|
||||
export default async function FinancialsPage() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members')
|
||||
.select('space_id, role')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.maybeSingle()
|
||||
if (!member?.space_id) redirect('/signup')
|
||||
|
||||
const { data: spaceRaw } = await supabase
|
||||
.from('spaces')
|
||||
.select('id, name, financial_visibility')
|
||||
.eq('id', member.space_id)
|
||||
.maybeSingle()
|
||||
const space = spaceRaw as unknown as Pick<Space, 'id' | 'name'> & { financial_visibility: FinancialVisibility } | null
|
||||
|
||||
if (!space) {
|
||||
return <PageShell title="Financials"><p className="font-sans text-sm">Space not found.</p></PageShell>
|
||||
}
|
||||
|
||||
if (!isVisible(space.financial_visibility, member.role)) {
|
||||
return (
|
||||
<PageShell title="Financials">
|
||||
<div className="bg-card rounded border border-border p-6 text-center">
|
||||
<p className="font-sans text-sm text-foreground mb-2">Financial data is not visible to your role.</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">
|
||||
Visibility is currently set to <strong>{space.financial_visibility.replace(/_/g, ' ')}</strong>.
|
||||
Ask an admin to change it from Settings if your space wants broader transparency.
|
||||
</p>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
const { data: paymentsRaw } = await supabase
|
||||
.from('payments')
|
||||
.select('*')
|
||||
.eq('space_id', member.space_id)
|
||||
.order('transaction_date', { ascending: false })
|
||||
|
||||
const payments = (paymentsRaw ?? []) as unknown as Payment[]
|
||||
|
||||
const now = new Date()
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const startOfYear = new Date(now.getFullYear(), 0, 1)
|
||||
|
||||
let lifetime = 0
|
||||
let thisYear = 0
|
||||
let thisMonth = 0
|
||||
let linked = 0
|
||||
let unlinked = 0
|
||||
const byPlatform: Record<string, number> = {}
|
||||
const byMonth: Record<string, number> = {}
|
||||
|
||||
for (const p of payments) {
|
||||
const amount = Number(p.amount ?? 0)
|
||||
lifetime += amount
|
||||
const tx = p.transaction_date ? new Date(p.transaction_date) : null
|
||||
if (tx) {
|
||||
if (tx >= startOfYear) thisYear += amount
|
||||
if (tx >= startOfMonth) thisMonth += amount
|
||||
const key = `${tx.getFullYear()}-${String(tx.getMonth() + 1).padStart(2, '0')}`
|
||||
byMonth[key] = (byMonth[key] ?? 0) + amount
|
||||
}
|
||||
if (p.link_status === 'linked') linked++
|
||||
else unlinked++
|
||||
const platform = String(p.platform ?? 'unknown')
|
||||
byPlatform[platform] = (byPlatform[platform] ?? 0) + amount
|
||||
}
|
||||
|
||||
const monthlyRows = Object.entries(byMonth)
|
||||
.sort(([a], [b]) => b.localeCompare(a))
|
||||
.slice(0, 12)
|
||||
|
||||
const platformRows = Object.entries(byPlatform).sort(([, a], [, b]) => b - a)
|
||||
const maxPlatform = Math.max(1, ...platformRows.map(([, v]) => v))
|
||||
|
||||
return (
|
||||
<PageShell title={`Financials — ${space.name}`}>
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-2">
|
||||
Visibility: {space.financial_visibility.replace(/_/g, ' ')}
|
||||
{member.role === 'admin' && (
|
||||
<>
|
||||
{' · '}
|
||||
<Link href="/settings" className="text-primary hover:underline normal-case tracking-normal">
|
||||
change in Settings
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4">
|
||||
<StatCard label="This Month" value={DOLLAR.format(thisMonth)} sub="received" />
|
||||
<StatCard label="This Year" value={DOLLAR.format(thisYear)} sub={`${now.getFullYear()}`} />
|
||||
<StatCard label="Lifetime" value={DOLLAR.format(lifetime)} sub="all payments" />
|
||||
<StatCard
|
||||
label="Reconciliation"
|
||||
value={`${linked}/${linked + unlinked}`}
|
||||
sub={`${unlinked} unlinked`}
|
||||
warn={unlinked > 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="bg-card rounded border border-border p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
||||
By platform
|
||||
</p>
|
||||
{platformRows.length === 0 ? (
|
||||
<p className="font-sans text-sm text-muted-foreground">No payments recorded yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{platformRows.map(([platform, total]) => {
|
||||
const pct = Math.round((total / maxPlatform) * 100)
|
||||
return (
|
||||
<li key={platform}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-sans text-sm capitalize text-foreground">{platform}</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">{DOLLAR.format(total)}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary rounded-full" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="bg-card rounded border border-border p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
||||
Last 12 months
|
||||
</p>
|
||||
{monthlyRows.length === 0 ? (
|
||||
<p className="font-sans text-sm text-muted-foreground">No monthly breakdown yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{monthlyRows.map(([month, total]) => {
|
||||
const [y, m] = month.split('-')
|
||||
const label = new Date(Number(y), Number(m) - 1, 1).toLocaleDateString('en-US', { year: 'numeric', month: 'short' })
|
||||
return (
|
||||
<div key={month} className="flex items-center justify-between bg-background rounded border border-border px-3 py-2">
|
||||
<span className="font-sans text-sm text-foreground">{label}</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">{DOLLAR.format(total)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
function PageShell({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3">
|
||||
<h1 className="text-white font-sans text-lg font-semibold">{title}</h1>
|
||||
</div>
|
||||
<div className="p-4 md:p-6 space-y-6 max-w-4xl">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub, warn }: { label: string; value: string; sub: string; warn?: boolean }) {
|
||||
return (
|
||||
<div className="bg-card rounded border border-border p-4 md:p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-2">{label}</p>
|
||||
<p className={`text-2xl font-sans font-bold ${warn ? 'text-orange-500' : 'text-primary'}`}>{value}</p>
|
||||
<p className="font-sans text-xs text-muted-foreground mt-1">{sub}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
updateIncidentStatus,
|
||||
addIncidentUpdate,
|
||||
appealIncident,
|
||||
} from '@/lib/actions'
|
||||
import type { IncidentStatus } from '@/lib/types'
|
||||
import { incidentStatuses, incidentUpdateVisibilities } from '@/lib/validations'
|
||||
|
||||
type Props = {
|
||||
incidentId: string
|
||||
status: IncidentStatus
|
||||
isAdminOrBoard: boolean
|
||||
isReporter: boolean
|
||||
hasAppeal: boolean
|
||||
appealProposalId: string | null
|
||||
}
|
||||
|
||||
export function IncidentActions({
|
||||
incidentId,
|
||||
status,
|
||||
isAdminOrBoard,
|
||||
isReporter,
|
||||
hasAppeal,
|
||||
appealProposalId,
|
||||
}: Props) {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Add-update form
|
||||
const [updateBody, setUpdateBody] = useState('')
|
||||
const [updateVisibility, setUpdateVisibility] = useState<string>('all_parties')
|
||||
|
||||
// Status transition form (admin/board)
|
||||
const [newStatus, setNewStatus] = useState<IncidentStatus>(status)
|
||||
const [disposition, setDisposition] = useState('')
|
||||
|
||||
// Appeal form (reporter)
|
||||
const [appealTitle, setAppealTitle] = useState('')
|
||||
const [appealBody, setAppealBody] = useState('')
|
||||
|
||||
function run<T>(fn: () => Promise<T>, after?: () => void) {
|
||||
setError('')
|
||||
startTransition(async () => {
|
||||
const r = (await fn()) as unknown as { error?: string }
|
||||
if (r && r.error) setError(r.error)
|
||||
else after?.()
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
const canAppeal = isReporter && status === 'decided' && !hasAppeal
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{isAdminOrBoard && (
|
||||
<div className="bg-card rounded border border-border p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
||||
Update status
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={newStatus}
|
||||
onChange={e => setNewStatus(e.target.value as IncidentStatus)}
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
>
|
||||
{incidentStatuses.map(s => (
|
||||
<option key={s} value={s}>{s.replace(/_/g, ' ')}</option>
|
||||
))}
|
||||
</select>
|
||||
{(newStatus === 'decided' || newStatus === 'closed') && (
|
||||
<textarea
|
||||
value={disposition}
|
||||
onChange={e => setDisposition(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={20000}
|
||||
placeholder="Disposition / reasoning (visible to reporter)"
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending || newStatus === status}
|
||||
onClick={() =>
|
||||
run(() => updateIncidentStatus(incidentId, newStatus, disposition || null), () => setDisposition(''))
|
||||
}
|
||||
className="bg-primary text-white font-sans text-sm font-medium px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
|
||||
>
|
||||
{isPending ? 'Saving...' : 'Apply status'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-card rounded border border-border p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
||||
Post an update
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<textarea
|
||||
value={updateBody}
|
||||
onChange={e => setUpdateBody(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={20000}
|
||||
placeholder="Add information, ask a question, share a decision."
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
{isAdminOrBoard && (
|
||||
<select
|
||||
value={updateVisibility}
|
||||
onChange={e => setUpdateVisibility(e.target.value)}
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
>
|
||||
{incidentUpdateVisibilities.map(v => (
|
||||
<option key={v} value={v}>{v.replace(/_/g, ' ')}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending || !updateBody.trim()}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => addIncidentUpdate({ incidentId, body: updateBody, visibility: updateVisibility }),
|
||||
() => setUpdateBody(''),
|
||||
)
|
||||
}
|
||||
className="bg-primary text-white font-sans text-sm font-medium px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
|
||||
>
|
||||
{isPending ? 'Posting...' : 'Post update'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canAppeal && (
|
||||
<div className="bg-card rounded border border-border p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
||||
Request membership appeal
|
||||
</p>
|
||||
<p className="font-sans text-xs text-muted-foreground mb-3">
|
||||
Creates a draft proposal that members can vote on. You will still need to open it
|
||||
for voting; that gives you time to refine the language.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={appealTitle}
|
||||
onChange={e => setAppealTitle(e.target.value)}
|
||||
maxLength={200}
|
||||
placeholder="Appeal of dismissed complaint"
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
<textarea
|
||||
value={appealBody}
|
||||
onChange={e => setAppealBody(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={20000}
|
||||
placeholder="Why the membership should overturn the board's decision."
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending || !appealTitle.trim()}
|
||||
onClick={() => run(() => appealIncident({ incidentId, title: appealTitle, body: appealBody }))}
|
||||
className="bg-primary text-white font-sans text-sm font-medium px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
|
||||
>
|
||||
{isPending ? 'Filing appeal...' : 'File appeal'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAppeal && appealProposalId && (
|
||||
<div className="bg-card rounded border border-border p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-2">Appeal</p>
|
||||
<Link href={`/proposals/${appealProposalId}`} className="font-sans text-sm text-primary hover:underline">
|
||||
View the appeal proposal →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="font-mono text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import Link from 'next/link'
|
||||
import { redirect, notFound } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { Incident, IncidentUpdateRow } from '@/lib/types'
|
||||
import { IncidentActions } from './incident-actions'
|
||||
import { MarkdownBody } from '@/components/markdown'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function IncidentDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members')
|
||||
.select('id, space_id, role, display_name')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.maybeSingle()
|
||||
if (!member?.space_id) redirect('/signup')
|
||||
|
||||
const { data: incidentRaw } = await supabase
|
||||
.from('incidents')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('space_id', member.space_id)
|
||||
.maybeSingle()
|
||||
if (!incidentRaw) notFound()
|
||||
|
||||
const incident = incidentRaw as unknown as Incident
|
||||
const isAdminOrBoard = member.role === 'admin' || member.role === 'board'
|
||||
const isReporter = incident.reporter_id === member.id
|
||||
|
||||
const { data: updatesRaw } = await supabase
|
||||
.from('incident_updates')
|
||||
.select('*')
|
||||
.eq('incident_id', incident.id)
|
||||
.order('created_at', { ascending: true })
|
||||
|
||||
const updates = (updatesRaw ?? []) as unknown as IncidentUpdateRow[]
|
||||
|
||||
const decisionMakers = incident.decision_maker_ids.length
|
||||
? await supabase
|
||||
.from('space_members')
|
||||
.select('id, display_name')
|
||||
.in('id', incident.decision_maker_ids)
|
||||
: { data: [] as Array<{ id: string; display_name: string | null }> }
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center gap-3">
|
||||
<Link href="/incidents" className="text-white/70 hover:text-white font-sans text-sm">
|
||||
← Incidents
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="p-4 md:p-6 max-w-3xl space-y-6">
|
||||
<header className="bg-card rounded border border-border p-5">
|
||||
<h1 className="font-sans text-xl font-semibold text-foreground mb-2">{incident.title}</h1>
|
||||
<p className="font-mono text-[10px] text-muted-foreground mb-4">
|
||||
{incident.category} · {incident.severity} · status: {incident.status.replace(/_/g, ' ')}
|
||||
{incident.is_anonymous ? ' · filed anonymously' : ''}
|
||||
{' · '}
|
||||
filed {new Date(incident.created_at).toLocaleString()}
|
||||
</p>
|
||||
<MarkdownBody content={incident.body} />
|
||||
</header>
|
||||
|
||||
{incident.disposition && (
|
||||
<section className="bg-card rounded border border-border p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
||||
Disposition
|
||||
</p>
|
||||
<MarkdownBody content={incident.disposition} />
|
||||
{decisionMakers.data && decisionMakers.data.length > 0 && (
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-3">
|
||||
Decision: {decisionMakers.data.map(m => m.display_name ?? 'Unknown').join(', ')}
|
||||
{incident.decided_at ? ` · ${new Date(incident.decided_at).toLocaleString()}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="bg-card rounded border border-border p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
||||
Updates ({updates.length})
|
||||
</p>
|
||||
{updates.length === 0 ? (
|
||||
<p className="font-sans text-sm text-muted-foreground">No updates posted yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{updates.map(u => (
|
||||
<li key={u.id} className="py-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-sans text-sm font-medium text-foreground">{u.author_name ?? 'Unknown'}</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
{u.visibility.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] text-muted-foreground/60 ml-auto">
|
||||
{new Date(u.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<MarkdownBody content={u.body} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<IncidentActions
|
||||
incidentId={incident.id}
|
||||
status={incident.status}
|
||||
isAdminOrBoard={isAdminOrBoard}
|
||||
isReporter={isReporter}
|
||||
hasAppeal={incident.appeal_proposal_id !== null}
|
||||
appealProposalId={incident.appeal_proposal_id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { fileIncident } from '@/lib/actions'
|
||||
import { incidentSeverities } from '@/lib/validations'
|
||||
|
||||
type Member = { id: string; display_name: string | null }
|
||||
|
||||
export function IncidentForm({ members }: { members: Member[] }) {
|
||||
const router = useRouter()
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [category, setCategory] = useState('general')
|
||||
const [severity, setSeverity] = useState<string>('medium')
|
||||
const [subjects, setSubjects] = useState<string[]>([])
|
||||
const [isAnonymous, setIsAnonymous] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
|
||||
function toggleSubject(id: string) {
|
||||
setSubjects(prev => (prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]))
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
|
||||
const result = await fileIncident({
|
||||
title,
|
||||
body,
|
||||
category,
|
||||
severity,
|
||||
subjects,
|
||||
is_anonymous: isAnonymous,
|
||||
})
|
||||
|
||||
if ('error' in result && result.error) {
|
||||
setError(result.error)
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (isAnonymous && 'token' in result && result.token) {
|
||||
setToken(result.token as string)
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
if ('data' in result && result.data) {
|
||||
router.push(`/incidents/${(result.data as { id: string }).id}`)
|
||||
} else {
|
||||
router.push('/incidents')
|
||||
}
|
||||
}
|
||||
|
||||
if (token) {
|
||||
return (
|
||||
<div className="bg-card rounded border border-border p-5 space-y-3">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Report filed anonymously</p>
|
||||
<p className="font-sans text-sm text-foreground">
|
||||
Save this tracking token. It is the only way to look the report up later. We never email
|
||||
or display it elsewhere.
|
||||
</p>
|
||||
<pre className="bg-background border border-border rounded p-3 font-mono text-sm break-all">{token}</pre>
|
||||
<Link
|
||||
href="/incidents"
|
||||
className="inline-block bg-primary text-white font-sans text-sm font-medium px-4 py-2 rounded hover:bg-primary/90 transition"
|
||||
>
|
||||
Done
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5 bg-card rounded border border-border p-5">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
maxLength={200}
|
||||
required
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
placeholder="Brief summary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
What happened
|
||||
</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
rows={8}
|
||||
maxLength={20000}
|
||||
required
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
placeholder="When, where, who was present, what was said or done."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Category
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value)}
|
||||
maxLength={50}
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
placeholder="general, safety, harassment, theft..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Severity
|
||||
</label>
|
||||
<select
|
||||
value={severity}
|
||||
onChange={e => setSeverity(e.target.value)}
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
>
|
||||
{incidentSeverities.map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-2">
|
||||
Subjects (optional)
|
||||
</label>
|
||||
<div className="bg-background border border-border rounded p-3 max-h-48 overflow-y-auto">
|
||||
{members.length === 0 ? (
|
||||
<p className="font-sans text-xs text-muted-foreground">No other members in the space.</p>
|
||||
) : (
|
||||
members.map(m => (
|
||||
<label key={m.id} className="flex items-center gap-2 py-1 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={subjects.includes(m.id)}
|
||||
onChange={() => toggleSubject(m.id)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="font-sans">{m.display_name ?? 'Unnamed'}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAnonymous}
|
||||
onChange={e => setIsAnonymous(e.target.checked)}
|
||||
className="rounded border-border mt-0.5"
|
||||
/>
|
||||
<span className="font-sans">
|
||||
File anonymously. Your name is omitted from the record. You will get a tracking
|
||||
token in exchange. Be aware: an admin with direct database access can still see
|
||||
the row exists; this is not cryptographic anonymity.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="font-mono text-xs text-red-500">{error}</p>}
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Link
|
||||
href="/incidents"
|
||||
className="font-sans text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !title.trim() || !body.trim()}
|
||||
className="bg-primary text-white font-sans text-sm font-medium px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'File report'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { IncidentForm } from './incident-form'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function NewIncidentPage() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members')
|
||||
.select('space_id')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.maybeSingle()
|
||||
if (!member?.space_id) redirect('/signup')
|
||||
|
||||
// Pull members in the same space so the reporter can name subjects.
|
||||
const { data: members } = await supabase
|
||||
.from('space_members')
|
||||
.select('id, display_name')
|
||||
.eq('space_id', member.space_id)
|
||||
.order('display_name')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3">
|
||||
<h1 className="text-white font-sans text-lg font-semibold">File a report</h1>
|
||||
</div>
|
||||
<div className="p-4 md:p-6 max-w-2xl">
|
||||
<IncidentForm members={(members ?? []) as Array<{ id: string; display_name: string | null }>} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { Incident, IncidentStatus } from '@/lib/types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const STATUS_STYLE: Record<IncidentStatus, string> = {
|
||||
received: 'text-orange-600 bg-orange-50',
|
||||
under_review: 'text-blue-600 bg-blue-50',
|
||||
decided: 'text-primary bg-primary/10',
|
||||
appealed: 'text-purple-600 bg-purple-50',
|
||||
closed: 'text-muted-foreground bg-muted',
|
||||
}
|
||||
|
||||
export default async function IncidentsPage() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members')
|
||||
.select('space_id, role')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.maybeSingle()
|
||||
if (!member?.space_id) redirect('/signup')
|
||||
|
||||
const isAdminOrBoard = member.role === 'admin' || member.role === 'board'
|
||||
|
||||
// RLS filters to admin/board view or reporter view automatically.
|
||||
const { data: incidents } = await supabase
|
||||
.from('incidents')
|
||||
.select('*')
|
||||
.eq('space_id', member.space_id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
const list = (incidents ?? []) as unknown as Incident[]
|
||||
|
||||
const open = list.filter(i => ['received', 'under_review'].includes(i.status))
|
||||
const decided = list.filter(i => ['decided', 'appealed'].includes(i.status))
|
||||
const closed = list.filter(i => i.status === 'closed')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center justify-between">
|
||||
<h1 className="text-white font-sans text-lg font-semibold">Incidents</h1>
|
||||
<Link
|
||||
href="/incidents/new"
|
||||
className="bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition"
|
||||
>
|
||||
File a report
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="p-4 md:p-6 space-y-8 max-w-4xl">
|
||||
{!isAdminOrBoard && (
|
||||
<p className="font-sans text-sm text-muted-foreground">
|
||||
You see incidents you have reported here. Admins and board members see all incidents in their space.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Section title="Open" empty="No open incidents." rows={open} />
|
||||
<Section title="Decided / under appeal" empty="None." rows={decided} />
|
||||
<Section title="Closed" empty="None." rows={closed} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, empty, rows }: { title: string; empty: string; rows: Incident[] }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">{title}</p>
|
||||
<div className="bg-card rounded border border-border divide-y divide-border">
|
||||
{rows.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-muted-foreground font-sans text-sm">{empty}</div>
|
||||
) : (
|
||||
rows.map(i => (
|
||||
<Link key={i.id} href={`/incidents/${i.id}`} className="block px-4 py-3 hover:bg-muted transition">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="font-sans text-sm font-medium text-foreground truncate">{i.title}</p>
|
||||
<span className={`font-mono text-[10px] tracking-widest uppercase px-1.5 py-0.5 rounded ${STATUS_STYLE[i.status]}`}>
|
||||
{i.status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">
|
||||
{i.category} · {i.severity}
|
||||
{i.is_anonymous ? ' · anonymous' : ''}
|
||||
{' · '}
|
||||
filed {new Date(i.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
{i.sla_response_by && i.status === 'received' && (
|
||||
<span className="font-mono text-[10px] text-orange-600 flex-shrink-0">
|
||||
response by {new Date(i.sla_response_by).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+12
-2
@@ -45,8 +45,18 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||
taskBadge={taskCount ?? 0}
|
||||
paymentBadge={paymentCount ?? 0}
|
||||
/>
|
||||
<main className="flex-1 overflow-y-auto pt-[52px] md:pt-0">
|
||||
{children}
|
||||
<main className="flex-1 overflow-y-auto pt-[52px] md:pt-0 flex flex-col">
|
||||
<div className="flex-1">{children}</div>
|
||||
{member.spaces && (member.spaces as { mission_statement?: string | null }).mission_statement && (
|
||||
<footer className="border-t border-border bg-card px-4 md:px-6 py-3">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-1">
|
||||
{member.spaces.name}
|
||||
</p>
|
||||
<p className="font-sans text-xs text-muted-foreground italic">
|
||||
{(member.spaces as { mission_statement: string }).mission_statement}
|
||||
</p>
|
||||
</footer>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import Link from 'next/link'
|
||||
import { redirect, notFound } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { Policy } from '@/lib/types'
|
||||
import { PolicyActions } from './policy-actions'
|
||||
import { MarkdownBody } from '@/components/markdown'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function PolicyDetailPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members')
|
||||
.select('space_id, role')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.maybeSingle()
|
||||
if (!member?.space_id) redirect('/signup')
|
||||
|
||||
const { data: versionsRaw } = await supabase
|
||||
.from('policies')
|
||||
.select('*')
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('slug', slug)
|
||||
.order('version', { ascending: false })
|
||||
|
||||
const versions = (versionsRaw ?? []) as unknown as Policy[]
|
||||
if (versions.length === 0) notFound()
|
||||
|
||||
// Pick the currently-active version, or the latest version, for the primary view.
|
||||
const current = versions.find(p => p.status === 'active') ?? versions[0]
|
||||
const isAdminOrBoard = member.role === 'admin' || member.role === 'board'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center gap-3">
|
||||
<Link href="/policies" className="text-white/70 hover:text-white font-sans text-sm">
|
||||
← Policies
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="p-4 md:p-6 max-w-3xl space-y-6">
|
||||
<header className="bg-card rounded border border-border p-5">
|
||||
<h1 className="font-sans text-xl font-semibold text-foreground mb-1">{current.title}</h1>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">
|
||||
{current.slug}
|
||||
{current.section_ref ? ` · §${current.section_ref}` : ''}
|
||||
{' · v'}{current.version}
|
||||
{' · '}
|
||||
{current.status}
|
||||
{current.effective_at ? ` · effective ${new Date(current.effective_at).toLocaleDateString()}` : ''}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{current.body_plain && (
|
||||
<section className="bg-card rounded border border-border p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-2">
|
||||
Plain language
|
||||
</p>
|
||||
<MarkdownBody content={current.body_plain} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="bg-card rounded border border-border p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-2">
|
||||
Formal text
|
||||
</p>
|
||||
<MarkdownBody content={current.body_formal} />
|
||||
</section>
|
||||
|
||||
{isAdminOrBoard && (
|
||||
<PolicyActions policy={current} />
|
||||
)}
|
||||
|
||||
<section className="bg-card rounded border border-border p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
||||
Version history ({versions.length})
|
||||
</p>
|
||||
<ul className="divide-y divide-border">
|
||||
{versions.map(v => (
|
||||
<li key={v.id} className="py-2 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-sans text-sm">v{v.version} · {v.status}</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">
|
||||
created {new Date(v.created_at).toLocaleDateString()}
|
||||
{v.effective_at ? ` · effective ${new Date(v.effective_at).toLocaleDateString()}` : ''}
|
||||
{v.adopted_by_proposal_id ? (
|
||||
<>
|
||||
{' · '}
|
||||
<Link href={`/proposals/${v.adopted_by_proposal_id}`} className="text-primary hover:underline">
|
||||
adopted by proposal
|
||||
</Link>
|
||||
</>
|
||||
) : ''}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { supersedePolicy, updatePolicyStatus } from '@/lib/actions'
|
||||
import { policyStatuses } from '@/lib/validations'
|
||||
import type { Policy } from '@/lib/types'
|
||||
|
||||
export function PolicyActions({ policy }: { policy: Policy }) {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [error, setError] = useState('')
|
||||
const [showSupersede, setShowSupersede] = useState(false)
|
||||
const [newBody, setNewBody] = useState(policy.body_formal)
|
||||
const [newPlain, setNewPlain] = useState(policy.body_plain ?? '')
|
||||
|
||||
function run<T>(fn: () => Promise<T>, after?: () => void) {
|
||||
setError('')
|
||||
startTransition(async () => {
|
||||
const r = (await fn()) as unknown as { error?: string }
|
||||
if (r && r.error) setError(r.error)
|
||||
else after?.()
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-card rounded border border-border p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
||||
Manage
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{policy.status !== 'active' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => run(() => updatePolicyStatus(policy.id, 'active'))}
|
||||
className="font-sans text-xs border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary"
|
||||
>
|
||||
Activate v{policy.version}
|
||||
</button>
|
||||
)}
|
||||
{policy.status === 'active' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSupersede(s => !s)}
|
||||
className="font-sans text-xs border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary"
|
||||
>
|
||||
{showSupersede ? 'Cancel supersede' : 'Supersede with new version'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => run(() => updatePolicyStatus(policy.id, 'deprecated'))}
|
||||
className="font-sans text-xs border border-border px-3 py-1.5 rounded hover:border-orange-500 hover:text-orange-600"
|
||||
>
|
||||
Deprecate
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{policyStatuses
|
||||
.filter(s => s !== policy.status && s !== 'active')
|
||||
.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => run(() => updatePolicyStatus(policy.id, s))}
|
||||
className="font-sans text-xs border border-border px-3 py-1.5 rounded text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Set {s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSupersede && (
|
||||
<div className="bg-card rounded border border-border p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
||||
Supersede v{policy.version} with v{policy.version + 1}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Plain language (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={newPlain}
|
||||
onChange={e => setNewPlain(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={100000}
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Formal text
|
||||
</label>
|
||||
<textarea
|
||||
value={newBody}
|
||||
onChange={e => setNewBody(e.target.value)}
|
||||
rows={12}
|
||||
maxLength={100000}
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
run(
|
||||
() =>
|
||||
supersedePolicy({
|
||||
policyId: policy.id,
|
||||
body_formal: newBody,
|
||||
body_plain: newPlain || null,
|
||||
}),
|
||||
() => setShowSupersede(false),
|
||||
)
|
||||
}
|
||||
className="bg-primary text-white font-sans text-sm font-medium px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
|
||||
>
|
||||
{isPending ? 'Creating draft...' : 'Create draft v' + (policy.version + 1)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="font-mono text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createPolicy } from '@/lib/actions'
|
||||
|
||||
export function NewPolicyForm() {
|
||||
const router = useRouter()
|
||||
const [slug, setSlug] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [sectionRef, setSectionRef] = useState('')
|
||||
const [bodyFormal, setBodyFormal] = useState('')
|
||||
const [bodyPlain, setBodyPlain] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
|
||||
const result = await createPolicy({
|
||||
slug,
|
||||
title,
|
||||
section_ref: sectionRef || null,
|
||||
body_formal: bodyFormal,
|
||||
body_plain: bodyPlain || null,
|
||||
})
|
||||
|
||||
if ('error' in result && result.error) {
|
||||
setError(result.error)
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push(`/policies/${slug}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5 bg-card rounded border border-border p-5">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Slug
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={e => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||
maxLength={80}
|
||||
required
|
||||
pattern="[a-z0-9-]+"
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
placeholder="bylaws"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Section reference (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={sectionRef}
|
||||
onChange={e => setSectionRef(e.target.value)}
|
||||
maxLength={80}
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
placeholder="Article III §2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
maxLength={200}
|
||||
required
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
placeholder="Bylaws"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Plain-language summary (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={bodyPlain}
|
||||
onChange={e => setBodyPlain(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={100000}
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
placeholder="In short: be excellent to each other and pay your dues."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Formal text
|
||||
</label>
|
||||
<textarea
|
||||
value={bodyFormal}
|
||||
onChange={e => setBodyFormal(e.target.value)}
|
||||
rows={16}
|
||||
maxLength={100000}
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary font-mono"
|
||||
placeholder="WHEREAS..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="font-mono text-xs text-red-500">{error}</p>}
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Link
|
||||
href="/policies"
|
||||
className="font-sans text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !slug.trim() || !title.trim()}
|
||||
className="bg-primary text-white font-sans text-sm font-medium px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Saving...' : 'Save as draft v1'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NewPolicyForm } from './new-policy-form'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function NewPolicyPage() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members')
|
||||
.select('space_id, role')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.maybeSingle()
|
||||
if (!member?.space_id) redirect('/signup')
|
||||
if (member.role !== 'admin' && member.role !== 'board') redirect('/policies')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3">
|
||||
<h1 className="text-white font-sans text-lg font-semibold">New policy</h1>
|
||||
</div>
|
||||
<div className="p-4 md:p-6 max-w-2xl">
|
||||
<NewPolicyForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { Policy, PolicyStatus } from '@/lib/types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const STATUS_STYLE: Record<PolicyStatus, string> = {
|
||||
draft: 'text-muted-foreground bg-muted',
|
||||
active: 'text-primary bg-primary/10',
|
||||
deprecated: 'text-orange-600 bg-orange-50',
|
||||
superseded: 'text-muted-foreground bg-muted',
|
||||
}
|
||||
|
||||
export default async function PoliciesPage() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members')
|
||||
.select('space_id, role')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.maybeSingle()
|
||||
if (!member?.space_id) redirect('/signup')
|
||||
|
||||
const { data: policiesRaw } = await supabase
|
||||
.from('policies')
|
||||
.select('*')
|
||||
.eq('space_id', member.space_id)
|
||||
.order('slug', { ascending: true })
|
||||
.order('version', { ascending: false })
|
||||
|
||||
const all = (policiesRaw ?? []) as unknown as Policy[]
|
||||
|
||||
// For each slug, show the highest-version active row; fall back to highest-version overall.
|
||||
const bySlug = new Map<string, Policy>()
|
||||
for (const p of all) {
|
||||
const existing = bySlug.get(p.slug)
|
||||
if (!existing) bySlug.set(p.slug, p)
|
||||
else if (p.status === 'active' && existing.status !== 'active') bySlug.set(p.slug, p)
|
||||
else if (p.status === existing.status && p.version > existing.version) bySlug.set(p.slug, p)
|
||||
}
|
||||
|
||||
const summary = Array.from(bySlug.values()).sort((a, b) => a.slug.localeCompare(b.slug))
|
||||
const isAdminOrBoard = member.role === 'admin' || member.role === 'board'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center justify-between">
|
||||
<h1 className="text-white font-sans text-lg font-semibold">Policies</h1>
|
||||
{isAdminOrBoard && (
|
||||
<Link
|
||||
href="/policies/new"
|
||||
className="bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition"
|
||||
>
|
||||
New policy
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 md:p-6 max-w-4xl">
|
||||
<div className="bg-card rounded border border-border divide-y divide-border">
|
||||
{summary.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center">
|
||||
<p className="font-sans text-sm text-muted-foreground mb-1">No policies yet.</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">
|
||||
Bylaws, code of conduct, station rules. Each lives here with full version history.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
summary.map(p => (
|
||||
<Link
|
||||
key={p.slug}
|
||||
href={`/policies/${p.slug}`}
|
||||
className="block px-4 py-3 hover:bg-muted transition"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="font-sans text-sm font-medium text-foreground truncate">{p.title}</p>
|
||||
<span className={`font-mono text-[10px] tracking-widest uppercase px-1.5 py-0.5 rounded ${STATUS_STYLE[p.status]}`}>
|
||||
{p.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">
|
||||
{p.slug}
|
||||
{p.section_ref ? ` · §${p.section_ref}` : ''}
|
||||
{' · v'}{p.version}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { discloseAffiliations } from '@/lib/actions'
|
||||
import { ChipInput } from './chip-input'
|
||||
|
||||
export function AffiliationsForm({ initial }: { initial: { affiliations: string[] } }) {
|
||||
const router = useRouter()
|
||||
const [affiliations, setAffiliations] = useState<string[]>(initial.affiliations)
|
||||
const [error, setError] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'saved'>('idle')
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setStatus('idle')
|
||||
startTransition(async () => {
|
||||
const r = await discloseAffiliations({ affiliations })
|
||||
if ('error' in r && r.error) setError(r.error)
|
||||
else {
|
||||
setStatus('saved')
|
||||
router.refresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-card rounded border border-border p-5 space-y-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-2">
|
||||
Outside affiliations
|
||||
</label>
|
||||
<ChipInput
|
||||
values={affiliations}
|
||||
onChange={setAffiliations}
|
||||
placeholder='add an affiliation and press enter, e.g. "ACME Robotics, contractor"'
|
||||
maxLength={200}
|
||||
maxItems={50}
|
||||
/>
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-1">
|
||||
Organizations, businesses, or roles outside this space that could intersect with space
|
||||
decisions. Visible to all members. Submitting stamps your disclosure date.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="font-mono text-xs text-red-500">{error}</p>}
|
||||
{status === 'saved' && !error && (
|
||||
<p className="font-mono text-xs text-primary">Disclosed. Your timestamp is updated.</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="bg-primary text-white font-sans text-sm font-medium px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
|
||||
>
|
||||
{isPending ? 'Disclosing...' : 'Disclose now'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type KeyboardEvent } from 'react'
|
||||
|
||||
type Props = {
|
||||
values: string[]
|
||||
onChange: (next: string[]) => void
|
||||
placeholder?: string
|
||||
maxLength?: number
|
||||
maxItems?: number
|
||||
suggestions?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal chip / token input. Press Enter or comma to commit the current
|
||||
* value. Backspace on an empty input removes the last chip. Optional
|
||||
* suggestion list shows un-used suggestions as click-to-add chips below.
|
||||
*/
|
||||
export function ChipInput({
|
||||
values,
|
||||
onChange,
|
||||
placeholder,
|
||||
maxLength = 80,
|
||||
maxItems = 50,
|
||||
suggestions,
|
||||
}: Props) {
|
||||
const [draft, setDraft] = useState('')
|
||||
|
||||
function commit(raw: string) {
|
||||
const v = raw.trim()
|
||||
if (!v) return
|
||||
if (values.includes(v)) return
|
||||
if (values.length >= maxItems) return
|
||||
onChange([...values, v.slice(0, maxLength)])
|
||||
setDraft('')
|
||||
}
|
||||
|
||||
function remove(idx: number) {
|
||||
onChange(values.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault()
|
||||
commit(draft)
|
||||
} else if (e.key === 'Backspace' && draft === '' && values.length > 0) {
|
||||
e.preventDefault()
|
||||
onChange(values.slice(0, -1))
|
||||
}
|
||||
}
|
||||
|
||||
const remainingSuggestions = (suggestions ?? []).filter(s => !values.includes(s))
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-1.5 bg-background border border-border rounded px-2 py-1.5 focus-within:border-primary">
|
||||
{values.map((v, i) => (
|
||||
<span
|
||||
key={`${v}-${i}`}
|
||||
className="inline-flex items-center gap-1 bg-muted rounded px-2 py-0.5 font-mono text-xs"
|
||||
>
|
||||
{v}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(i)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Remove ${v}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => commit(draft)}
|
||||
placeholder={values.length === 0 ? placeholder : ''}
|
||||
maxLength={maxLength}
|
||||
className="flex-1 min-w-[120px] bg-transparent text-foreground font-sans text-sm py-1 px-1 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
{remainingSuggestions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{remainingSuggestions.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => commit(s)}
|
||||
className="font-mono text-[10px] tracking-widest border border-border text-muted-foreground hover:text-foreground hover:border-primary rounded px-2 py-0.5"
|
||||
>
|
||||
+ {s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { ProfileForm } from './profile-form'
|
||||
import { AffiliationsForm } from './affiliations-form'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const { data: memberRaw } = await supabase
|
||||
.from('space_members')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.maybeSingle()
|
||||
if (!memberRaw) redirect('/signup')
|
||||
|
||||
const member = memberRaw as unknown as {
|
||||
id: string
|
||||
display_name: string | null
|
||||
handle: string | null
|
||||
phone: string | null
|
||||
role: string
|
||||
tier: string
|
||||
skills: string[]
|
||||
interests: string[]
|
||||
willing_to: string[]
|
||||
affiliations: string[]
|
||||
coi_last_disclosed_at: string | null
|
||||
email: string | null
|
||||
}
|
||||
|
||||
const privilegedRoles = ['admin', 'board', 'treasurer']
|
||||
const isPrivileged = privilegedRoles.includes(member.role)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3">
|
||||
<h1 className="text-white font-sans text-lg font-semibold">My profile</h1>
|
||||
</div>
|
||||
|
||||
<div className="p-4 md:p-6 space-y-6 max-w-3xl">
|
||||
<header className="bg-card rounded border border-border p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-1">
|
||||
Account
|
||||
</p>
|
||||
<p className="font-sans text-sm text-foreground">{member.email}</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-1">
|
||||
role: {member.role} · tier: {member.tier}
|
||||
</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-2">
|
||||
Role and tier can only be changed by an admin or board member. The privilege-escalation
|
||||
trigger blocks self-edits to those fields at the database level.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
||||
Profile
|
||||
</p>
|
||||
<ProfileForm
|
||||
initial={{
|
||||
display_name: member.display_name ?? '',
|
||||
handle: member.handle ?? '',
|
||||
phone: member.phone ?? '',
|
||||
skills: member.skills ?? [],
|
||||
interests: member.interests ?? [],
|
||||
willing_to: member.willing_to ?? [],
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
|
||||
Conflict-of-interest disclosure
|
||||
</p>
|
||||
{member.coi_last_disclosed_at && (
|
||||
<p className="font-mono text-[10px] text-muted-foreground">
|
||||
last disclosed {new Date(member.coi_last_disclosed_at).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isPrivileged && !member.coi_last_disclosed_at && (
|
||||
<div className="bg-orange-50 border border-orange-200 rounded p-3 mb-3">
|
||||
<p className="font-sans text-sm text-orange-900">
|
||||
You hold a privileged role ({member.role}). Please disclose any outside affiliations
|
||||
that could conflict with space governance. Disclosure is visible to other members.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<AffiliationsForm initial={{ affiliations: member.affiliations ?? [] }} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { updateMyProfile } from '@/lib/actions'
|
||||
import { ChipInput } from './chip-input'
|
||||
|
||||
type Props = {
|
||||
initial: {
|
||||
display_name: string
|
||||
handle: string
|
||||
phone: string
|
||||
skills: string[]
|
||||
interests: string[]
|
||||
willing_to: string[]
|
||||
}
|
||||
}
|
||||
|
||||
const WILLING_TO_SUGGESTIONS = [
|
||||
'board_candidate',
|
||||
'treasurer_candidate',
|
||||
'host_volunteer',
|
||||
'area_lead_candidate',
|
||||
'event_organizer',
|
||||
'safety_committee',
|
||||
'docs_steward',
|
||||
]
|
||||
|
||||
export function ProfileForm({ initial }: Props) {
|
||||
const router = useRouter()
|
||||
const [displayName, setDisplayName] = useState(initial.display_name)
|
||||
const [handle, setHandle] = useState(initial.handle)
|
||||
const [phone, setPhone] = useState(initial.phone)
|
||||
const [skills, setSkills] = useState<string[]>(initial.skills)
|
||||
const [interests, setInterests] = useState<string[]>(initial.interests)
|
||||
const [willingTo, setWillingTo] = useState<string[]>(initial.willing_to)
|
||||
const [error, setError] = useState('')
|
||||
const [status, setStatus] = useState<'idle' | 'saved'>('idle')
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setStatus('idle')
|
||||
startTransition(async () => {
|
||||
const r = await updateMyProfile({
|
||||
display_name: displayName,
|
||||
handle: handle || null,
|
||||
phone: phone || null,
|
||||
skills,
|
||||
interests,
|
||||
willing_to: willingTo,
|
||||
})
|
||||
if ('error' in r && r.error) setError(r.error)
|
||||
else {
|
||||
setStatus('saved')
|
||||
router.refresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-card rounded border border-border p-5 space-y-5">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Display name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={e => setDisplayName(e.target.value)}
|
||||
maxLength={100}
|
||||
required
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Handle
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={handle}
|
||||
onChange={e => setHandle(e.target.value)}
|
||||
maxLength={50}
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Phone
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={e => setPhone(e.target.value)}
|
||||
maxLength={20}
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Skills
|
||||
</label>
|
||||
<ChipInput values={skills} onChange={setSkills} placeholder="add a skill and press enter" maxLength={60} maxItems={40} />
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-1">
|
||||
Examples: woodworking, laser, electronics, accounting, grant writing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Interests
|
||||
</label>
|
||||
<ChipInput values={interests} onChange={setInterests} placeholder="add an interest and press enter" maxLength={60} maxItems={40} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Willing to do
|
||||
</label>
|
||||
<ChipInput
|
||||
values={willingTo}
|
||||
onChange={setWillingTo}
|
||||
placeholder="add a role and press enter"
|
||||
maxLength={60}
|
||||
maxItems={20}
|
||||
suggestions={WILLING_TO_SUGGESTIONS}
|
||||
/>
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-1">
|
||||
Used by the recruitment view (board only) when looking for members willing to take on a role.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="font-mono text-xs text-red-500">{error}</p>}
|
||||
{status === 'saved' && !error && (
|
||||
<p className="font-mono text-xs text-primary">Saved.</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="bg-primary text-white font-sans text-sm font-medium px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
|
||||
>
|
||||
{isPending ? 'Saving...' : 'Save profile'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -10,8 +10,18 @@ export default async function ProjectsPage() {
|
||||
.from('space_members').select('space_id').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member) return null
|
||||
|
||||
const { data: projects } = await supabase
|
||||
.from('projects').select('*').eq('space_id', member.space_id).order('created_at', { ascending: false })
|
||||
const [{ data: projects }, { data: areasRaw }] = await Promise.all([
|
||||
supabase.from('projects').select('*').eq('space_id', member.space_id).order('created_at', { ascending: false }),
|
||||
supabase
|
||||
.from('space_areas')
|
||||
.select('name')
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('is_archived', false)
|
||||
.order('sort_order', { ascending: true })
|
||||
.order('name', { ascending: true }),
|
||||
])
|
||||
|
||||
return <ProjectsClient projects={projects ?? []} spaceId={member.space_id} />
|
||||
const areas = (areasRaw ?? []).map((a: { name: string }) => a.name)
|
||||
|
||||
return <ProjectsClient projects={projects ?? []} spaceId={member.space_id} areas={areas} />
|
||||
}
|
||||
|
||||
@@ -14,9 +14,14 @@ const STATUS_COLS = [
|
||||
{ key: 'done', label: 'DONE', color: 'text-muted-foreground', dot: 'bg-muted-foreground' },
|
||||
]
|
||||
|
||||
const AREAS = ['3D Printing', 'Electronics', 'Woodshop', 'Laser', 'Metal Shop', 'Facilities', 'Admin', 'Software', 'General']
|
||||
const DEFAULT_AREAS = ['3D Printing', 'Electronics', 'Woodshop', 'Laser', 'Metal Shop', 'Facilities', 'Admin', 'Software', 'General']
|
||||
|
||||
export function ProjectsClient({ projects: initialProjects, spaceId }: { projects: Project[], spaceId: string }) {
|
||||
export function ProjectsClient({
|
||||
projects: initialProjects,
|
||||
spaceId,
|
||||
areas,
|
||||
}: { projects: Project[]; spaceId: string; areas?: string[] }) {
|
||||
const AREAS = areas && areas.length > 0 ? areas : DEFAULT_AREAS
|
||||
const [projects, setProjects] = useState<Project[]>(initialProjects)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [form, setForm] = useState({ title: '', description: '', area: '', tags: '', due_date: '' })
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import Link from 'next/link'
|
||||
import { redirect, notFound } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { Proposal, ProposalVote } from '@/lib/types'
|
||||
import { ProposalStatusBadge } from '../proposal-badges'
|
||||
import { ProposalActions } from './proposal-actions'
|
||||
import { MarkdownBody } from '@/components/markdown'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function ProposalDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members')
|
||||
.select('id, space_id, role, display_name')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.maybeSingle()
|
||||
if (!member?.space_id) redirect('/signup')
|
||||
|
||||
const { data: proposalRaw } = await supabase
|
||||
.from('proposals')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('space_id', member.space_id)
|
||||
.maybeSingle()
|
||||
if (!proposalRaw) notFound()
|
||||
|
||||
const proposal = proposalRaw as unknown as Proposal
|
||||
|
||||
const { data: votesRaw } = await supabase
|
||||
.from('proposal_votes')
|
||||
.select('*')
|
||||
.eq('proposal_id', proposal.id)
|
||||
.order('voted_at', { ascending: false })
|
||||
|
||||
const votes = (votesRaw ?? []) as unknown as ProposalVote[]
|
||||
|
||||
// Resolve voter display names from space_members.
|
||||
const voterIds = Array.from(new Set(votes.map(v => v.member_id)))
|
||||
const { data: voters } = voterIds.length
|
||||
? await supabase
|
||||
.from('space_members')
|
||||
.select('id, display_name')
|
||||
.in('id', voterIds)
|
||||
: { data: [] as Array<{ id: string; display_name: string | null }> }
|
||||
|
||||
const voterNames = new Map((voters ?? []).map(m => [m.id, m.display_name]))
|
||||
|
||||
const myVote = votes.find(v => v.member_id === (member as { id: string }).id) ?? null
|
||||
const isAdminOrBoard = member.role === 'admin' || member.role === 'board'
|
||||
const votingOpen =
|
||||
proposal.status === 'open' &&
|
||||
proposal.voting_opens_at !== null &&
|
||||
proposal.voting_closes_at !== null &&
|
||||
new Date(proposal.voting_opens_at) <= new Date() &&
|
||||
new Date(proposal.voting_closes_at) > new Date()
|
||||
|
||||
const totalCounted = proposal.outcome_yes + proposal.outcome_no + proposal.outcome_abstain
|
||||
const quorumPct = proposal.quorum_required > 0
|
||||
? Math.min(100, Math.round((totalCounted / proposal.quorum_required) * 100))
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center gap-3">
|
||||
<Link href="/proposals" className="text-white/70 hover:text-white font-sans text-sm">
|
||||
← Proposals
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="p-4 md:p-6 max-w-4xl space-y-6">
|
||||
<header className="bg-card rounded border border-border p-5">
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<h1 className="font-sans text-xl font-semibold text-foreground flex-1">{proposal.title}</h1>
|
||||
<ProposalStatusBadge status={proposal.status} />
|
||||
</div>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">
|
||||
{proposal.proposal_type.replace(/_/g, ' ')}
|
||||
{proposal.proposer_name ? ` · proposed by ${proposal.proposer_name}` : ''}
|
||||
{' · '}
|
||||
{proposal.threshold.replace(/_/g, ' ')} required
|
||||
</p>
|
||||
{proposal.body && <MarkdownBody className="mt-4" content={proposal.body} />}
|
||||
</header>
|
||||
|
||||
<section className="bg-card rounded border border-border p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
||||
Tally
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||
<Tally label="Yes" value={proposal.outcome_yes} accent="text-primary" />
|
||||
<Tally label="No" value={proposal.outcome_no} accent="text-orange-600" />
|
||||
<Tally label="Abstain" value={proposal.outcome_abstain} accent="text-muted-foreground" />
|
||||
<Tally label="Recused" value={proposal.outcome_recused} accent="text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
|
||||
Quorum
|
||||
</span>
|
||||
<span className="font-sans text-xs">
|
||||
{totalCounted} of {proposal.quorum_required}
|
||||
{' · '}
|
||||
<span className={proposal.quorum_met ? 'text-primary' : 'text-orange-600'}>
|
||||
{proposal.quorum_met ? 'met' : 'not met'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 bg-border rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${proposal.quorum_met ? 'bg-primary' : 'bg-orange-400'}`} style={{ width: `${quorumPct}%` }} />
|
||||
</div>
|
||||
{proposal.status === 'decided' && (
|
||||
<p className="font-mono text-[10px] text-muted-foreground pt-1">
|
||||
Outcome: <span className={proposal.passed ? 'text-primary' : 'text-orange-600'}>{proposal.passed ? 'PASSED' : 'DID NOT PASS'}</span>
|
||||
{proposal.decided_at ? ` · ${new Date(proposal.decided_at).toLocaleString()}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ProposalActions
|
||||
proposalId={proposal.id}
|
||||
status={proposal.status}
|
||||
isAdminOrBoard={isAdminOrBoard}
|
||||
isProposer={proposal.proposer_id === member.id}
|
||||
votingOpen={votingOpen}
|
||||
myVote={myVote}
|
||||
/>
|
||||
|
||||
<section className="bg-card rounded border border-border p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
||||
Votes ({votes.length})
|
||||
</p>
|
||||
{votes.length === 0 ? (
|
||||
<p className="font-sans text-sm text-muted-foreground">No votes cast yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{votes.map(v => (
|
||||
<li key={v.id} className="py-2 flex items-start gap-3">
|
||||
<span className="font-mono text-[10px] tracking-widest uppercase w-16 flex-shrink-0 mt-1">
|
||||
{v.position}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-sans text-sm text-foreground">{voterNames.get(v.member_id) ?? 'Unknown member'}</p>
|
||||
{v.comment && <p className="font-sans text-xs text-muted-foreground mt-0.5">{v.comment}</p>}
|
||||
{v.recusal_reason && (
|
||||
<p className="font-sans text-xs text-muted-foreground italic mt-0.5">Recused: {v.recusal_reason}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-muted-foreground/60 flex-shrink-0 mt-1">
|
||||
{new Date(v.voted_at).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Tally({ label, value, accent }: { label: string; value: number; accent: string }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-1">{label}</p>
|
||||
<p className={`font-sans text-2xl font-semibold ${accent}`}>{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
castVote,
|
||||
decideProposal,
|
||||
openProposal,
|
||||
withdrawProposal,
|
||||
} from '@/lib/actions'
|
||||
import type { ProposalStatus, ProposalVote, VotePosition } from '@/lib/types'
|
||||
import { votePositions } from '@/lib/validations'
|
||||
|
||||
type Props = {
|
||||
proposalId: string
|
||||
status: ProposalStatus
|
||||
isAdminOrBoard: boolean
|
||||
isProposer: boolean
|
||||
votingOpen: boolean
|
||||
myVote: ProposalVote | null
|
||||
}
|
||||
|
||||
export function ProposalActions({
|
||||
proposalId,
|
||||
status,
|
||||
isAdminOrBoard,
|
||||
isProposer,
|
||||
votingOpen,
|
||||
myVote,
|
||||
}: Props) {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Voting form state
|
||||
const [position, setPosition] = useState<VotePosition>(myVote?.position ?? 'yes')
|
||||
const [recusalReason, setRecusalReason] = useState(myVote?.recusal_reason ?? '')
|
||||
const [comment, setComment] = useState(myVote?.comment ?? '')
|
||||
|
||||
function run<T>(fn: () => Promise<T>) {
|
||||
setError('')
|
||||
startTransition(async () => {
|
||||
const r = (await fn()) as unknown as { error?: string }
|
||||
if (r && r.error) setError(r.error)
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{votingOpen && (
|
||||
<div className="bg-card rounded border border-border p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
||||
{myVote ? 'Update your vote' : 'Cast your vote'}
|
||||
</p>
|
||||
<div className="grid grid-cols-4 gap-2 mb-3">
|
||||
{votePositions.map(p => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setPosition(p)}
|
||||
className={`font-mono text-xs tracking-widest uppercase py-2 rounded border transition ${
|
||||
position === p
|
||||
? 'bg-primary text-white border-primary'
|
||||
: 'border-border text-foreground hover:border-primary'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{position === 'recused' && (
|
||||
<div className="mb-3">
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Recusal reason (required)
|
||||
</label>
|
||||
<textarea
|
||||
value={recusalReason}
|
||||
onChange={e => setRecusalReason(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={1000}
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3">
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Comment (optional, public)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
maxLength={2000}
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending || (position === 'recused' && recusalReason.trim().length === 0)}
|
||||
onClick={() =>
|
||||
run(() =>
|
||||
castVote({
|
||||
proposalId,
|
||||
position,
|
||||
recusal_reason: position === 'recused' ? recusalReason : null,
|
||||
comment: comment || null,
|
||||
}),
|
||||
)
|
||||
}
|
||||
className="bg-primary text-white font-sans text-sm font-medium px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
|
||||
>
|
||||
{isPending ? 'Saving...' : myVote ? 'Update vote' : 'Submit vote'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isAdminOrBoard || isProposer) && (
|
||||
<div className="bg-card rounded border border-border p-5">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
||||
Manage
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{status === 'draft' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => run(() => openProposal(proposalId))}
|
||||
disabled={isPending}
|
||||
className="font-sans text-xs border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary"
|
||||
>
|
||||
Open for voting
|
||||
</button>
|
||||
)}
|
||||
{(status === 'draft' || status === 'open') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => run(() => withdrawProposal(proposalId))}
|
||||
disabled={isPending}
|
||||
className="font-sans text-xs border border-border px-3 py-1.5 rounded hover:border-orange-500 hover:text-orange-600"
|
||||
>
|
||||
Withdraw
|
||||
</button>
|
||||
)}
|
||||
{status === 'open' && isAdminOrBoard && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => run(() => decideProposal(proposalId))}
|
||||
disabled={isPending}
|
||||
className="font-sans text-xs border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary"
|
||||
>
|
||||
Mark decided
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="font-mono text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createProposal } from '@/lib/actions'
|
||||
import {
|
||||
proposalTypes,
|
||||
thresholdRules,
|
||||
} from '@/lib/validations'
|
||||
|
||||
export function NewProposalForm() {
|
||||
const router = useRouter()
|
||||
const [title, setTitle] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [proposalType, setProposalType] = useState<string>('advisory_poll')
|
||||
const [threshold, setThreshold] = useState<string>('simple_majority')
|
||||
const [openImmediately, setOpenImmediately] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
|
||||
const result = await createProposal({
|
||||
title,
|
||||
body,
|
||||
proposal_type: proposalType,
|
||||
threshold,
|
||||
open_immediately: openImmediately,
|
||||
})
|
||||
|
||||
if ('error' in result && result.error) {
|
||||
setError(result.error)
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
if ('data' in result && result.data) {
|
||||
router.push(`/proposals/${(result.data as { id: string }).id}`)
|
||||
} else {
|
||||
router.push('/proposals')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5 bg-card rounded border border-border p-5">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
maxLength={200}
|
||||
required
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
placeholder="Approve new laser station rules"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Body
|
||||
</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
rows={10}
|
||||
maxLength={20000}
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
placeholder="Describe the proposal, the change being proposed, and the rationale."
|
||||
/>
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-1">Markdown is rendered on the detail page.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
value={proposalType}
|
||||
onChange={e => setProposalType(e.target.value)}
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
>
|
||||
{proposalTypes.map(t => (
|
||||
<option key={t} value={t}>
|
||||
{t.replace(/_/g, ' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Threshold to pass
|
||||
</label>
|
||||
<select
|
||||
value={threshold}
|
||||
onChange={e => setThreshold(e.target.value)}
|
||||
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
|
||||
>
|
||||
{thresholdRules.map(t => (
|
||||
<option key={t} value={t}>
|
||||
{t.replace(/_/g, ' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={openImmediately}
|
||||
onChange={e => setOpenImmediately(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span className="font-sans">Open voting immediately. Quorum and voting window come from space defaults.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="font-mono text-xs text-red-500">{error}</p>}
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Link
|
||||
href="/proposals"
|
||||
className="font-sans text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !title.trim()}
|
||||
className="bg-primary text-white font-sans text-sm font-medium px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Saving...' : 'Save proposal'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NewProposalForm } from './new-proposal-form'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function NewProposalPage() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members')
|
||||
.select('space_id')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.maybeSingle()
|
||||
if (!member?.space_id) redirect('/signup')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3">
|
||||
<h1 className="text-white font-sans text-lg font-semibold">New proposal</h1>
|
||||
</div>
|
||||
<div className="p-4 md:p-6 max-w-2xl">
|
||||
<NewProposalForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { Proposal } from '@/lib/types'
|
||||
import { ProposalStatusBadge } from './proposal-badges'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function ProposalsPage() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members')
|
||||
.select('space_id')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.maybeSingle()
|
||||
if (!member?.space_id) redirect('/signup')
|
||||
|
||||
const { data: proposals } = await supabase
|
||||
.from('proposals')
|
||||
.select('*')
|
||||
.eq('space_id', member.space_id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
const list = (proposals ?? []) as unknown as Proposal[]
|
||||
|
||||
const open = list.filter(p => p.status === 'open')
|
||||
const drafts = list.filter(p => p.status === 'draft')
|
||||
const decided = list.filter(p => ['decided', 'withdrawn', 'expired'].includes(p.status))
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center justify-between">
|
||||
<h1 className="text-white font-sans text-lg font-semibold">Proposals</h1>
|
||||
<Link
|
||||
href="/proposals/new"
|
||||
className="bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition"
|
||||
>
|
||||
New proposal
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="p-4 md:p-6 space-y-8 max-w-4xl">
|
||||
<Section title="Open for voting" empty="No proposals are currently open." proposals={open} />
|
||||
<Section title="Drafts" empty="No drafts." proposals={drafts} />
|
||||
<Section title="Archive" empty="No decided or withdrawn proposals." proposals={decided} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, empty, proposals }: { title: string; empty: string; proposals: Proposal[] }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">{title}</p>
|
||||
<div className="bg-card rounded border border-border divide-y divide-border">
|
||||
{proposals.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-muted-foreground font-sans text-sm">{empty}</div>
|
||||
) : (
|
||||
proposals.map(p => <ProposalRow key={p.id} p={p} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProposalRow({ p }: { p: Proposal }) {
|
||||
const totalVotes = p.outcome_yes + p.outcome_no + p.outcome_abstain
|
||||
return (
|
||||
<Link href={`/proposals/${p.id}`} className="block px-4 py-3 hover:bg-muted transition">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="font-sans text-sm font-medium text-foreground truncate">{p.title}</p>
|
||||
<ProposalStatusBadge status={p.status} />
|
||||
</div>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">
|
||||
{p.proposal_type.replace(/_/g, ' ')}
|
||||
{p.proposer_name ? ` · proposed by ${p.proposer_name}` : ''}
|
||||
{p.voting_closes_at && p.status === 'open'
|
||||
? ` · closes ${new Date(p.voting_closes_at).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="font-mono text-[10px] text-muted-foreground tracking-widest uppercase">
|
||||
{p.outcome_yes} y · {p.outcome_no} n · {p.outcome_abstain} a
|
||||
</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
|
||||
{totalVotes}/{p.quorum_required} for quorum
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ProposalStatus } from '@/lib/types'
|
||||
|
||||
const STATUS_STYLE: Record<ProposalStatus, string> = {
|
||||
draft: 'text-muted-foreground bg-muted',
|
||||
open: 'text-primary bg-primary/10',
|
||||
decided: 'text-blue-600 bg-blue-50',
|
||||
withdrawn: 'text-muted-foreground bg-muted',
|
||||
expired: 'text-orange-600 bg-orange-50',
|
||||
}
|
||||
|
||||
export function ProposalStatusBadge({ status }: { status: ProposalStatus }) {
|
||||
return (
|
||||
<span className={`font-mono text-[10px] tracking-widest uppercase px-1.5 py-0.5 rounded ${STATUS_STYLE[status]}`}>
|
||||
{status.replace(/_/g, ' ')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
type SkilledMember = {
|
||||
id: string
|
||||
display_name: string | null
|
||||
handle: string | null
|
||||
role: string
|
||||
tier: string
|
||||
skills: string[]
|
||||
interests: string[]
|
||||
willing_to: string[]
|
||||
coi_last_disclosed_at: string | null
|
||||
}
|
||||
|
||||
export default async function RecruitmentPage() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) redirect('/login')
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members')
|
||||
.select('space_id, role')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.maybeSingle()
|
||||
if (!member?.space_id) redirect('/signup')
|
||||
if (member.role !== 'admin' && member.role !== 'board') redirect('/dashboard')
|
||||
|
||||
const { data: rowsRaw } = await supabase
|
||||
.from('space_members')
|
||||
.select('id, display_name, handle, role, tier, skills, interests, willing_to, coi_last_disclosed_at')
|
||||
.eq('space_id', member.space_id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.eq('approved', true)
|
||||
.order('display_name', { ascending: true })
|
||||
|
||||
const members = ((rowsRaw ?? []) as unknown as SkilledMember[]).filter(
|
||||
m => m.willing_to.length > 0 || m.skills.length > 0,
|
||||
)
|
||||
|
||||
// Group by willing_to role.
|
||||
const byRole = new Map<string, SkilledMember[]>()
|
||||
for (const m of members) {
|
||||
for (const w of m.willing_to) {
|
||||
if (!byRole.has(w)) byRole.set(w, [])
|
||||
byRole.get(w)!.push(m)
|
||||
}
|
||||
}
|
||||
const roleGroups = Array.from(byRole.entries()).sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
// Members who only declared skills but no willing_to.
|
||||
const skillsOnly = members.filter(m => m.willing_to.length === 0 && m.skills.length > 0)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="bg-sidebar px-4 md:px-6 py-3">
|
||||
<h1 className="text-white font-sans text-lg font-semibold">Recruitment</h1>
|
||||
</div>
|
||||
|
||||
<div className="p-4 md:p-6 max-w-4xl space-y-6">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
|
||||
Board / admin view. Read-only.
|
||||
</p>
|
||||
|
||||
{members.length === 0 ? (
|
||||
<div className="bg-card rounded border border-border p-8 text-center">
|
||||
<p className="font-sans text-sm text-foreground mb-1">No members have declared willingness or skills yet.</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">
|
||||
Members opt in from their <Link href="/profile" className="text-primary hover:underline">profile</Link> page.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{roleGroups.length > 0 && (
|
||||
<section>
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
||||
Willing to take on
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{roleGroups.map(([role, list]) => (
|
||||
<div key={role} className="bg-card rounded border border-border p-4">
|
||||
<p className="font-mono text-xs tracking-widest uppercase text-primary mb-2">
|
||||
{role.replace(/_/g, ' ')}
|
||||
</p>
|
||||
<ul className="divide-y divide-border">
|
||||
{list.map(m => (
|
||||
<li key={m.id} className="py-2 flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-sans text-sm text-foreground">
|
||||
{m.display_name ?? 'Unnamed'}
|
||||
{m.handle ? <span className="font-mono text-[10px] text-muted-foreground"> · @{m.handle}</span> : null}
|
||||
</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground">
|
||||
{m.role} · {m.tier}
|
||||
{m.skills.length > 0 ? ` · skills: ${m.skills.join(', ')}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
{(m.role === 'admin' || m.role === 'board' || m.role === 'treasurer') && (
|
||||
<span
|
||||
className={`font-mono text-[10px] tracking-widest uppercase px-1.5 py-0.5 rounded ${
|
||||
m.coi_last_disclosed_at
|
||||
? 'text-primary bg-primary/10'
|
||||
: 'text-orange-600 bg-orange-50'
|
||||
}`}
|
||||
>
|
||||
{m.coi_last_disclosed_at ? 'COI on file' : 'no COI'}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{skillsOnly.length > 0 && (
|
||||
<section>
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
|
||||
Skills declared (no role opt-in)
|
||||
</p>
|
||||
<div className="bg-card rounded border border-border divide-y divide-border">
|
||||
{skillsOnly.map(m => (
|
||||
<div key={m.id} className="p-4">
|
||||
<p className="font-sans text-sm text-foreground">
|
||||
{m.display_name ?? 'Unnamed'}
|
||||
{m.handle ? <span className="font-mono text-[10px] text-muted-foreground"> · @{m.handle}</span> : null}
|
||||
</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
|
||||
skills: {m.skills.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,13 @@ export default async function SettingsPage() {
|
||||
const { data: integrations } = await supabase
|
||||
.from('integrations').select('*').eq('space_id', member.space_id)
|
||||
|
||||
const { data: areas } = await supabase
|
||||
.from('space_areas')
|
||||
.select('id, code, name, icon, sort_order, is_archived')
|
||||
.eq('space_id', member.space_id)
|
||||
.order('sort_order', { ascending: true })
|
||||
.order('name', { ascending: true })
|
||||
|
||||
const isAdmin = member?.role === 'admin'
|
||||
|
||||
return (
|
||||
@@ -26,6 +33,14 @@ export default async function SettingsPage() {
|
||||
isAdmin={isAdmin}
|
||||
integrations={integrations ?? []}
|
||||
currentRole={member.role}
|
||||
areas={(areas ?? []) as Array<{
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
icon: string | null
|
||||
sort_order: number
|
||||
is_archived: boolean
|
||||
}>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,17 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { X, Check, Copy, Eye, EyeOff, RotateCcw } from 'lucide-react'
|
||||
import { updateSpaceSettings, saveIntegration, disconnectIntegration, rotateWebhookSecret } from '@/lib/actions'
|
||||
import { updateSpaceSettings, saveIntegration, disconnectIntegration, rotateWebhookSecret, updateSpaceVisibility, createArea, updateArea, deleteArea } from '@/lib/actions'
|
||||
import { financialVisibilities, directoryVisibilities } from '@/lib/validations'
|
||||
|
||||
type Area = {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
icon: string | null
|
||||
sort_order: number
|
||||
is_archived: boolean
|
||||
}
|
||||
import type { Tables } from '@/types/database'
|
||||
|
||||
type Space = Tables<'spaces'>
|
||||
@@ -13,6 +23,7 @@ interface Props {
|
||||
isAdmin: boolean
|
||||
integrations: Integration[]
|
||||
currentRole: string
|
||||
areas: Area[]
|
||||
}
|
||||
|
||||
const ROLES_INFO = [
|
||||
@@ -87,18 +98,32 @@ const INTEGRATIONS_CONFIG = [
|
||||
},
|
||||
]
|
||||
|
||||
export default function SettingsClient({ space, isAdmin, integrations, currentRole }: Props) {
|
||||
export default function SettingsClient({ space, isAdmin, integrations, currentRole, areas: initialAreas }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<'space' | 'roles' | 'integrations' | 'webhooks'>('space')
|
||||
const [areas, setAreas] = useState<Area[]>(initialAreas)
|
||||
const [newArea, setNewArea] = useState({ code: '', name: '' })
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
|
||||
// Space form
|
||||
// Space form (basic + mission statement)
|
||||
const spaceExt = space as Space & {
|
||||
mission_statement?: string | null
|
||||
financial_visibility?: string
|
||||
member_directory_visibility?: string
|
||||
}
|
||||
const [spaceForm, setSpaceForm] = useState({
|
||||
name: space.name || '',
|
||||
slug: space.slug || '',
|
||||
city: space.city || '',
|
||||
require_approval: space.require_approval ?? true,
|
||||
public_member_directory: space.public_member_directory ?? false,
|
||||
mission_statement: spaceExt.mission_statement || '',
|
||||
})
|
||||
|
||||
// Visibility form (governance) — separate server action, separate save.
|
||||
const [visibilityForm, setVisibilityForm] = useState({
|
||||
financial_visibility: (spaceExt.financial_visibility as string | undefined) || 'board_visible',
|
||||
member_directory_visibility: (spaceExt.member_directory_visibility as string | undefined) || 'members_visible',
|
||||
})
|
||||
|
||||
// Integration config modal
|
||||
@@ -124,6 +149,20 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
async function handleSaveVisibility(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!isAdmin) return
|
||||
setSaving(true)
|
||||
setMessage(null)
|
||||
const result = await updateSpaceVisibility(visibilityForm)
|
||||
if ('error' in result && result.error) {
|
||||
setMessage({ type: 'error', text: result.error })
|
||||
} else {
|
||||
setMessage({ type: 'success', text: 'Visibility updated.' })
|
||||
}
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
async function handleSaveIntegration(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!editingIntegration || !isAdmin) return
|
||||
@@ -284,6 +323,19 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<label className="font-sans text-sm text-foreground block mb-1.5">Mission statement</label>
|
||||
<textarea
|
||||
value={spaceForm.mission_statement}
|
||||
onChange={e => setSpaceForm(f => ({ ...f, mission_statement: e.target.value }))}
|
||||
disabled={!isAdmin}
|
||||
maxLength={5000}
|
||||
rows={4}
|
||||
placeholder="One or two sentences. Rendered at the foot of every page as a reminder to members and the platform itself."
|
||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground disabled:opacity-50 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<p className="font-sans text-sm text-foreground mb-1.5">Invite code</p>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -317,6 +369,233 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Governance Visibility (still under Space tab) */}
|
||||
{activeTab === 'space' && (
|
||||
<div className="bg-card rounded border border-border p-6 mt-6">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-1">Visibility</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground mb-6">
|
||||
Who sees financial data and the member directory. These flags propagate to the
|
||||
/financials page and (in a future pass) to the members directory.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSaveVisibility} className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="font-sans text-sm text-foreground block mb-1.5">Financial data visibility</label>
|
||||
<select
|
||||
value={visibilityForm.financial_visibility}
|
||||
onChange={e => setVisibilityForm(f => ({ ...f, financial_visibility: e.target.value }))}
|
||||
disabled={!isAdmin}
|
||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground disabled:opacity-50 focus:outline-none focus:border-primary"
|
||||
>
|
||||
{financialVisibilities.map(v => (
|
||||
<option key={v} value={v}>{v.replace(/_/g, ' ')}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-1">
|
||||
Treasurer-only is the default in cooperatives historically. Board-visible is
|
||||
the recommended default. All-members-visible is full transparency.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-sans text-sm text-foreground block mb-1.5">Member directory visibility</label>
|
||||
<select
|
||||
value={visibilityForm.member_directory_visibility}
|
||||
onChange={e => setVisibilityForm(f => ({ ...f, member_directory_visibility: e.target.value }))}
|
||||
disabled={!isAdmin}
|
||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground disabled:opacity-50 focus:outline-none focus:border-primary"
|
||||
>
|
||||
{directoryVisibilities.map(v => (
|
||||
<option key={v} value={v}>{v.replace(/_/g, ' ')}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-1">
|
||||
Public-members-visible exposes the directory at a public URL. Count-only
|
||||
surfaces the headcount but no individual names.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="bg-primary text-white font-sans text-sm px-6 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save visibility'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Areas (still under Space tab) */}
|
||||
{activeTab === 'space' && (
|
||||
<div className="bg-card rounded border border-border p-6 mt-6">
|
||||
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-1">Areas</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground mb-6">
|
||||
Areas appear in the Tasks, Projects, and KB dropdowns. Defaults are seeded when a space
|
||||
is created. Rename, reorder, or archive freely; existing tasks tagged with the old
|
||||
name will not auto-rename.
|
||||
</p>
|
||||
|
||||
<ul className="divide-y divide-border mb-6">
|
||||
{areas.length === 0 ? (
|
||||
<li className="py-3 font-sans text-sm text-muted-foreground">No areas yet.</li>
|
||||
) : (
|
||||
areas.map(area => (
|
||||
<li key={area.id} className="py-2 flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
value={area.sort_order}
|
||||
onChange={async e => {
|
||||
const next = parseInt(e.target.value || '0', 10)
|
||||
setAreas(prev => prev.map(a => (a.id === area.id ? { ...a, sort_order: next } : a)))
|
||||
}}
|
||||
onBlur={async e => {
|
||||
if (!isAdmin) return
|
||||
const next = parseInt(e.target.value || '0', 10)
|
||||
await updateArea({ areaId: area.id, sort_order: next })
|
||||
}}
|
||||
disabled={!isAdmin}
|
||||
className="w-14 bg-background border border-border rounded px-2 py-1 font-mono text-xs text-foreground disabled:opacity-50 focus:outline-none focus:border-primary"
|
||||
aria-label="Sort order"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={area.name}
|
||||
onChange={e => {
|
||||
const next = e.target.value
|
||||
setAreas(prev => prev.map(a => (a.id === area.id ? { ...a, name: next } : a)))
|
||||
}}
|
||||
onBlur={async e => {
|
||||
if (!isAdmin) return
|
||||
const next = e.target.value.trim()
|
||||
if (!next || next === area.name) return
|
||||
const result = await updateArea({ areaId: area.id, name: next })
|
||||
if ('error' in result && result.error) {
|
||||
setMessage({ type: 'error', text: result.error })
|
||||
}
|
||||
}}
|
||||
disabled={!isAdmin}
|
||||
maxLength={80}
|
||||
className={`flex-1 bg-background border border-border rounded px-3 py-1.5 font-sans text-sm rounded ${
|
||||
area.is_archived ? 'text-muted-foreground line-through' : 'text-foreground'
|
||||
} disabled:opacity-50 focus:outline-none focus:border-primary`}
|
||||
/>
|
||||
<span className="font-mono text-[10px] text-muted-foreground/60 w-24 truncate" title={area.code}>
|
||||
{area.code}
|
||||
</span>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const result = await updateArea({ areaId: area.id, is_archived: !area.is_archived })
|
||||
if ('error' in result && result.error) {
|
||||
setMessage({ type: 'error', text: result.error })
|
||||
} else {
|
||||
setAreas(prev =>
|
||||
prev.map(a =>
|
||||
a.id === area.id ? { ...a, is_archived: !a.is_archived } : a,
|
||||
),
|
||||
)
|
||||
}
|
||||
}}
|
||||
className="font-mono text-[10px] tracking-widest uppercase border border-border text-muted-foreground hover:text-foreground rounded px-2 py-1"
|
||||
>
|
||||
{area.is_archived ? 'restore' : 'archive'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!confirm(`Delete area "${area.name}" entirely? Existing tagged items keep the name as plain text. Use Archive instead if it has been used.`)) return
|
||||
const result = await deleteArea(area.id)
|
||||
if ('error' in result && result.error) {
|
||||
setMessage({ type: 'error', text: result.error })
|
||||
} else {
|
||||
setAreas(prev => prev.filter(a => a.id !== area.id))
|
||||
}
|
||||
}}
|
||||
className="font-mono text-[10px] tracking-widest uppercase border border-border text-muted-foreground hover:text-red-500 hover:border-red-500 rounded px-2 py-1"
|
||||
>
|
||||
delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{isAdmin && (
|
||||
<form
|
||||
onSubmit={async e => {
|
||||
e.preventDefault()
|
||||
const code = newArea.code.trim().toLowerCase()
|
||||
const name = newArea.name.trim()
|
||||
if (!code || !name) return
|
||||
setSaving(true)
|
||||
const result = await createArea({ code, name })
|
||||
if ('error' in result && result.error) {
|
||||
setMessage({ type: 'error', text: result.error })
|
||||
} else if ('data' in result && result.data) {
|
||||
setAreas(prev =>
|
||||
[...prev, result.data as Area].sort(
|
||||
(a, b) => a.sort_order - b.sort_order || a.name.localeCompare(b.name),
|
||||
),
|
||||
)
|
||||
setNewArea({ code: '', name: '' })
|
||||
}
|
||||
setSaving(false)
|
||||
}}
|
||||
className="border-t border-border pt-4 grid grid-cols-1 sm:grid-cols-[1fr_2fr_auto] gap-3 items-end"
|
||||
>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newArea.code}
|
||||
onChange={e =>
|
||||
setNewArea(f => ({
|
||||
...f,
|
||||
code: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''),
|
||||
}))
|
||||
}
|
||||
maxLength={40}
|
||||
placeholder="leather"
|
||||
className="w-full bg-background border border-border rounded px-3 py-2 font-mono text-sm text-foreground focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
|
||||
Display name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newArea.name}
|
||||
onChange={e => setNewArea(f => ({ ...f, name: e.target.value }))}
|
||||
maxLength={80}
|
||||
placeholder="Leatherwork"
|
||||
className="w-full bg-background border border-border rounded px-3 py-2 font-sans text-sm text-foreground focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !newArea.code.trim() || !newArea.name.trim()}
|
||||
className="bg-primary text-white font-sans text-sm px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
|
||||
>
|
||||
Add area
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users & Roles Tab */}
|
||||
{activeTab === 'roles' && (
|
||||
<div className="bg-card rounded border border-border p-6">
|
||||
|
||||
@@ -16,10 +16,27 @@ export default async function TasksPage() {
|
||||
const spaceId = member?.space_id
|
||||
if (!spaceId) return null
|
||||
|
||||
const [{ data: tasks }, { data: members }] = await Promise.all([
|
||||
const [{ data: tasks }, { data: members }, { data: areasRaw }] = await Promise.all([
|
||||
supabase.from('tasks').select('*').eq('space_id', spaceId).order('created_at', { ascending: false }),
|
||||
supabase.from('space_members').select('id, display_name, user_id').eq('space_id', spaceId).in('status', ['current', 'late']),
|
||||
supabase
|
||||
.from('space_areas')
|
||||
.select('name')
|
||||
.eq('space_id', spaceId)
|
||||
.eq('is_archived', false)
|
||||
.order('sort_order', { ascending: true })
|
||||
.order('name', { ascending: true }),
|
||||
])
|
||||
|
||||
return <TasksClient tasks={tasks ?? []} members={members ?? []} currentUserId={user.id} spaceId={spaceId} />
|
||||
const areas = (areasRaw ?? []).map((a: { name: string }) => a.name)
|
||||
|
||||
return (
|
||||
<TasksClient
|
||||
tasks={tasks ?? []}
|
||||
members={members ?? []}
|
||||
currentUserId={user.id}
|
||||
spaceId={spaceId}
|
||||
areas={areas}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,12 +12,15 @@ interface Props {
|
||||
members: Pick<Tables<'space_members'>, 'id' | 'display_name' | 'user_id'>[]
|
||||
currentUserId: string
|
||||
spaceId: string
|
||||
/** Per-space area names. Sourced from `space_areas` server-side. */
|
||||
areas?: string[]
|
||||
}
|
||||
|
||||
const AREAS = ['3D Printing', 'Electronics', 'Woodshop', 'Laser', 'Metal Shop', 'Facilities', 'Admin', 'Kitchen', 'General']
|
||||
const DEFAULT_AREAS = ['3D Printing', 'Electronics', 'Woodshop', 'Laser', 'Metal Shop', 'Facilities', 'Admin', 'Kitchen', 'General']
|
||||
const RECURRENCES = ['none', 'daily', 'weekly', 'biweekly', 'monthly']
|
||||
|
||||
export function TasksClient({ tasks: initialTasks, members, currentUserId, spaceId }: Props) {
|
||||
export function TasksClient({ tasks: initialTasks, members, currentUserId, spaceId, areas }: Props) {
|
||||
const AREAS = areas && areas.length > 0 ? areas : DEFAULT_AREAS
|
||||
const [tasks, setTasks] = useState<Task[]>(initialTasks)
|
||||
const [activeTab, setActiveTab] = useState<'chores' | 'ongoing' | 'mine' | 'done'>('chores')
|
||||
const [filterArea, setFilterArea] = useState('')
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 200 },
|
||||
)
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { signIn } from '@/lib/auth-actions'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input} from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleEmailLogin(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
const result = await signIn(email, password)
|
||||
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
setLoading(false)
|
||||
} else {
|
||||
router.push('/dashboard')
|
||||
router.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOAuthLogin(provider: 'github' | 'google') {
|
||||
const supabase = createClient()
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider,
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/auth/callback`,
|
||||
},
|
||||
})
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--auth-bg)] flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="border-t-2 border-[var(--auth-accent)] mb-12"></div>
|
||||
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl font-bold text-[var(--auth-accent)] font-mono mb-2">
|
||||
{'{'}<span className="text-white">hackerspace.sh</span>{'}'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-6">
|
||||
Sign in to your space
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleEmailLogin} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="email" className="text-sm text-gray-400 uppercase tracking-wide">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-2 bg-black/40 border-gray-800 text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="password" className="text-sm text-gray-400 uppercase tracking-wide">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-2 bg-black/40 border-gray-800 text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-400 bg-red-950/30 border border-red-900/30 rounded px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-[var(--auth-accent)] hover:bg-[var(--auth-accent)]/90 text-black font-semibold"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in →'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 flex items-center gap-4">
|
||||
<div className="flex-1 border-t border-gray-800"></div>
|
||||
<span className="text-xs text-gray-600 uppercase">or</span>
|
||||
<div className="flex-1 border-t border-gray-800"></div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOAuthLogin('github')}
|
||||
className="w-full border-gray-800 bg-transparent text-white hover:bg-gray-900"
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
GitHub
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOAuthLogin('google')}
|
||||
className="w-full border-gray-800 bg-transparent text-white hover:bg-gray-900"
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Google
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
no account?{' '}
|
||||
<Link href="/auth/onboarding" className="text-[var(--auth-accent)] hover:underline">
|
||||
create or join a space →
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -345,7 +345,7 @@ export default function SignupPage() {
|
||||
</div>
|
||||
<div className="bg-[#232323] border border-[#333] rounded p-4">
|
||||
<p className="font-mono text-[10px] text-zinc-500 mb-1">You'll be the admin of this space.</p>
|
||||
<p className="font-mono text-[10px] text-zinc-500">Default channels (general, announcements, random) will be created automatically.</p>
|
||||
<p className="font-mono text-[10px] text-zinc-500">Default channels (general, announcements, ops) will be created automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import {
|
||||
LayoutDashboard, ListChecks, FolderKanban, Settings2, MessageSquare,
|
||||
Users, CreditCard, BookUser, Download, LogOut, ChevronDown
|
||||
} from 'lucide-react'
|
||||
import type { Tables } from '@/types/database'
|
||||
|
||||
const NAV = [
|
||||
{
|
||||
section: 'WORKSPACE',
|
||||
items: [
|
||||
{ label: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ label: 'Tasks & Chores', href: '/tasks', icon: ListChecks, badge: 7 },
|
||||
{ label: 'Projects', href: '/projects', icon: FolderKanban },
|
||||
{ label: 'Ops & Facilities', href: '/ops', icon: Settings2 },
|
||||
{ label: 'Comms', href: '/comms', icon: MessageSquare, badge: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'PEOPLE',
|
||||
items: [
|
||||
{ label: 'Members', href: '/members', icon: Users },
|
||||
{ label: 'Payments', href: '/payments', icon: CreditCard, badge: 12, badgeColor: 'text-orange-500 bg-orange-500/10' },
|
||||
{ label: 'Contacts', href: '/contacts', icon: BookUser },
|
||||
],
|
||||
},
|
||||
{
|
||||
section: 'ADMIN',
|
||||
items: [
|
||||
{ label: 'Import / Sync', href: '/import', icon: Download },
|
||||
{ label: 'Settings', href: '/settings', icon: Settings2 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
interface Props {
|
||||
member: Tables<'space_members'>
|
||||
space: Tables<'spaces'>
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function AppShell({ member, space, children }: Props) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [signingOut, setSigningOut] = useState(false)
|
||||
|
||||
async function signOut() {
|
||||
setSigningOut(true)
|
||||
const supabase = createClient()
|
||||
await supabase.auth.signOut()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const initials = (member.display_name || 'U')
|
||||
.split(' ')
|
||||
.map((n: string) => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-[230px] flex-shrink-0 bg-sidebar flex flex-col h-full overflow-y-auto">
|
||||
{/* Logo */}
|
||||
<div className="px-4 pt-5 pb-4">
|
||||
<Link href="/dashboard" className="block">
|
||||
<span className="font-mono text-lg font-bold">
|
||||
<span className="text-primary">{'{'}</span>
|
||||
<span className="text-sidebar-foreground">hs</span>
|
||||
<span className="text-primary">{'}'}</span>
|
||||
</span>
|
||||
</Link>
|
||||
<p className="font-sans text-xs text-sidebar-foreground/50 mt-0.5 truncate">{space.name}</p>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 px-2 space-y-4">
|
||||
{NAV.map(({ section, items }) => (
|
||||
<div key={section}>
|
||||
<p className="font-mono text-[10px] text-sidebar-foreground/30 tracking-widest px-2 mb-1">
|
||||
{section}
|
||||
</p>
|
||||
{items.map(({ label, href, icon: Icon, badge, badgeColor }) => {
|
||||
const active = pathname === href || pathname.startsWith(href + '/')
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`flex items-center gap-2.5 px-2 py-1.5 rounded text-sm font-sans transition group ${
|
||||
active
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
{badge ? (
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full ${
|
||||
badgeColor || 'text-sidebar-foreground/50 bg-sidebar-foreground/10'
|
||||
}`}>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User footer */}
|
||||
<div className="p-3 border-t border-sidebar-border">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded bg-primary flex items-center justify-center text-xs font-mono font-bold text-white flex-shrink-0">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-sans text-sidebar-foreground truncate">{member.display_name}</p>
|
||||
<p className="text-[10px] font-mono text-sidebar-foreground/40">
|
||||
{member.role} · {member.tier}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={signOut}
|
||||
disabled={signingOut}
|
||||
title="Sign out"
|
||||
className="text-sidebar-foreground/30 hover:text-sidebar-foreground/70 transition"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ type Space = Tables<'spaces'>
|
||||
import {
|
||||
Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2,
|
||||
MessageSquare, Users, CreditCard, BookUser, Download, LogOut,
|
||||
Vote, ShieldAlert, ScrollText, LineChart, UserCircle, UserSearch,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface NavLinkProps {
|
||||
@@ -93,12 +94,24 @@ export function AppSidebar({ member, taskBadge = 0, commsBadge = 0, paymentBadge
|
||||
<NavLink href="/ops" label="Ops & Facilities" icon={Settings2} active={isActive('/ops')} onClick={onNav} />
|
||||
<NavLink href="/comms" label="Comms" icon={MessageSquare} active={isActive('/comms')} badge={commsBadge} onClick={onNav} />
|
||||
|
||||
<div className="px-3 mt-4 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Governance</p>
|
||||
</div>
|
||||
<NavLink href="/proposals" label="Proposals" icon={Vote} active={isActive('/proposals')} onClick={onNav} />
|
||||
<NavLink href="/incidents" label="Incidents" icon={ShieldAlert} active={isActive('/incidents')} onClick={onNav} />
|
||||
<NavLink href="/policies" label="Policies" icon={ScrollText} active={isActive('/policies')} onClick={onNav} />
|
||||
|
||||
<div className="px-3 mt-4 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">People</p>
|
||||
</div>
|
||||
<NavLink href="/members" label="Members" icon={Users} active={isActive('/members')} onClick={onNav} />
|
||||
<NavLink href="/payments" label="Payments" icon={CreditCard} active={isActive('/payments')} badge={paymentBadge} onClick={onNav} />
|
||||
<NavLink href="/financials" label="Financials" icon={LineChart} active={isActive('/financials')} onClick={onNav} />
|
||||
<NavLink href="/contacts" label="Contacts" icon={BookUser} active={isActive('/contacts')} onClick={onNav} />
|
||||
<NavLink href="/profile" label="My profile" icon={UserCircle} active={isActive('/profile')} onClick={onNav} />
|
||||
{isAdmin && (
|
||||
<NavLink href="/recruitment" label="Recruitment" icon={UserSearch} active={isActive('/recruitment')} onClick={onNav} />
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
/**
|
||||
* Server-renderable markdown component for governance text (proposals,
|
||||
* incidents, policies). Renders bold, italic, lists, links, code, tables,
|
||||
* strikethrough, and autolinks via GFM. All HTML is escaped by react-markdown
|
||||
* by default; we never pass `rehypeRaw` so embedded `<script>` tags or
|
||||
* arbitrary HTML in user input are inert.
|
||||
*
|
||||
* `content` is treated as untrusted member input.
|
||||
*/
|
||||
export function MarkdownBody({ content, className = '' }: { content: string | null | undefined; className?: string }) {
|
||||
if (!content || content.trim().length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`markdown-body font-sans text-sm text-foreground/90 ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => <h1 className="text-xl font-semibold text-foreground mt-4 mb-2">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-lg font-semibold text-foreground mt-4 mb-2">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-base font-semibold text-foreground mt-3 mb-1">{children}</h3>,
|
||||
p: ({ children }) => <p className="my-2 leading-relaxed">{children}</p>,
|
||||
ul: ({ children }) => <ul className="list-disc list-outside pl-5 my-2 space-y-1">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-outside pl-5 my-2 space-y-1">{children}</ol>,
|
||||
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline hover:no-underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ children }) => (
|
||||
<code className="bg-muted px-1 py-0.5 rounded font-mono text-xs">{children}</code>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="bg-muted border border-border rounded p-3 my-2 overflow-x-auto font-mono text-xs">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-border pl-3 my-2 text-muted-foreground italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto my-3">
|
||||
<table className="border-collapse text-sm">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-border px-2 py-1 bg-muted text-left font-semibold">{children}</th>
|
||||
),
|
||||
td: ({ children }) => <td className="border border-border px-2 py-1">{children}</td>,
|
||||
hr: () => <hr className="my-4 border-border" />,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Production-style local deploy of the hackerspace-management app.
|
||||
# Reads secrets from .env.local. For a real droplet, point env_file at
|
||||
# .env.production and lock the file to 0600.
|
||||
# -----------------------------------------------------------------------------
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
|
||||
image: hackerspace-management:latest
|
||||
container_name: hackerspace_app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- .env.local
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--no-verbose",
|
||||
"--tries=1",
|
||||
"--spider",
|
||||
"http://localhost:3000/api/health",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: hackerspace_net
|
||||
+471
@@ -0,0 +1,471 @@
|
||||
# Codebase Audit
|
||||
|
||||
Date: 2026-05-13 through 2026-05-14 (three passes)
|
||||
Scope: every file outside `node_modules`, `.next`, and `.git`.
|
||||
Method: read each source file, cross-check against the live database type definitions in `types/database.ts`, the canonical schema in `scripts/schema.sql`, and the column map in `DB_SCHEMA_MAP.md`.
|
||||
|
||||
This document supersedes the older `AUDIT.md`, `IMPLEMENTATION_STATUS.md`, and `docs/PRODUCTION_AUDIT.md` for findings as of this date. Those files remain as historical context.
|
||||
|
||||
- **Pass 1** (2026-05-13): fresh-deploy blockers and deployment artifacts.
|
||||
- **Pass 2** (2026-05-13): validation schema enum drift, RLS privilege escalation, dashboard task filter bug, dead duplicate routes, auth function duplication.
|
||||
- **Pass 3** (2026-05-14): architecture refactor. Split monolithic `lib/actions.ts` by domain, introduced `lib/permissions.ts` and `lib/auth-helpers.ts`, wired validation into every action with a schema, added 76 real tests, deleted remaining dead code, added `scripts/README.md`.
|
||||
|
||||
All three passes are reflected below.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the system is
|
||||
|
||||
A multi-tenant member-management app for hackerspaces. Each tenant is a `spaces` row; every other table is keyed by `space_id`. Authentication uses Supabase Auth (email + password, OAuth wiring is stubbed). All data access goes through Supabase with row-level security enforced at the database. The Next.js app uses server components for reads, server actions for writes, and one client-side Realtime subscription for chat.
|
||||
|
||||
Stack:
|
||||
|
||||
| Layer | Stack |
|
||||
|-------|-------|
|
||||
| Framework | Next.js 16 (App Router), React 19, TypeScript 5.7 |
|
||||
| Styling | Tailwind v4 (CSS-native config), shadcn/ui, Lucide icons |
|
||||
| Backend | Next.js server actions, one REST route under `/api/paypal/sync`, one `/api/health` route |
|
||||
| Data | Supabase Postgres + Auth + Realtime |
|
||||
| Tests | Vitest (unit), Playwright (E2E) |
|
||||
| Hosting | Vercel, DigitalOcean App Platform, or self-hosted Droplet (Dockerfile + compose ship with the repo) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Repo layout
|
||||
|
||||
```
|
||||
/
|
||||
app/
|
||||
(app)/ Protected routes. Layout enforces auth + membership.
|
||||
dashboard/ Server component, parallel counts.
|
||||
tasks/ Server page + client component.
|
||||
projects/ Kanban-style board.
|
||||
members/ Admin/board only mutations.
|
||||
payments/ Treasurer/admin/board only.
|
||||
comms/ Realtime chat (Supabase channel subscription).
|
||||
contacts/ Member-writable CRUD.
|
||||
ops/ Knowledge base, secrets, area leads.
|
||||
ops/[id]/ KB entry detail + editor.
|
||||
ops/new/ KB entry create form.
|
||||
import/ CSV import UI (partial).
|
||||
settings/ Space + integrations.
|
||||
(landing)/ Public landing page.
|
||||
auth/callback/route.ts OAuth code exchange.
|
||||
api/
|
||||
health/route.ts Liveness probe (added this audit).
|
||||
paypal/sync/route.ts Per-space PayPal pull.
|
||||
login/, signup/ Public auth pages.
|
||||
layout.tsx, page.tsx Root layout, root redirect.
|
||||
components/
|
||||
ui/ shadcn primitives.
|
||||
app-sidebar.tsx The actual sidebar (role-gated).
|
||||
app-shell.tsx DEAD: not imported by any page.
|
||||
task-claim-button.tsx Used by tasks-client.
|
||||
theme-provider.tsx next-themes wrapper.
|
||||
hooks/
|
||||
use-mobile.ts, use-toast.ts
|
||||
lib/
|
||||
supabase/
|
||||
client.ts Browser client.
|
||||
server.ts Server component client (cookie-bound).
|
||||
admin.ts Service-role client. Bypasses RLS.
|
||||
proxy.ts Middleware session refresher.
|
||||
actions.ts All mutation server actions.
|
||||
auth-actions.ts Auth-specific server actions.
|
||||
validations.ts Zod schemas (most are not wired up, see findings).
|
||||
security.ts Sanitisation, rate limiting, helpers.
|
||||
utils.ts Tailwind classname merge.
|
||||
types.ts Re-exports from generated database types.
|
||||
types/
|
||||
database.ts Generated Supabase types. Source of truth for column shape.
|
||||
middleware.ts Auth gate. Public routes list lives here.
|
||||
scripts/
|
||||
schema.sql Canonical, idempotent, full-schema deploy.
|
||||
014_member_user_id_nullable.sql Most recent incremental migration.
|
||||
patch-dashboard.mjs, fix-actions.js, fix-sidebar.js, fix-sidebar.mjs DEAD scripts.
|
||||
__tests__/ Vitest specs.
|
||||
e2e/ Playwright specs.
|
||||
docs/ Architecture, deployment, database, API, audit.
|
||||
Dockerfile, docker-compose.yml, .dockerignore Self-host artifacts.
|
||||
.do/app.yaml DigitalOcean App Platform spec.
|
||||
.env.example Env var template.
|
||||
CLAUDE.md Working agreement.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Issues fixed in this audit
|
||||
|
||||
### 3.1 `space_members.user_id` was NOT NULL but the app needs it nullable
|
||||
|
||||
The schema declared:
|
||||
|
||||
```sql
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE
|
||||
```
|
||||
|
||||
But `addMember()` (`lib/actions.ts:302`) and `importMembers()` (`lib/actions.ts:897`) insert rows with no `user_id`. These are "offline" members added by an admin or CSV-imported, who have not signed up yet. On a fresh Supabase project, both code paths fail with a NOT NULL violation.
|
||||
|
||||
Fix applied:
|
||||
- `scripts/schema.sql`: dropped `NOT NULL` on `space_members.user_id`. Added a comment explaining the choice.
|
||||
- `scripts/014_member_user_id_nullable.sql`: new incremental migration for existing databases.
|
||||
|
||||
The `(space_id, user_id)` unique constraint still works because Postgres treats NULL as distinct, so multiple offline members with `user_id IS NULL` coexist. The partial unique index on `(space_id, email)` (`WHERE email IS NOT NULL`) prevents duplicate offline-member emails.
|
||||
|
||||
### 3.2 Realtime publication statements were not idempotent
|
||||
|
||||
The closing two lines of `schema.sql` were:
|
||||
|
||||
```sql
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_messages;
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_channels;
|
||||
```
|
||||
|
||||
`ALTER PUBLICATION ... ADD TABLE` errors if the table is already in the publication, which breaks the "idempotent" promise the file makes in its header. Wrapped both in `DO $$ ... EXCEPTION WHEN duplicate_object THEN NULL; END $$` blocks.
|
||||
|
||||
### 3.3 RLS policies were not idempotent
|
||||
|
||||
Section 6 of `schema.sql` does `CREATE POLICY ...` without `IF NOT EXISTS` (which Postgres 15 does not support). Re-running the file on a database that already has policies errored with `policy ... already exists`. Added a block of `DROP POLICY IF EXISTS` statements before the `CREATE POLICY` block.
|
||||
|
||||
Net result of 3.1, 3.2, 3.3: `scripts/schema.sql` now runs cleanly on both a fresh Supabase project and an existing one. The header comment claiming idempotency is now true.
|
||||
|
||||
### 3.4 Missing deployment artifacts
|
||||
|
||||
The deployment guide instructed users to "create" a Dockerfile, a docker-compose file, and to manually edit `next.config.mjs`. None of these artifacts existed in the repo. App Platform users had no spec to reference.
|
||||
|
||||
Added:
|
||||
- `Dockerfile` (multi-stage, standalone output, runs as non-root, healthcheck).
|
||||
- `docker-compose.yml` (service definition with healthcheck, env_file).
|
||||
- `.dockerignore` (excludes git, node_modules, dev artifacts, secrets).
|
||||
- `.do/app.yaml` (App Platform spec with health check path).
|
||||
- `.env.example` (every variable the app reads, with comments).
|
||||
- `app/api/health/route.ts` (liveness probe used by Docker, App Platform, and operators).
|
||||
- Updated `middleware.ts` to include `/api/health` in the public routes list so the healthcheck does not redirect to `/login`.
|
||||
- Updated `docs/DEPLOYMENT.md` to reference the bundled artifacts instead of asking users to write their own. Also removed the incorrect note about manually editing `next.config.mjs` (the file already keys `output: 'standalone'` off `DOCKER_BUILD=1`, which the Dockerfile sets).
|
||||
|
||||
A fresh clone is now: `pnpm install`, copy `.env.example` to `.env.local`, run `scripts/schema.sql` in Supabase, `pnpm dev`.
|
||||
|
||||
### 3.5 Dashboard "Quick Chores" filter was wrong
|
||||
|
||||
`app/(app)/dashboard/page.tsx:49` filtered tasks with `.neq('status', 'done')` to find open work. The actual task_status enum has both `done` (legacy) and `completed`, and `completeTask()` writes `'completed'`. Completed tasks therefore leaked into the dashboard's open-tasks list. Replaced with a positive filter on the open statuses: `open, claimed, in_progress, overdue, due_today, blocked`.
|
||||
|
||||
### 3.6 Zod validation schemas had wrong enum values
|
||||
|
||||
`lib/validations.ts` declared enums that disagreed with the database in six places. The schemas were mostly unused (only `createTask` and `taskId` were wired), so the bugs were latent. Both fixed:
|
||||
|
||||
- `createTaskSchema.type`: was free-form string, now `enum(['task','chore'])`.
|
||||
- `createTaskSchema.recurrence`: missing `biweekly`. Added.
|
||||
- `updateProjectStatusSchema.status`: had `planning, active, paused, completed, cancelled`. Replaced with the real `backlog, in_progress, review, done, blocked`.
|
||||
- `addMemberSchema.role` and `updateMemberSchema.role`: missing `associate`. Added.
|
||||
- `createContactSchema.contact_type`: had `vendor, sponsor, partner, city, media, other`. Replaced with the real `vendor, supplier, partner, landlord, city`.
|
||||
- `createKbEntrySchema.visibility`: had `public, members, board, admin`. Replaced with the real `all_members, board, admin_only`.
|
||||
|
||||
Also tightened: `logCashPaymentSchema` (removed unused `from_identifier`, added `member_id`), `updateSpaceSettingsSchema` (added `slug`), `upsertAreaLeadSchema` (matched the action's parameter shape).
|
||||
|
||||
### 3.7 Privilege escalation on `space_members` self-update
|
||||
|
||||
The `members_update` RLS policy let a member update their own row including `role`, `tier`, `status`, `approved`, and `has_card_access`. An authenticated member could PATCH their own membership via PostgREST and become an admin.
|
||||
|
||||
Fixed with a `BEFORE UPDATE` trigger (`prevent_member_self_role_change`) that rejects any change to protected columns when the row belongs to the current user and the current user is not already admin/board/treasurer in that space. Service-role calls (no `auth.uid()`) and privileged self-edits are unaffected. Applied to `schema.sql` and shipped as incremental migration `scripts/015_prevent_member_self_role_change.sql`.
|
||||
|
||||
### 3.8 Validation wired into the highest-risk actions
|
||||
|
||||
The Zod schemas were imported by `lib/actions.ts` but never actually invoked except by `createTask` and `claimTask`. Wired the now-correct schemas into:
|
||||
|
||||
- `createTask` (already)
|
||||
- `claimTask` (already)
|
||||
- `createProject`
|
||||
- `updateProjectStatus`
|
||||
- `addMember`
|
||||
- `logCashPayment`
|
||||
- `linkPaymentToMember`
|
||||
|
||||
Remaining unwired actions are noted in section 4.
|
||||
|
||||
### 3.9 Duplicate auth functions in `lib/actions.ts`
|
||||
|
||||
Lines 30 to 47 of `lib/actions.ts` redeclared `signIn`, `signOut`, and `getUser`, each a slightly worse copy of the version in `lib/auth-actions.ts`. No call site imported them from `actions.ts`. Removed.
|
||||
|
||||
### 3.10 Dead one-off scripts removed
|
||||
|
||||
Deleted `scripts/fix-actions.js`, `scripts/fix-sidebar.js`, `scripts/fix-sidebar.mjs`, and `scripts/patch-dashboard.mjs`. All four had hardcoded `/vercel/share/v0-project` paths and were one-time patches that have long since landed in the tree.
|
||||
|
||||
### 3.11 `types/database.ts` did not match the schema for `user_id`
|
||||
|
||||
After making `space_members.user_id` nullable in `scripts/schema.sql`, the generated types were stale. Updated `Row.user_id` to `string | null` and `Insert.user_id` / `Update.user_id` to optional nullable. Regenerate properly with `supabase gen types typescript` on next schema sync.
|
||||
|
||||
### 3.12 Signup page copy referenced channels that are not created
|
||||
|
||||
The "Create Space" panel told users "Default channels (general, announcements, random) will be created automatically." The schema's `create_default_channels` trigger creates `general`, `announcements`, `ops`. Updated the copy to match.
|
||||
|
||||
### 3.13 Monolithic `lib/actions.ts` split by domain
|
||||
|
||||
`lib/actions.ts` was 914 lines and held 29 exported server actions for every entity in the system. Maintenance friction was high. The duplication was severe: 25+ copies of "get user, then look up active member, then check role" and "insert activity_log row" boilerplate.
|
||||
|
||||
Refactored into `lib/actions/` containing:
|
||||
|
||||
- `tasks.ts`, `projects.ts`, `members.ts`, `contacts.ts`, `payments.ts`, `knowledge-base.ts`, `secrets.ts`, `area-leads.ts`, `settings.ts`, `imports.ts` — one file per entity, each carrying `'use server'`.
|
||||
- `index.ts` — barrel that re-exports every action so the public import path `@/lib/actions` remains stable.
|
||||
|
||||
Every client component that imports from `@/lib/actions` was verified to keep working. The barrel is plain re-exports; Next.js preserves the `'use server'` marking from the source files.
|
||||
|
||||
### 3.14 Shared helpers in `lib/auth-helpers.ts` and `lib/permissions.ts`
|
||||
|
||||
Two new modules absorb the repeated boilerplate.
|
||||
|
||||
`lib/permissions.ts`:
|
||||
- `ROLES`, `ADMIN_ROLES`, `TREASURER_ROLES`, `ALL_ROLES`, `ACTIVE_STATUSES` as const tuples.
|
||||
- `Role` and `MemberStatus` type aliases.
|
||||
- `hasRole(role, allowed)` helper.
|
||||
|
||||
`lib/auth-helpers.ts`:
|
||||
- `getAuthMember(supabase)`: returns the active member or null.
|
||||
- `requireMember(supabase)`: returns `{ ok: true, member } | { ok: false, error }`.
|
||||
- `requireMemberWithRole(supabase, allowed, label?)`: same plus role gate.
|
||||
- `parseInput(schema, input)`: Zod parse with a discriminated result.
|
||||
- `logActivity(supabase, member, action, entityType, entityId, details?)`: fire-and-forget activity log insert.
|
||||
|
||||
The result types use the canonical `ok: boolean` discriminator so TypeScript narrows reliably under `strict: true`.
|
||||
|
||||
### 3.15 Validation wired into every action that has a schema
|
||||
|
||||
After pass 2, only 7 actions called their schemas. Pass 3 wired the remaining 11:
|
||||
|
||||
- `updateMember`, `approveMember`, `removeMember`
|
||||
- `createContact`, `updateContact`, `deleteContact`
|
||||
- `createKbEntry`, `updateKbEntry`, `deleteKbEntry`
|
||||
- `createSecret`, `deleteSecret`
|
||||
- `upsertAreaLead`
|
||||
- `updateSpaceSettings`, `saveIntegration`
|
||||
- `disconnectIntegration` (inline string check, no Zod schema needed)
|
||||
- `importMembers`, `importPaymentsCsv` (array-level guard + filter)
|
||||
|
||||
Bad input now returns a clean error message instead of a database-level failure.
|
||||
|
||||
### 3.16 Real tests, not assertion theatre
|
||||
|
||||
The existing `__tests__/actions.test.ts` and `__tests__/utils.test.ts` were inline value assertions that never imported project code. Kept for compatibility, but four new files exercise the real units:
|
||||
|
||||
| File | What it covers |
|
||||
|------|----------------|
|
||||
| `__tests__/validations.test.ts` | Every Zod schema, asserting real DB enum values pass and legacy ones fail. 37 tests. |
|
||||
| `__tests__/permissions.test.ts` | Role constants, `ACTIVE_STATUSES`, `hasRole`. 10 tests. |
|
||||
| `__tests__/security.test.ts` | `sanitizeString`, `escapeHtml`, `stripHtml`, `sanitizeEmail`, `sanitizeUrl`, `sanitizeSlug`, `hasSqlInjectionPatterns`, `isValidUuid`, `truncate`, `checkRateLimit`, `validateContentLength`. 25 tests. |
|
||||
| `__tests__/auth-helpers.test.ts` | `parseInput` parse/error path. 4 tests. |
|
||||
|
||||
Total: 202 tests across 7 files, all green.
|
||||
|
||||
### 3.17 Vitest config fixed
|
||||
|
||||
- `vitest.setup.ts` had JSX inside a `.ts` file (esbuild refused: "Expected '>' but found '{'"). Replaced the `next/image` mock with `React.createElement`.
|
||||
- `vitest.config.ts` was picking up Playwright e2e specs and trying to run them. Added explicit `include` and `exclude` globs.
|
||||
|
||||
### 3.18 Final dead-code sweep
|
||||
|
||||
Deleted:
|
||||
- `app/auth/login/page.tsx` (dead duplicate login at `/auth/login` linking to non-existent `/auth/onboarding`).
|
||||
- `components/app-shell.tsx` (no importer; hardcoded badge counts; no role gating).
|
||||
|
||||
### 3.19 Shared types consolidated in `lib/types.ts`
|
||||
|
||||
`lib/types.ts` already re-exported the generated row and enum types. Added:
|
||||
- `MemberSummary`: common projection used by sidebars and props.
|
||||
- `ActionResult<T>`: standard server-action return shape.
|
||||
- Re-exports of `Role`, `ROLES`, `ADMIN_ROLES`, `TREASURER_ROLES`, `ALL_ROLES`, `ACTIVE_STATUSES`, `hasRole` from `lib/permissions.ts`.
|
||||
- Re-exports of `Member`, `Result`, `MemberResult`, `ServerSupabase` from `lib/auth-helpers.ts`.
|
||||
|
||||
One-stop import for any consumer of typed entities.
|
||||
|
||||
### 3.20 Migration conventions documented
|
||||
|
||||
Added `scripts/README.md` covering: canonical vs incremental files, naming, the idempotency rules (`IF NOT EXISTS`, `DO ... EXCEPTION`, `DROP POLICY IF EXISTS`, etc), the security baseline for new tables, and verification queries for fresh deploys.
|
||||
|
||||
---
|
||||
|
||||
## 4. Open issues (not fixed in this pass)
|
||||
|
||||
These are documented for the next person, not silently shipped. Each one has a concrete location and a concrete fix.
|
||||
|
||||
### 4.1 (was open: dead scripts) RESOLVED in pass 2. See 3.10.
|
||||
|
||||
### 4.2 (was open: dead `components/app-shell.tsx`) RESOLVED in pass 3. See 3.18.
|
||||
|
||||
### 4.3 (was open: dead `app/auth/login`) RESOLVED in pass 3. See 3.18.
|
||||
|
||||
### 4.4 (was open: validation not wired everywhere) RESOLVED in pass 3. See 3.15.
|
||||
|
||||
### 4.4a Supabase TypeScript generic drift
|
||||
|
||||
`@supabase/ssr` and `@supabase/supabase-js` emit `SupabaseClient` with 3 generics. Our `types/database.ts` was generated against an older signature. The result: 38 `lib/actions/*` and 103 `app/(app)/*` TypeScript errors of the form `Argument of type '{...}' is not assignable to parameter of type 'never'`.
|
||||
|
||||
These errors already existed in the pre-refactor codebase. They are masked by `next.config.mjs: typescript.ignoreBuildErrors: true`. The runtime is unaffected: Supabase's PostgREST client accepts the values fine.
|
||||
|
||||
Fix: regenerate `types/database.ts` with the latest Supabase CLI (`supabase gen types typescript --project-id <ref>`). Then drop `typescript.ignoreBuildErrors`.
|
||||
|
||||
### 4.5 `signUp` metadata is not consumed by the auth trigger
|
||||
|
||||
`lib/auth-actions.ts:signUp` passes metadata keys `full_name, action, space_name, space_slug, city, invite_code`. The `handle_space_signup` trigger in `schema.sql` reads `space_id, role, display_name`. Different keys, so the trigger short-circuits and does nothing. That is actually fine because `createSpace` and `joinSpace` insert the membership themselves using the admin client. But the trigger is then dead code unless someone passes `space_id` in `data`. Either:
|
||||
|
||||
- Document the trigger as a future-use safety net and add a code comment, or
|
||||
- Remove the trigger and stop pretending it does anything.
|
||||
|
||||
### 4.6 Several documented features are UI-only or unfinished
|
||||
|
||||
| Feature | Status | Location |
|
||||
|---------|--------|----------|
|
||||
| GitHub / Google OAuth | Client wired (`signInWithOAuth`) but needs provider config in Supabase Auth | `app/login/page.tsx:31` |
|
||||
| Webhook endpoint | URL displayed in settings, no handler | (none) |
|
||||
| Zeffy, Venmo, Stripe sync | Config storage only, no API code | `lib/actions.ts:saveIntegration` |
|
||||
| CSV import | UI uploads a file but does not process it | `app/(app)/import/import-client.tsx` |
|
||||
| Email notifications | None | (none) |
|
||||
| Task recurrence auto-rollover | Cron job not implemented | (none) |
|
||||
| Secrets encryption | Plain text in DB | `secrets.value` |
|
||||
|
||||
None of these are runtime errors. They are missing-feature stubs. They are listed here so deployers know what is and is not real. Pass 1's audit incorrectly listed OAuth as "UI only, no handlers"; the client-side OAuth call is actually wired. What is missing is the provider configuration in the Supabase dashboard.
|
||||
|
||||
### 4.7 `package.json` name is `"my-project"`
|
||||
|
||||
Cosmetic. Should be `"hackerspace-management"`. Not a deploy blocker.
|
||||
|
||||
### 4.8 `tsconfig.json` has `"strict": true` but `next.config.mjs` has `typescript.ignoreBuildErrors: true`
|
||||
|
||||
The build does not fail on type errors. This is a v0 default that should be removed once the codebase passes `pnpm exec tsc --noEmit` cleanly. The pass-2 schema and types edits move the needle. Remaining issues are minor.
|
||||
|
||||
### 4.9 Default channels: 3 vs 4
|
||||
|
||||
`schema.sql` `create_default_channels` inserts 3 channels: `general`, `announcements`, `ops`. Older docs (`DB_SCHEMA_MAP.md`, `docs/ARCHITECTURE.md`) say 4 channels (`general`, `announcements`, `random`, `facilities`). The schema is current; the older docs are stale. The signup page copy was fixed in pass 2. Update `DB_SCHEMA_MAP.md` and `docs/ARCHITECTURE.md` on next docs pass.
|
||||
|
||||
### 4.10 (was open: members RLS privilege escalation) RESOLVED in pass 2. See 3.7.
|
||||
|
||||
---
|
||||
|
||||
## 5. Database column map versus code use
|
||||
|
||||
I cross-checked every column accessed in `lib/actions.ts`, `lib/auth-actions.ts`, `app/api/paypal/sync/route.ts`, and the client components named in `app/(app)/*`. Findings:
|
||||
|
||||
- `space_members.last_paid_at`: present in schema, used by `linkPaymentToMember` and `importMembers`. OK.
|
||||
- `space_members.last_payment_at`: legacy duplicate. Schema keeps both. Code reads either with a fallback (`members-client.tsx`).
|
||||
- `knowledge_base.is_pinned` vs `pinned`: schema has both, code uses `is_pinned`. OK.
|
||||
- `secrets.title` vs `label`: schema has both, code writes both fields (`createSecret` at `lib/actions.ts:701`). OK.
|
||||
- `payments.status` vs `link_status`: schema has both, code uses `link_status`. OK.
|
||||
- `integrations.config` vs `credentials` / `settings`: schema has all three for legacy compatibility, code uses `config`. OK.
|
||||
- `tasks.task_type`: column matches enum name. Server actions write `task_type`, never `type`. OK.
|
||||
- `member_status` filter in app uses `('current', 'unverified', 'late')`. Schema enum matches. OK.
|
||||
|
||||
No drift between active code paths and the schema beyond what is in section 4.
|
||||
|
||||
---
|
||||
|
||||
## 6. Security posture
|
||||
|
||||
| Control | State |
|
||||
|---------|-------|
|
||||
| RLS on every table | Yes |
|
||||
| Service role key isolated to server | Yes, but `lib/actions.ts:30-47` and other call sites should be reviewed periodically |
|
||||
| Input validation | Partial. `createSpace` sanitises. `createTask` and `claimTask` use Zod. The rest do not |
|
||||
| Rate limiting | Present in `lib/security.ts` as in-memory map. Wired into `signIn` and `createSpace`. Not persistent. For multi-instance deploys, switch to Redis (Upstash) |
|
||||
| Secrets encryption at rest | None. `secrets.value` and `integrations.config` are plain text. Encrypt before merging to production |
|
||||
| CSRF / Origin check | Implicit through Next server actions + same-origin cookies. No explicit Origin check |
|
||||
| Audit log | `activity_log` table is populated by most write actions |
|
||||
| Privilege escalation on `space_members` update | Possible per 4.11, fix before production |
|
||||
|
||||
---
|
||||
|
||||
## 7. Test posture
|
||||
|
||||
- `__tests__/utils.test.ts`: assertion-only tests of helpers, no real imports of project code. Useful as smoke tests, not as coverage.
|
||||
- `__tests__/actions.test.ts`: mocked Supabase client, tests the call shape of server actions. Does not exercise RLS, does not hit a database.
|
||||
- `__tests__/components.test.tsx`: not present, but listed in `TESTING.md`. The doc lies.
|
||||
- `e2e/auth.spec.ts` and `e2e/critical-flows.spec.ts`: present, Playwright. Not verified to pass.
|
||||
|
||||
Recommended next steps: stand up a Supabase local instance for integration tests, add a CI job that runs `pnpm test` and `pnpm test:e2e`, fix `TESTING.md` to reflect reality.
|
||||
|
||||
---
|
||||
|
||||
## 8. Performance notes
|
||||
|
||||
- `app/(app)/layout.tsx` makes three sequential Supabase calls (member, taskCount, paymentCount). The latter two can run in `Promise.all`.
|
||||
- `app/(app)/dashboard/page.tsx` runs multiple queries; check whether they parallelise. (Not audited line-by-line, see file.)
|
||||
- Realtime: only `comms_messages` and `comms_channels` are in the publication. Good.
|
||||
- Indexes: 22 indexes are defined in `schema.sql`, covering every foreign key and common filter (`tasks.status`, `payments.transaction_date`, `comms_messages.channel_id`). Good.
|
||||
|
||||
---
|
||||
|
||||
## 9. Deployment readiness summary
|
||||
|
||||
| Target | Ready? | Notes |
|
||||
|--------|--------|-------|
|
||||
| Local dev | Yes | `pnpm install && pnpm dev`, with `.env.local` filled |
|
||||
| Vercel | Yes | Push to GitHub, connect, set env vars |
|
||||
| DigitalOcean App Platform | Yes | Use `.do/app.yaml` or the UI flow |
|
||||
| DigitalOcean Droplet (Docker) | Yes | Dockerfile and compose ship with the repo |
|
||||
| Database fresh deploy | Yes | One file: `scripts/schema.sql` |
|
||||
| Database upgrade from existing | Yes | Run new incremental migrations in `scripts/` only |
|
||||
|
||||
A fresh deploy will succeed. The open issues in section 4 are quality and feature gaps, not deploy blockers.
|
||||
|
||||
---
|
||||
|
||||
## 10. What changed across the three passes
|
||||
|
||||
### Pass 1 (deployment hardening)
|
||||
|
||||
Edited:
|
||||
- `scripts/schema.sql`: user_id nullable, realtime idempotent, RLS policies idempotent.
|
||||
- `middleware.ts`: `/api/health` added to public routes.
|
||||
- `docs/DEPLOYMENT.md`: env section, App Platform section, Droplet section (no longer asks user to write the Dockerfile/compose), post-deploy checklist (health probe), upgrading section.
|
||||
|
||||
Added:
|
||||
- `CLAUDE.md`
|
||||
- `Dockerfile`, `.dockerignore`, `docker-compose.yml`
|
||||
- `.do/app.yaml`
|
||||
- `.env.example`
|
||||
- `app/api/health/route.ts`
|
||||
- `scripts/014_member_user_id_nullable.sql`
|
||||
- `docs/AUDIT.md` (this file)
|
||||
- `docs/HANDOFF.md`
|
||||
- `README.md` rewritten
|
||||
|
||||
### Pass 2 (application repairs)
|
||||
|
||||
Edited:
|
||||
- `app/(app)/dashboard/page.tsx`: open-task filter now lists open statuses positively instead of `neq('status','done')` (was leaking `completed` tasks).
|
||||
- `app/signup/page.tsx`: default channel list in copy now reads `general, announcements, ops` to match the trigger.
|
||||
- `lib/validations.ts`: aligned enums for tasks, projects, members, contacts, KB; tightened settings, area-lead, and cash-payment schemas.
|
||||
- `lib/actions.ts`: removed duplicate `signIn`, `signOut`, `getUser` (kept in `auth-actions.ts`); wired `createProject`, `updateProjectStatus`, `addMember`, `logCashPayment`, `linkPaymentToMember` to their Zod schemas.
|
||||
- `scripts/schema.sql`: added `prevent_member_self_role_change` trigger to stop privilege escalation on self-update.
|
||||
- `types/database.ts`: `space_members.user_id` now `string | null` to match the schema.
|
||||
|
||||
Added:
|
||||
- `scripts/015_prevent_member_self_role_change.sql`
|
||||
|
||||
Removed:
|
||||
- `scripts/fix-actions.js`, `scripts/fix-sidebar.js`, `scripts/fix-sidebar.mjs`, `scripts/patch-dashboard.mjs`
|
||||
|
||||
No business rules, no UI styling, no test code were changed. Pass 2 edits are bug fixes, security hardening, schema alignment, and dead-code removal.
|
||||
|
||||
### Pass 3 (architecture refactor + real tests)
|
||||
|
||||
Added:
|
||||
- `lib/permissions.ts` (role constants, `hasRole`)
|
||||
- `lib/auth-helpers.ts` (`requireMember`, `requireMemberWithRole`, `parseInput`, `logActivity`)
|
||||
- `lib/actions/` directory with 11 files (one per domain, plus `index.ts` barrel)
|
||||
- `__tests__/validations.test.ts` (37 tests)
|
||||
- `__tests__/permissions.test.ts` (10 tests)
|
||||
- `__tests__/security.test.ts` (25 tests)
|
||||
- `__tests__/auth-helpers.test.ts` (4 tests)
|
||||
- `scripts/README.md`
|
||||
|
||||
Edited:
|
||||
- `lib/types.ts` (now re-exports helpers + permissions for one-stop import)
|
||||
- `vitest.config.ts` (proper include/exclude globs)
|
||||
- `vitest.setup.ts` (replaced inline JSX in a .ts file with `React.createElement`)
|
||||
|
||||
Removed:
|
||||
- `lib/actions.ts` (replaced by `lib/actions/` directory; barrel preserves the public import path)
|
||||
- `app/auth/login/page.tsx` (dead duplicate)
|
||||
- `components/app-shell.tsx` (unused, hardcoded badges)
|
||||
|
||||
Test suite: **202 tests, 7 files, all passing**.
|
||||
|
||||
Pass 3 changes UI nothing, schema nothing, business rules nothing. They are pure organisation, test coverage, and dead-code removal.
|
||||
+76
-120
@@ -1,6 +1,8 @@
|
||||
# Hackerspace.sh — Deployment Guide
|
||||
|
||||
Complete instructions for every supported deployment target.
|
||||
Complete instructions for every supported deployment target that uses **managed Supabase Cloud** as the database / auth layer.
|
||||
|
||||
For a fully self-hosted deployment where you run Supabase yourself on a DigitalOcean Droplet (Postgres, GoTrue, PostgREST, Realtime, Storage, Studio — all on your own server), see [DEPLOY_DO_SELFHOSTED.md](./DEPLOY_DO_SELFHOSTED.md). That guide is end-to-end and assumes nothing else.
|
||||
|
||||
---
|
||||
|
||||
@@ -111,27 +113,27 @@ WHERE email = 'your@email.com';
|
||||
|
||||
## 3. Environment Variables
|
||||
|
||||
Create `.env.local` for local dev. For deployed environments, set these in your host's dashboard.
|
||||
The repo ships an `.env.example` file. Copy it locally:
|
||||
|
||||
```bash
|
||||
# ── Supabase ──────────────────────────────────────────────────
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://xxxxxxxxxxxx.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci...
|
||||
SUPABASE_SERVICE_ROLE_KEY=eyJhbGci...
|
||||
|
||||
# ── App ───────────────────────────────────────────────────────
|
||||
NEXT_PUBLIC_APP_URL=https://yourdomain.com
|
||||
|
||||
# ── PayPal (optional — set in app Settings UI too) ────────────
|
||||
# These are also stored per-space in the integrations table.
|
||||
# The API route reads from the DB, not from env vars.
|
||||
# No env vars needed for PayPal.
|
||||
|
||||
# ── Node ──────────────────────────────────────────────────────
|
||||
NODE_ENV=production
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
**Never commit `.env.local` to git.** The `.gitignore` already excludes it.
|
||||
Then fill in real values. For deployed environments, set the same keys in the host's dashboard.
|
||||
|
||||
Required variables:
|
||||
|
||||
| Key | Where it is used |
|
||||
|-----|------------------|
|
||||
| `NEXT_PUBLIC_SUPABASE_URL` | Browser + server, in every request |
|
||||
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser + server |
|
||||
| `SUPABASE_SERVICE_ROLE_KEY` | Server only. Bypasses RLS. Used by createSpace / joinSpace |
|
||||
| `NEXT_PUBLIC_APP_URL` | OAuth callback URLs |
|
||||
| `NODE_ENV` | Runtime mode |
|
||||
|
||||
Per-space integration credentials (PayPal, Zeffy, Venmo, Stripe) are stored in the `integrations` table, not in env. The Settings UI writes them; `/api/paypal/sync` reads them.
|
||||
|
||||
`.env.local` is git-ignored. Never commit real secrets.
|
||||
|
||||
---
|
||||
|
||||
@@ -190,24 +192,35 @@ git push origin feat/my-feature # triggers preview deploy
|
||||
|
||||
Managed container hosting. No Docker knowledge required. Scales automatically.
|
||||
|
||||
The repo ships an App Platform spec at `.do/app.yaml`. You can either apply it directly with `doctl` or use the UI flow below.
|
||||
|
||||
### 5.1 Push your repo to GitHub
|
||||
|
||||
DigitalOcean App Platform deploys directly from GitHub.
|
||||
App Platform deploys directly from GitHub.
|
||||
|
||||
```bash
|
||||
git remote add origin https://github.com/YOUR_ORG/hackerspace-management.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
### 5.2 Create the app
|
||||
### 5.2a Create the app via `doctl` (recommended)
|
||||
|
||||
Edit `.do/app.yaml` and replace `REPO_OWNER/REPO_NAME` and the placeholder env values. Then:
|
||||
|
||||
```bash
|
||||
doctl apps create --spec .do/app.yaml
|
||||
```
|
||||
|
||||
### 5.2b Or create via the UI
|
||||
|
||||
1. Go to [cloud.digitalocean.com/apps](https://cloud.digitalocean.com/apps)
|
||||
2. Click **Create App** → **GitHub** → authorize and select your repo
|
||||
2. Click **Create App** then **GitHub**, authorize and select your repo
|
||||
3. Select branch: `main`
|
||||
4. DigitalOcean auto-detects Next.js — confirm the build settings:
|
||||
- **Build command**: `pnpm install && pnpm build`
|
||||
4. App Platform auto-detects Next.js. Confirm the build settings:
|
||||
- **Build command**: `corepack enable && pnpm install --frozen-lockfile && pnpm build`
|
||||
- **Run command**: `pnpm start`
|
||||
- **HTTP port**: `3000`
|
||||
- **Health check path**: `/api/health`
|
||||
|
||||
### 5.3 Set environment variables
|
||||
|
||||
@@ -305,102 +318,38 @@ sudo chown -R deploy:deploy /opt/hackerspace
|
||||
cd /opt/hackerspace
|
||||
```
|
||||
|
||||
### 6.6 Create the Docker Compose file
|
||||
### 6.6 Use the bundled Dockerfile and Compose file
|
||||
|
||||
```bash
|
||||
cat > /opt/hackerspace/docker-compose.yml << 'EOF'
|
||||
version: "3.9"
|
||||
Both files ship in the repo root:
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: hackerspace_app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- .env.production
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: hackerspace_net
|
||||
EOF
|
||||
```
|
||||
Dockerfile (multi-stage build, standalone output, healthcheck)
|
||||
docker-compose.yml (service definition, env_file, healthcheck)
|
||||
.dockerignore
|
||||
```
|
||||
|
||||
### 6.7 Create the Dockerfile
|
||||
You do not need to write your own. The Dockerfile sets `DOCKER_BUILD=1` during build so `next.config.mjs` emits standalone output. The compose file reads `.env.local` by default; for production swap that to `.env.production` (next step) or edit the compose file inline.
|
||||
|
||||
### 6.7 Create the production env file
|
||||
|
||||
```bash
|
||||
cat > /opt/hackerspace/Dockerfile << 'EOF'
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable pnpm
|
||||
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
EOF
|
||||
cp /opt/hackerspace/.env.example /opt/hackerspace/.env.production
|
||||
nano /opt/hackerspace/.env.production
|
||||
```
|
||||
|
||||
### 6.8 Enable standalone output in Next.js config
|
||||
|
||||
Edit `next.config.mjs` and ensure it contains:
|
||||
|
||||
```js
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
// ...rest of your config
|
||||
}
|
||||
```
|
||||
|
||||
### 6.9 Create the production env file
|
||||
Fill in the real Supabase URL, anon key, service role key, and your app URL. Then lock the file:
|
||||
|
||||
```bash
|
||||
cat > /opt/hackerspace/.env.production << 'EOF'
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://xxxxxxxxxxxx.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci...
|
||||
SUPABASE_SERVICE_ROLE_KEY=eyJhbGci...
|
||||
NEXT_PUBLIC_APP_URL=https://yourdomain.com
|
||||
NODE_ENV=production
|
||||
EOF
|
||||
|
||||
# Lock down permissions — this file contains secrets
|
||||
chmod 600 /opt/hackerspace/.env.production
|
||||
```
|
||||
|
||||
### 6.10 Build and start the container
|
||||
Update `docker-compose.yml` to point at `.env.production` instead of `.env.local`, or symlink:
|
||||
|
||||
```bash
|
||||
ln -sf .env.production /opt/hackerspace/.env.local
|
||||
```
|
||||
|
||||
### 6.8 Build and start the container
|
||||
|
||||
```bash
|
||||
cd /opt/hackerspace
|
||||
@@ -409,6 +358,10 @@ docker compose up -d
|
||||
|
||||
# Check logs
|
||||
docker compose logs -f app
|
||||
|
||||
# Verify health endpoint
|
||||
curl http://localhost:3000/api/health
|
||||
# {"status":"ok","timestamp":"..."}
|
||||
```
|
||||
|
||||
### 6.11 Configure Nginx as reverse proxy
|
||||
@@ -526,31 +479,34 @@ sudo ufw status
|
||||
|
||||
Run through this after every fresh deployment:
|
||||
|
||||
- [ ] App loads at your domain with HTTPS
|
||||
- [ ] App loads at your domain over HTTPS
|
||||
- [ ] `GET /api/health` returns `{ "status": "ok" }`
|
||||
- [ ] `/signup` creates a user and redirects to `/dashboard`
|
||||
- [ ] Dashboard shows "No members yet" (not an error)
|
||||
- [ ] Supabase dashboard → **Authentication → Users** shows the signup
|
||||
- [ ] `space_members` table has a row for the new user
|
||||
- [ ] Chat page loads channels (general, announcements, ops)
|
||||
- [ ] Send a message — it appears immediately without page refresh
|
||||
- [ ] Upgrade your user to admin via SQL (see section 2.6)
|
||||
- [ ] Settings page → save space settings → no error toast
|
||||
- [ ] PayPal integration → save credentials → "LIVE" badge appears
|
||||
- [ ] Dashboard renders without errors (counts may be zero)
|
||||
- [ ] Supabase dashboard, Authentication > Users shows the signup
|
||||
- [ ] `space_members` table has a row for the new user, with `role = 'admin'` for the first user (`createSpace` sets this)
|
||||
- [ ] Chat page loads the three default channels (`general`, `announcements`, `ops`)
|
||||
- [ ] Send a message and watch it appear in another tab without refresh (verifies realtime)
|
||||
- [ ] Settings page saves without error
|
||||
- [ ] PayPal integration accepts credentials and shows the connected badge
|
||||
|
||||
---
|
||||
|
||||
## 8. Upgrading
|
||||
|
||||
When the schema changes, a new numbered script will be added to `scripts/`. For example `scripts/012_add_feature.sql`. Run only the new script in the Supabase SQL editor — do not re-run `schema.sql` on an existing database.
|
||||
When the schema changes, a new numbered script is added to `scripts/` (for example `scripts/014_member_user_id_nullable.sql`). On an existing database, run only the new script in the Supabase SQL editor.
|
||||
|
||||
```
|
||||
scripts/
|
||||
schema.sql ← fresh deployment only
|
||||
012_add_feature.sql ← incremental upgrade
|
||||
013_...sql
|
||||
schema.sql fresh deployment, full schema
|
||||
001_create_schema.sql historical, not used for fresh deploys
|
||||
...
|
||||
014_member_user_id_nullable.sql most recent incremental
|
||||
```
|
||||
|
||||
For Vercel / App Platform: push to `main`, the build auto-deploys.
|
||||
`scripts/schema.sql` is also idempotent: it uses `IF NOT EXISTS` for tables and indexes, `DROP POLICY IF EXISTS` before recreating policies, and `EXCEPTION WHEN duplicate_object` for enums and realtime publications. Re-running it on an existing database is safe but unnecessary.
|
||||
|
||||
For Vercel and App Platform: push to `main`, the build auto-deploys.
|
||||
|
||||
For Droplet: `ssh deploy@IP '/opt/hackerspace/deploy.sh'`
|
||||
|
||||
|
||||
@@ -0,0 +1,886 @@
|
||||
# Full self-hosted deployment on DigitalOcean
|
||||
|
||||
End-to-end guide to run the entire stack on your own hardware: self-hosted Supabase plus the Next.js app, both on a single DigitalOcean Droplet. No managed Supabase, no Vercel, no third-party SaaS for application data.
|
||||
|
||||
What you get at the end:
|
||||
- `https://yourdomain.com` serves the Next.js app.
|
||||
- `https://supabase.yourdomain.com` serves the Supabase API gateway (auth, REST, realtime).
|
||||
- `https://studio.yourdomain.com` serves Supabase Studio (password-protected).
|
||||
- Postgres, GoTrue (auth), PostgREST, Realtime, Storage, and Studio all run as Docker containers on the same Droplet.
|
||||
- Automatic SSL via Let's Encrypt.
|
||||
- Nightly Postgres backups to local disk (and optionally DigitalOcean Spaces).
|
||||
|
||||
If you want the managed-Supabase version instead, see [DEPLOYMENT.md](./DEPLOYMENT.md). That path is simpler but locks you into Supabase Cloud.
|
||||
|
||||
---
|
||||
|
||||
## Table of contents
|
||||
|
||||
1. [Architecture](#1-architecture)
|
||||
2. [Cost](#2-cost)
|
||||
3. [Prerequisites](#3-prerequisites)
|
||||
4. [Provision the Droplet](#4-provision-the-droplet)
|
||||
5. [Initial server hardening](#5-initial-server-hardening)
|
||||
6. [Install Docker, Nginx, Certbot](#6-install-docker-nginx-certbot)
|
||||
7. [DNS records](#7-dns-records)
|
||||
8. [Self-host Supabase](#8-self-host-supabase)
|
||||
9. [Apply the app schema and migrations](#9-apply-the-app-schema-and-migrations)
|
||||
10. [Deploy the Next.js app](#10-deploy-the-nextjs-app)
|
||||
11. [Nginx reverse proxy + SSL](#11-nginx-reverse-proxy--ssl)
|
||||
12. [Post-deploy verification](#12-post-deploy-verification)
|
||||
13. [Backups](#13-backups)
|
||||
14. [Operations: upgrades, logs, monitoring](#14-operations-upgrades-logs-monitoring)
|
||||
15. [Hardening checklist](#15-hardening-checklist)
|
||||
16. [Troubleshooting](#16-troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture
|
||||
|
||||
```
|
||||
Internet
|
||||
|
|
||||
v
|
||||
DigitalOcean Droplet (Ubuntu 24.04)
|
||||
|
|
||||
|-- Nginx (80, 443)
|
||||
| |
|
||||
| |-- yourdomain.com -> localhost:3000 (Next.js app)
|
||||
| |-- supabase.yourdomain.com -> localhost:8000 (Kong, Supabase API gateway)
|
||||
| `-- studio.yourdomain.com -> localhost:3001 (Supabase Studio, basic-auth gated)
|
||||
|
|
||||
|-- Docker network: hackerspace_net
|
||||
|
|
||||
|-- hackerspace_app (Next.js, port 3000)
|
||||
|
|
||||
`-- Docker network: supabase_default
|
||||
|-- supabase-db (Postgres 15)
|
||||
|-- supabase-auth (GoTrue)
|
||||
|-- supabase-rest (PostgREST)
|
||||
|-- supabase-realtime (Realtime)
|
||||
|-- supabase-storage (Storage API)
|
||||
|-- supabase-kong (API gateway, port 8000)
|
||||
|-- supabase-studio (Dashboard, port 3000 -> host 3001)
|
||||
|-- supabase-meta (Postgres meta API)
|
||||
|-- supabase-imgproxy
|
||||
`-- supabase-vector (logs)
|
||||
```
|
||||
|
||||
The Next.js app and Supabase live in separate Docker networks. Nginx is the only public entry point. The app talks to Supabase over `https://supabase.yourdomain.com` (public URL) so it works the same locally and in production.
|
||||
|
||||
You can also expose Supabase only over the internal docker network and have the app talk to `http://supabase-kong:8000` directly. That is faster but breaks browser-side Supabase calls because the browser cannot resolve internal hostnames. Stick with the public URL pattern unless you understand the trade-off.
|
||||
|
||||
---
|
||||
|
||||
## 2. Cost
|
||||
|
||||
| Item | Spec | Monthly |
|
||||
|------|------|---------|
|
||||
| Droplet | 8 GB RAM, 4 vCPU, 160 GB SSD (Regular) | $48 |
|
||||
| Reserved IP | static IPv4 | included |
|
||||
| Domain | from any registrar | ~$1 |
|
||||
| **Total** | | **~$49/mo** |
|
||||
|
||||
You can run on a 4 GB / 2 vCPU Droplet ($24/mo) if you tune Postgres and disable a few Supabase services (Storage, Edge Functions). 8 GB is the comfort floor.
|
||||
|
||||
For larger spaces (200+ members, heavy chat, file uploads), split into two Droplets: one 8 GB for Supabase, one 2 GB for the app, joined via a VPC.
|
||||
|
||||
---
|
||||
|
||||
## 3. Prerequisites
|
||||
|
||||
- DigitalOcean account.
|
||||
- A domain you control. Two A records (or one A record with two CNAMEs) point to the Droplet IP. See section 7.
|
||||
- An SSH public key on your local machine (`~/.ssh/id_ed25519.pub` or similar).
|
||||
- Local tools: `ssh`, `git`, `curl`, `openssl`, `docker` (for testing builds), `node` (for the JWT generator one-liner).
|
||||
- An SMTP provider for auth emails (Resend, SendGrid, AWS SES, Postmark, Mailgun). Free tiers are fine. You need:
|
||||
- SMTP host, port (587 or 465), username, password
|
||||
- A verified `from` address
|
||||
|
||||
---
|
||||
|
||||
## 4. Provision the Droplet
|
||||
|
||||
1. cloud.digitalocean.com → Droplets → Create.
|
||||
2. Image: **Ubuntu 24.04 (LTS) x64**.
|
||||
3. Plan: **Basic — Regular — 8 GB / 4 vCPU / 160 GB SSD**.
|
||||
4. Datacenter: closest to your users.
|
||||
5. Authentication: **SSH Key** (add your public key, never use password auth).
|
||||
6. Hostname: `hackerspace-prod` (or whatever).
|
||||
7. Click **Create Droplet**.
|
||||
8. Note the public IPv4 address. Reserve it under Networking → Reserved IPs if you want it permanent.
|
||||
|
||||
---
|
||||
|
||||
## 5. Initial server hardening
|
||||
|
||||
SSH in as root:
|
||||
|
||||
```bash
|
||||
ssh root@<droplet-ip>
|
||||
```
|
||||
|
||||
Update and create a deploy user:
|
||||
|
||||
```bash
|
||||
apt-get update && apt-get -y upgrade
|
||||
adduser deploy # set a strong password
|
||||
usermod -aG sudo deploy
|
||||
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
|
||||
```
|
||||
|
||||
Disable root SSH and password auth:
|
||||
|
||||
```bash
|
||||
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
|
||||
sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
|
||||
systemctl restart ssh
|
||||
```
|
||||
|
||||
From a NEW terminal (keep the old one open until you confirm), test:
|
||||
|
||||
```bash
|
||||
ssh deploy@<droplet-ip>
|
||||
```
|
||||
|
||||
If that works, close the root session and continue as `deploy`.
|
||||
|
||||
Set up a firewall (allow SSH, HTTP, HTTPS only):
|
||||
|
||||
```bash
|
||||
sudo ufw allow OpenSSH
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw enable
|
||||
```
|
||||
|
||||
Confirm: `sudo ufw status`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Install Docker, Nginx, Certbot
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
sudo usermod -aG docker deploy
|
||||
newgrp docker
|
||||
|
||||
# Compose v2 plugin is bundled with the install above. Verify:
|
||||
docker --version
|
||||
docker compose version
|
||||
|
||||
# Nginx and Certbot
|
||||
sudo apt-get install -y nginx certbot python3-certbot-nginx git
|
||||
|
||||
# pnpm for occasional ad-hoc commands (optional; the app runs from Docker)
|
||||
curl -fsSL https://get.pnpm.io/install.sh | sh -
|
||||
```
|
||||
|
||||
Log out and back in so the `docker` group takes effect.
|
||||
|
||||
---
|
||||
|
||||
## 7. DNS records
|
||||
|
||||
At your registrar, add A records pointing to the Droplet IP:
|
||||
|
||||
| Host | Type | Value |
|
||||
|------|------|-------|
|
||||
| `yourdomain.com` | A | `<droplet-ip>` |
|
||||
| `www.yourdomain.com` | A | `<droplet-ip>` |
|
||||
| `supabase.yourdomain.com` | A | `<droplet-ip>` |
|
||||
| `studio.yourdomain.com` | A | `<droplet-ip>` |
|
||||
|
||||
Wait for propagation (`dig +short supabase.yourdomain.com` should return your IP).
|
||||
|
||||
---
|
||||
|
||||
## 8. Self-host Supabase
|
||||
|
||||
### 8.1 Clone the Supabase repo
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt && cd /opt
|
||||
sudo git clone --depth 1 https://github.com/supabase/supabase.git
|
||||
sudo chown -R deploy:deploy /opt/supabase
|
||||
cd /opt/supabase/docker
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### 8.2 Generate secrets
|
||||
|
||||
You need four critical secrets: `POSTGRES_PASSWORD`, `JWT_SECRET`, `ANON_KEY`, `SERVICE_ROLE_KEY`. Generate them on your **local machine** (not the Droplet) and paste them into `.env`.
|
||||
|
||||
```bash
|
||||
# Strong random Postgres password (32 chars)
|
||||
openssl rand -base64 24
|
||||
|
||||
# Strong JWT secret (40+ chars, alphanumeric)
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Now generate the JWTs. Pick the JWT_SECRET you just generated and run this Node one-liner on your local machine:
|
||||
|
||||
```bash
|
||||
# Save the JWT_SECRET from above into a variable first.
|
||||
export JWT_SECRET='paste-your-jwt-secret-here'
|
||||
|
||||
# Generate ANON_KEY (10-year expiry):
|
||||
npx -y -p jsonwebtoken@9 node -e "
|
||||
const jwt = require('jsonwebtoken');
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + 60 * 60 * 24 * 365 * 10;
|
||||
console.log(jwt.sign({ role: 'anon', iss: 'supabase', iat, exp }, process.env.JWT_SECRET));
|
||||
"
|
||||
|
||||
# Generate SERVICE_ROLE_KEY (10-year expiry):
|
||||
npx -y -p jsonwebtoken@9 node -e "
|
||||
const jwt = require('jsonwebtoken');
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + 60 * 60 * 24 * 365 * 10;
|
||||
console.log(jwt.sign({ role: 'service_role', iss: 'supabase', iat, exp }, process.env.JWT_SECRET));
|
||||
"
|
||||
```
|
||||
|
||||
Save all four values somewhere safe. The service role key is a god-mode key; treat it like root credentials.
|
||||
|
||||
Also generate two more secrets:
|
||||
|
||||
```bash
|
||||
# Dashboard credentials (for Supabase Studio basic auth)
|
||||
openssl rand -base64 16 # use as DASHBOARD_PASSWORD
|
||||
|
||||
# Logflare API keys (Supabase logs analytics; required even if you don't use Logflare cloud)
|
||||
openssl rand -hex 32 # use as LOGFLARE_API_KEY
|
||||
openssl rand -hex 32 # use as LOGFLARE_LOGGER_BACKEND_API_KEY
|
||||
```
|
||||
|
||||
### 8.3 Edit `/opt/supabase/docker/.env`
|
||||
|
||||
Open the file: `nano /opt/supabase/docker/.env`. Set:
|
||||
|
||||
```bash
|
||||
# Postgres
|
||||
POSTGRES_PASSWORD=<from step 8.2>
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_HOST=db
|
||||
POSTGRES_DB=postgres
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=<from step 8.2>
|
||||
JWT_EXPIRY=3600
|
||||
ANON_KEY=<JWT from step 8.2>
|
||||
SERVICE_ROLE_KEY=<JWT from step 8.2>
|
||||
|
||||
# Public URLs (set to your domain, NOT the Droplet IP)
|
||||
SITE_URL=https://yourdomain.com
|
||||
ADDITIONAL_REDIRECT_URLS=https://yourdomain.com,http://localhost:3000
|
||||
API_EXTERNAL_URL=https://supabase.yourdomain.com
|
||||
SUPABASE_PUBLIC_URL=https://supabase.yourdomain.com
|
||||
|
||||
# Studio
|
||||
STUDIO_DEFAULT_ORGANIZATION=hackerspace
|
||||
STUDIO_DEFAULT_PROJECT=hackerspace
|
||||
STUDIO_PORT=3000 # internal; we remap on the host in docker-compose.override.yml
|
||||
DASHBOARD_USERNAME=admin
|
||||
DASHBOARD_PASSWORD=<from step 8.2>
|
||||
|
||||
# Kong
|
||||
KONG_HTTP_PORT=8000
|
||||
KONG_HTTPS_PORT=8443
|
||||
|
||||
# SMTP for auth emails (use your provider's values)
|
||||
SMTP_ADMIN_EMAIL=admin@yourdomain.com
|
||||
SMTP_HOST=smtp.resend.com # or sendgrid, ses, etc.
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=resend
|
||||
SMTP_PASS=<your SMTP API key>
|
||||
SMTP_SENDER_NAME=hackerspace.sh
|
||||
|
||||
# Auth behavior
|
||||
ENABLE_EMAIL_SIGNUP=true
|
||||
ENABLE_EMAIL_AUTOCONFIRM=false # set to true for dev; false in prod
|
||||
ENABLE_ANONYMOUS_USERS=false
|
||||
ENABLE_PHONE_SIGNUP=false
|
||||
ENABLE_PHONE_AUTOCONFIRM=false
|
||||
|
||||
# Logs / analytics
|
||||
LOGFLARE_API_KEY=<from step 8.2>
|
||||
LOGFLARE_LOGGER_BACKEND_API_KEY=<from step 8.2>
|
||||
LOGFLARE_PUBLIC_ACCESS_TOKEN=<reuse LOGFLARE_API_KEY or generate another>
|
||||
|
||||
# Postgrest
|
||||
PGRST_DB_SCHEMAS=public,storage,graphql_public
|
||||
|
||||
# Functions (you probably don't use these; keep default)
|
||||
FUNCTIONS_VERIFY_JWT=true
|
||||
|
||||
# Pooler (Supavisor) — keep defaults unless you know you need to change
|
||||
POOLER_TENANT_ID=hackerspace
|
||||
POOLER_DEFAULT_POOL_SIZE=20
|
||||
POOLER_MAX_CLIENT_CONN=100
|
||||
POOLER_DB_POOL_SIZE=5
|
||||
POOLER_PROXY_PORT_TRANSACTION=6543
|
||||
POOLER_PROXY_PORT_SESSION=5432
|
||||
SECRET_KEY_BASE=<openssl rand -hex 32>
|
||||
VAULT_ENC_KEY=<openssl rand -hex 32>
|
||||
```
|
||||
|
||||
Lock the file:
|
||||
|
||||
```bash
|
||||
chmod 600 /opt/supabase/docker/.env
|
||||
```
|
||||
|
||||
### 8.4 Remap Studio off port 3000
|
||||
|
||||
The Next.js app uses host port 3000, and Supabase Studio also defaults to host port 3000. Create an override so Studio binds to 3001 instead.
|
||||
|
||||
```bash
|
||||
cat > /opt/supabase/docker/docker-compose.override.yml << 'EOF'
|
||||
services:
|
||||
studio:
|
||||
ports:
|
||||
- "127.0.0.1:3001:3000/tcp"
|
||||
kong:
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000/tcp"
|
||||
- "127.0.0.1:8443:8443/tcp"
|
||||
EOF
|
||||
```
|
||||
|
||||
Binding to `127.0.0.1` means these ports are only reachable locally; Nginx will proxy them publicly. The default Supabase compose binds to `0.0.0.0`, which would expose them on the public internet. This override fixes that.
|
||||
|
||||
### 8.5 Start Supabase
|
||||
|
||||
```bash
|
||||
cd /opt/supabase/docker
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
docker compose ps # all services should be "running" or "healthy"
|
||||
```
|
||||
|
||||
First boot pulls about 4 GB of images and takes 5–10 minutes.
|
||||
|
||||
Once everything is up, the API is at `http://localhost:8000` and Studio is at `http://localhost:3001`. Both are private to the host (we bound them to `127.0.0.1`). We'll expose them with Nginx in section 11.
|
||||
|
||||
### 8.6 Confirm Postgres is healthy
|
||||
|
||||
```bash
|
||||
docker compose exec db psql -U postgres -c '\dt public.*'
|
||||
```
|
||||
|
||||
You should see an empty table list. Postgres is ready; the application schema goes in next.
|
||||
|
||||
---
|
||||
|
||||
## 9. Apply the app schema and migrations
|
||||
|
||||
The hackerspace-management app stores everything in Postgres. We apply the schema directly to the self-hosted Postgres instance.
|
||||
|
||||
### 9.1 Copy the schema files onto the Droplet
|
||||
|
||||
From your local machine:
|
||||
|
||||
```bash
|
||||
scp scripts/schema.sql deploy@<droplet-ip>:/tmp/
|
||||
scp scripts/014_member_user_id_nullable.sql deploy@<droplet-ip>:/tmp/
|
||||
scp scripts/015_prevent_member_self_role_change.sql deploy@<droplet-ip>:/tmp/
|
||||
```
|
||||
|
||||
(Or `git clone` the repo on the Droplet, see section 10.1.)
|
||||
|
||||
### 9.2 Run the schema
|
||||
|
||||
```bash
|
||||
cd /opt/supabase/docker
|
||||
|
||||
# Pipe each file into psql inside the Postgres container.
|
||||
docker compose exec -T db psql -U postgres -d postgres < /tmp/schema.sql
|
||||
docker compose exec -T db psql -U postgres -d postgres < /tmp/014_member_user_id_nullable.sql
|
||||
docker compose exec -T db psql -U postgres -d postgres < /tmp/015_prevent_member_self_role_change.sql
|
||||
```
|
||||
|
||||
No errors expected. Verify:
|
||||
|
||||
```bash
|
||||
docker compose exec db psql -U postgres -d postgres -c '\dt public.*'
|
||||
docker compose exec db psql -U postgres -d postgres -c "SELECT count(*) FROM pg_policies WHERE schemaname = 'public';"
|
||||
docker compose exec db psql -U postgres -d postgres -c "SELECT tablename FROM pg_publication_tables WHERE pubname = 'supabase_realtime';"
|
||||
```
|
||||
|
||||
Expect: 13 tables, ~45 policies, `comms_messages` and `comms_channels` in the realtime publication.
|
||||
|
||||
### 9.3 Restart PostgREST so it picks up the new schema
|
||||
|
||||
PostgREST caches the schema on start. After applying tables, restart it:
|
||||
|
||||
```bash
|
||||
docker compose restart rest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Deploy the Next.js app
|
||||
|
||||
### 10.1 Clone the app repo on the Droplet
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/hackerspace
|
||||
sudo chown deploy:deploy /opt/hackerspace
|
||||
cd /opt
|
||||
git clone https://github.com/<owner>/hackerspace-management.git hackerspace
|
||||
cd hackerspace
|
||||
```
|
||||
|
||||
### 10.2 Create the production env file
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
nano .env.local
|
||||
```
|
||||
|
||||
Fill in:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://supabase.yourdomain.com
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<ANON_KEY from step 8.2>
|
||||
SUPABASE_SERVICE_ROLE_KEY=<SERVICE_ROLE_KEY from step 8.2>
|
||||
NEXT_PUBLIC_APP_URL=https://yourdomain.com
|
||||
NODE_ENV=production
|
||||
```
|
||||
|
||||
Lock the file:
|
||||
|
||||
```bash
|
||||
chmod 600 /opt/hackerspace/.env.local
|
||||
```
|
||||
|
||||
### 10.3 Build and start the app
|
||||
|
||||
The repo ships with a Dockerfile that produces a standalone Next.js image, and a docker-compose.yml that runs it. The Dockerfile expects build-time access to the public Supabase URL and anon key (Next.js inlines `NEXT_PUBLIC_*` at build time), so pass them as build args.
|
||||
|
||||
```bash
|
||||
cd /opt/hackerspace
|
||||
docker compose build \
|
||||
--build-arg NEXT_PUBLIC_SUPABASE_URL=https://supabase.yourdomain.com \
|
||||
--build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY="<ANON_KEY>" \
|
||||
--build-arg NEXT_PUBLIC_APP_URL=https://yourdomain.com
|
||||
|
||||
docker compose up -d
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
Wait for `Ready in <ms>`. Then verify locally on the Droplet:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/health
|
||||
# {"status":"ok","timestamp":"..."}
|
||||
```
|
||||
|
||||
The app is now running on `127.0.0.1:3000`. Public access still requires Nginx, which we set up next.
|
||||
|
||||
---
|
||||
|
||||
## 11. Nginx reverse proxy + SSL
|
||||
|
||||
Create one server block per public hostname.
|
||||
|
||||
### 11.1 App: `yourdomain.com`
|
||||
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/hackerspace-app
|
||||
```
|
||||
|
||||
Paste:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name yourdomain.com www.yourdomain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 11.2 Supabase API: `supabase.yourdomain.com`
|
||||
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/supabase-api
|
||||
```
|
||||
|
||||
Paste:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name supabase.yourdomain.com;
|
||||
|
||||
# Generous body size for storage uploads
|
||||
client_max_body_size 50m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Realtime / WebSockets
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 11.3 Studio (admin dashboard): `studio.yourdomain.com`
|
||||
|
||||
Studio is a god-mode dashboard. Gate it behind HTTP basic auth in addition to its own login.
|
||||
|
||||
```bash
|
||||
sudo apt-get install -y apache2-utils
|
||||
sudo htpasswd -c /etc/nginx/.htpasswd_studio admin # set a strong password
|
||||
sudo nano /etc/nginx/sites-available/supabase-studio
|
||||
```
|
||||
|
||||
Paste:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name studio.yourdomain.com;
|
||||
|
||||
# Restrict to your office/home IPs if you have a static one.
|
||||
# allow 1.2.3.4;
|
||||
# deny all;
|
||||
|
||||
auth_basic "Restricted";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd_studio;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 11.4 Enable and reload
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/hackerspace-app /etc/nginx/sites-enabled/
|
||||
sudo ln -s /etc/nginx/sites-available/supabase-api /etc/nginx/sites-enabled/
|
||||
sudo ln -s /etc/nginx/sites-available/supabase-studio /etc/nginx/sites-enabled/
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 11.5 SSL with Let's Encrypt
|
||||
|
||||
Make sure all four hostnames resolve to the Droplet before running:
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx \
|
||||
-d yourdomain.com -d www.yourdomain.com \
|
||||
-d supabase.yourdomain.com \
|
||||
-d studio.yourdomain.com
|
||||
```
|
||||
|
||||
Pick option `2` (redirect HTTP to HTTPS) when prompted. Certbot rewrites the Nginx configs to add `listen 443 ssl;` blocks and sets up auto-renewal via a systemd timer.
|
||||
|
||||
Confirm renewal works:
|
||||
|
||||
```bash
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Post-deploy verification
|
||||
|
||||
Run through this list. Everything should pass on the first try.
|
||||
|
||||
```bash
|
||||
# 1. App health
|
||||
curl https://yourdomain.com/api/health
|
||||
# -> {"status":"ok","timestamp":"..."}
|
||||
|
||||
# 2. Supabase API health (Kong should reject without an apikey)
|
||||
curl https://supabase.yourdomain.com/rest/v1/
|
||||
# -> {"hint":"...","message":"Invalid API key"} or similar (proves Kong is reachable)
|
||||
|
||||
# 3. Supabase API with anon key (should return empty array or a hint)
|
||||
curl https://supabase.yourdomain.com/rest/v1/spaces \
|
||||
-H "apikey: <ANON_KEY>" -H "Authorization: Bearer <ANON_KEY>"
|
||||
# -> [] (empty because RLS filters out everything for an unauthenticated request)
|
||||
|
||||
# 4. Studio
|
||||
# Open https://studio.yourdomain.com in a browser. Pass basic auth (admin / your password).
|
||||
# Studio's own login uses DASHBOARD_USERNAME / DASHBOARD_PASSWORD.
|
||||
```
|
||||
|
||||
App flow:
|
||||
|
||||
- [ ] Visit `https://yourdomain.com/signup`. Create an account, create a space, land on `/dashboard`.
|
||||
- [ ] In Studio, Table Editor, `space_members`: confirm a row exists with `role = 'admin'`, `status = 'current'`.
|
||||
- [ ] `comms_channels`: confirm three rows (`general`, `announcements`, `ops`).
|
||||
- [ ] Open `/comms`, send a message, open a second tab, watch the message appear without refresh (realtime works).
|
||||
- [ ] In Studio, run as the `anon` role:
|
||||
```sql
|
||||
UPDATE public.space_members SET role = 'admin' WHERE user_id = auth.uid();
|
||||
```
|
||||
This must fail with `Members cannot change their own role, tier, status, approval, card access, or space.` That confirms migration 015 is active.
|
||||
|
||||
---
|
||||
|
||||
## 13. Backups
|
||||
|
||||
### 13.1 Daily Postgres dump to local disk
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/backups/supabase
|
||||
sudo chown deploy:deploy /var/backups/supabase
|
||||
|
||||
cat > /opt/hackerspace/backup-db.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
TS=$(date +%Y%m%d_%H%M%S)
|
||||
DEST=/var/backups/supabase/postgres_${TS}.sql.gz
|
||||
|
||||
cd /opt/supabase/docker
|
||||
docker compose exec -T db pg_dump -U postgres --clean --if-exists postgres | gzip > "$DEST"
|
||||
|
||||
# Keep last 14 days
|
||||
find /var/backups/supabase -name "postgres_*.sql.gz" -mtime +14 -delete
|
||||
|
||||
echo "[$(date)] backup -> $DEST ($(du -h "$DEST" | cut -f1))"
|
||||
EOF
|
||||
|
||||
chmod +x /opt/hackerspace/backup-db.sh
|
||||
```
|
||||
|
||||
Schedule daily at 3 AM:
|
||||
|
||||
```bash
|
||||
( crontab -l 2>/dev/null; echo "0 3 * * * /opt/hackerspace/backup-db.sh >> /var/log/db-backup.log 2>&1" ) | crontab -
|
||||
```
|
||||
|
||||
### 13.2 Off-site backups to DigitalOcean Spaces (recommended)
|
||||
|
||||
Local backups die with the Droplet. Push them to Spaces (S3-compatible).
|
||||
|
||||
1. Spaces dashboard → Create Space → note the bucket name and endpoint URL.
|
||||
2. Spaces → Access Keys → generate one. Save the key and secret.
|
||||
3. Install `s3cmd`:
|
||||
```bash
|
||||
sudo apt-get install -y s3cmd
|
||||
s3cmd --configure
|
||||
# Endpoint: nyc3.digitaloceanspaces.com (or your region)
|
||||
# Use HTTPS, no encryption
|
||||
```
|
||||
4. Add an upload line to the backup script:
|
||||
```bash
|
||||
echo "s3cmd put $DEST s3://<your-space>/db/" >> /opt/hackerspace/backup-db.sh
|
||||
```
|
||||
5. Lifecycle policy on the Space: rotate to cold storage / delete after N days.
|
||||
|
||||
### 13.3 Restoring
|
||||
|
||||
```bash
|
||||
gunzip -c /var/backups/supabase/postgres_20260514_030000.sql.gz | \
|
||||
docker compose -f /opt/supabase/docker/docker-compose.yml exec -T db psql -U postgres -d postgres
|
||||
```
|
||||
|
||||
Test restores at least quarterly on a staging Droplet. A backup you have not restored is not a backup.
|
||||
|
||||
---
|
||||
|
||||
## 14. Operations: upgrades, logs, monitoring
|
||||
|
||||
### 14.1 App upgrades
|
||||
|
||||
```bash
|
||||
cd /opt/hackerspace
|
||||
git pull origin main
|
||||
docker compose build \
|
||||
--build-arg NEXT_PUBLIC_SUPABASE_URL=https://supabase.yourdomain.com \
|
||||
--build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY="<ANON_KEY>" \
|
||||
--build-arg NEXT_PUBLIC_APP_URL=https://yourdomain.com
|
||||
docker compose up -d --no-deps app
|
||||
docker image prune -f
|
||||
```
|
||||
|
||||
Wrap in `/opt/hackerspace/deploy.sh` and trigger from GitHub Actions on push to `main` if you want CI auto-deploy.
|
||||
|
||||
### 14.2 Database migrations
|
||||
|
||||
When the app ships a new `scripts/NNN_*.sql`, apply it:
|
||||
|
||||
```bash
|
||||
cd /opt/supabase/docker
|
||||
docker compose exec -T db psql -U postgres -d postgres < /opt/hackerspace/scripts/NNN_*.sql
|
||||
docker compose restart rest # PostgREST caches the schema
|
||||
```
|
||||
|
||||
Never re-run `scripts/schema.sql` against a live database; it is the canonical full file for fresh deploys. Use the numbered incrementals on existing databases.
|
||||
|
||||
### 14.3 Supabase upgrades
|
||||
|
||||
```bash
|
||||
cd /opt/supabase/docker
|
||||
git fetch origin && git pull origin master
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Read the [release notes](https://github.com/supabase/supabase/releases) before pulling major versions. The Supabase docker-compose has occasional breaking env-var renames.
|
||||
|
||||
### 14.4 Logs
|
||||
|
||||
```bash
|
||||
# App
|
||||
docker compose -f /opt/hackerspace/docker-compose.yml logs -f app
|
||||
|
||||
# Supabase
|
||||
cd /opt/supabase/docker
|
||||
docker compose logs -f kong auth rest realtime
|
||||
docker compose logs -f db | grep -v 'connection received' # noisy
|
||||
|
||||
# Nginx
|
||||
sudo journalctl -u nginx -f
|
||||
sudo tail -f /var/log/nginx/access.log
|
||||
```
|
||||
|
||||
### 14.5 Resource monitoring
|
||||
|
||||
```bash
|
||||
# Quick view
|
||||
docker stats
|
||||
|
||||
# Disk
|
||||
df -h
|
||||
docker system df
|
||||
|
||||
# Postgres size
|
||||
docker compose -f /opt/supabase/docker/docker-compose.yml exec db \
|
||||
psql -U postgres -c "SELECT pg_size_pretty(pg_database_size('postgres'));"
|
||||
```
|
||||
|
||||
For real monitoring, install [Netdata](https://www.netdata.cloud/) (`bash <(curl -Ss https://my-netdata.io/kickstart.sh)`) or wire DigitalOcean Monitoring to send alerts on CPU > 80%, disk > 80%, or memory > 85%.
|
||||
|
||||
---
|
||||
|
||||
## 15. Hardening checklist
|
||||
|
||||
Before you point real users at this:
|
||||
|
||||
- [ ] Root SSH disabled, password auth disabled (section 5).
|
||||
- [ ] UFW allows only 22, 80, 443.
|
||||
- [ ] All four hostnames have valid Let's Encrypt certs (`sudo certbot certificates`).
|
||||
- [ ] `/opt/hackerspace/.env.local` and `/opt/supabase/docker/.env` are mode 600, owned by `deploy`.
|
||||
- [ ] `SERVICE_ROLE_KEY` is set as a Docker secret or env var only, never logged.
|
||||
- [ ] Studio (`studio.yourdomain.com`) is behind HTTP basic auth and ideally IP-allowlisted.
|
||||
- [ ] `DASHBOARD_PASSWORD` is strong and stored in a password manager.
|
||||
- [ ] `JWT_SECRET` is 32+ random bytes, never committed.
|
||||
- [ ] Migration 015 (`prevent_member_self_role_change`) is applied (verify by trying the self-promotion query in section 12).
|
||||
- [ ] Daily Postgres backups running, off-site copies enabled.
|
||||
- [ ] `unattended-upgrades` installed for OS security patches:
|
||||
`sudo apt-get install -y unattended-upgrades && sudo dpkg-reconfigure -plow unattended-upgrades`
|
||||
- [ ] Fail2Ban for SSH:
|
||||
`sudo apt-get install -y fail2ban && sudo systemctl enable --now fail2ban`
|
||||
- [ ] SMTP configured and sending: trigger a password reset email and confirm it arrives.
|
||||
- [ ] Realtime works end-to-end (verified in section 12).
|
||||
- [ ] Disk has at least 50 GB free; Postgres grows over time.
|
||||
|
||||
---
|
||||
|
||||
## 16. Troubleshooting
|
||||
|
||||
### App returns 500 on every page
|
||||
|
||||
Almost always wrong env vars. Confirm `/opt/hackerspace/.env.local` has the four required values and that `NEXT_PUBLIC_SUPABASE_URL` exactly matches the URL you used in `API_EXTERNAL_URL` in the Supabase `.env`.
|
||||
|
||||
`docker compose -f /opt/hackerspace/docker-compose.yml logs app | tail -50` will surface the actual error.
|
||||
|
||||
### "Invalid API key" from every Supabase call
|
||||
|
||||
The `ANON_KEY` you put in the app does not match the `JWT_SECRET` Supabase signed it with, or you regenerated `JWT_SECRET` without regenerating the keys. Regenerate both JWTs from the current `JWT_SECRET`, paste into both env files, restart both stacks.
|
||||
|
||||
### Signup succeeds but `/dashboard` redirects to `/signup` forever
|
||||
|
||||
The signup completed in `auth.users` but `space_members` insertion failed (most often migration 014 not applied). Check Studio → Authentication → Users (you should see the new user) and Table Editor → `space_members` (you should see a row). If the row is missing, the trigger or the `createSpace` server action returned an error, look in the app logs.
|
||||
|
||||
### Realtime is silent
|
||||
|
||||
Two things to check:
|
||||
1. `comms_messages` and `comms_channels` are in the realtime publication:
|
||||
```sql
|
||||
SELECT tablename FROM pg_publication_tables WHERE pubname = 'supabase_realtime';
|
||||
```
|
||||
2. Nginx is forwarding WebSocket upgrades. `proxy_set_header Upgrade $http_upgrade;` and `proxy_set_header Connection 'upgrade';` must be present in the `supabase-api` server block.
|
||||
|
||||
### `pnpm build` works locally but fails in Docker
|
||||
|
||||
Check that the `NEXT_PUBLIC_*` build args were passed (section 10.3). Next.js inlines those at build time; if missing, the client bundle ships with `undefined` for them and the app reports "Failed to fetch" on every Supabase call.
|
||||
|
||||
### Postgres uses 100% CPU
|
||||
|
||||
Run `pg_stat_activity` from Studio to find the offender:
|
||||
```sql
|
||||
SELECT pid, state, query_start, wait_event_type, wait_event, left(query, 200)
|
||||
FROM pg_stat_activity
|
||||
WHERE state != 'idle'
|
||||
ORDER BY query_start;
|
||||
```
|
||||
Kill runaway queries with `SELECT pg_terminate_backend(<pid>);`. Add an index. Common culprit: missing index on `comms_messages(channel_id, created_at)` for chat history; the schema already has this, so check that the index exists (`\di idx_comms_messages_*` in psql).
|
||||
|
||||
### Out of memory
|
||||
|
||||
Supabase realtime and Postgres are the heaviest. Bump the Droplet up one tier. Or, if you do not use Storage / Functions / Edge Runtime, comment those services out of `/opt/supabase/docker/docker-compose.yml`. Easy 1–1.5 GB saved.
|
||||
|
||||
### Renewal of SSL certs failed
|
||||
|
||||
```bash
|
||||
sudo systemctl status certbot.timer
|
||||
sudo journalctl -u certbot | tail -50
|
||||
```
|
||||
|
||||
Most common cause: a hostname stopped resolving. Confirm `dig +short <host>` returns the Droplet IP for every hostname Certbot manages.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: keeping the app and Supabase versions in sync
|
||||
|
||||
The application is versioned per its own `package.json`. Supabase is versioned per the `supabase/supabase` repo's tags. They evolve independently.
|
||||
|
||||
- The app pins `@supabase/ssr` and `@supabase/supabase-js` in `package.json`. If you upgrade Supabase server-side (in `/opt/supabase/docker`), check the [Supabase release notes](https://github.com/supabase/supabase/releases) for breaking changes in the JS client.
|
||||
- Run `pnpm update @supabase/ssr @supabase/supabase-js` in a feature branch, run the test suite (`pnpm vitest run`), redeploy.
|
||||
|
||||
For the application-level schema, the source of truth is `scripts/schema.sql` plus the numbered incrementals. The Supabase stack does not manage application schemas; it only provides Postgres and the layers on top.
|
||||
|
||||
---
|
||||
|
||||
This document covers the full self-hosted path. If anything here drifts from `docs/DEPLOYMENT.md` (the managed-Supabase guide), this document is the source of truth for self-hosted deployments.
|
||||
@@ -0,0 +1,418 @@
|
||||
# Governance feature proposals
|
||||
|
||||
A diagnostic-to-feature mapping from twelve recurring institutional pain patterns documented in real hackerspace operations. Filtered through three questions:
|
||||
|
||||
1. **Does this generalize?** Will it serve any hackerspace, not just one with HeatSync Labs's specific bylaws, room layout, or political history?
|
||||
2. **Does it solve a recurring pain?** Not an interesting capability; a documented every-few-months problem.
|
||||
3. **Does it fit the existing model?** Multi-tenant (`space_id` on every row), RLS-enforced, role-gated (`admin`, `board`, `treasurer`, `member`, `associate`), built on Supabase. Avoid features that require new infrastructure (door hardware, SMTP, IdP, federation protocols) unless the operator has already deployed them.
|
||||
|
||||
What follows is what I'd actually add. Things I'd reject or defer are at the end.
|
||||
|
||||
---
|
||||
|
||||
## Architectural principles for any new feature
|
||||
|
||||
Three rules govern every proposal below.
|
||||
|
||||
1. **Privacy default for individuals; transparency default for institutions.** A member's payment notes are private. The vote a board member cast on a proposal is public. Schema choices reflect this.
|
||||
2. **Audit trails for governance, not for member behavior.** Who voted yes on proposal X is logged forever. Whether a member opened the chat at 2am Tuesday is not logged at all.
|
||||
3. **Configuration per space; no hardcoded politics.** Bylaws section numbers, quorum percentages, voting windows, role names, "Hack Your Heart Out" night, anonymity policy — all of it lives in `spaces` settings or per-proposal config. The app ships sensible defaults; each space overrides.
|
||||
|
||||
---
|
||||
|
||||
## Tier 1: the governance kernel (Phase 1) — SHIPPED 2026-05-14
|
||||
|
||||
Status: implemented end-to-end (schema, server actions, validations, tests, UI). See `docs/HANDOFF.md` pass 5 for the build summary.
|
||||
|
||||
Migration: `scripts/016_governance_kernel.sql` (also folded into `scripts/schema.sql` as Section 9). Routes: `/proposals`, `/proposals/[id]`, `/proposals/new`, `/incidents`, `/incidents/[id]`, `/incidents/new`, `/policies`, `/policies/[slug]`, `/policies/new`. Tests: 40 schema-validation tests in `__tests__/governance.test.ts`. Sidebar nav has a new "Governance" group.
|
||||
|
||||
The three top-frequency patterns (CoC complaints, information asymmetry, async voting) collapse into one tightly integrated module: **proposals + incidents + policies**. Built together; each references the others.
|
||||
|
||||
### 1.1 `proposals` and `proposal_votes`
|
||||
|
||||
The single most valuable feature. Generalises patterns 3, 4, 5, 10, 12.
|
||||
|
||||
```
|
||||
proposals
|
||||
id uuid PK
|
||||
space_id uuid FK → spaces
|
||||
title text
|
||||
body text (markdown)
|
||||
proposer_id uuid FK → space_members
|
||||
proposer_name text (denormalized for history)
|
||||
proposal_type enum: 'bylaw_change' | 'board_action' | 'membership_vote'
|
||||
| 'advisory_poll' | 'recall' | 'budget'
|
||||
status enum: 'draft' | 'open' | 'decided' | 'withdrawn' | 'expired'
|
||||
quorum_required integer (absolute count, computed when proposal opens)
|
||||
quorum_percent integer (the percentage from space settings at time of open)
|
||||
threshold enum: 'simple_majority' | 'two_thirds' | 'three_fourths' | 'unanimous'
|
||||
voting_opens_at timestamptz
|
||||
voting_closes_at timestamptz
|
||||
policy_ref_id uuid FK → policies (nullable; for bylaw-change proposals)
|
||||
parent_incident_id uuid FK → incidents (nullable; for appeals)
|
||||
outcome_yes integer (frozen at close)
|
||||
outcome_no integer (frozen at close)
|
||||
outcome_abstain integer (frozen at close)
|
||||
passed boolean (frozen at close)
|
||||
created_at, updated_at, decided_at
|
||||
|
||||
proposal_votes
|
||||
id uuid PK
|
||||
proposal_id uuid FK
|
||||
member_id uuid FK → space_members
|
||||
position enum: 'yes' | 'no' | 'abstain' | 'recused'
|
||||
recusal_reason text (required iff position = 'recused')
|
||||
comment text (optional public comment on vote)
|
||||
voted_at timestamptz
|
||||
UNIQUE (proposal_id, member_id)
|
||||
```
|
||||
|
||||
**RLS**:
|
||||
- SELECT: members of the space.
|
||||
- INSERT proposal: any active member (proposing is universal).
|
||||
- UPDATE proposal: proposer while `status = 'draft'`; admin/board for status transitions.
|
||||
- INSERT/UPDATE vote: voter on their own row only, only while `voting_opens_at <= now() < voting_closes_at`.
|
||||
- DELETE vote: never.
|
||||
|
||||
**Why this generalises**: every cooperative has decisions. The differences (quorum percent, threshold rule, voting window length) are configuration, not code. The hardcoded thing HSL did — "Member-Called Special Elections" — becomes one of several `proposal_type` values; the threshold and quorum are read from space settings at proposal-open time.
|
||||
|
||||
**What it solves**:
|
||||
- Async voting (Pattern 3): voting_opens_at and voting_closes_at do the work.
|
||||
- Vote-of-confidence as a first-class workflow (Pattern 3): proposal_type='recall'.
|
||||
- Decision archive (Pattern 4): closed proposals are immutable and full-text searchable.
|
||||
- Recusal log (Pattern 5): `position='recused'` with required reason.
|
||||
- Vote-record visibility (Pattern 5): a member's voting history is a query, not a leak.
|
||||
- Quorum/threshold transparency (Pattern 12): both are stored on the proposal at open time, displayed on the proposal page, no procedural disputes possible.
|
||||
|
||||
**Defaults a space should set** (new fields on `spaces`):
|
||||
- `default_quorum_percent` (e.g. 10)
|
||||
- `default_quorum_floor` (e.g. 8 — "10% or 8, whichever is greater")
|
||||
- `default_voting_window_hours` (e.g. 216 for 9 days)
|
||||
- `default_threshold` (e.g. 'simple_majority')
|
||||
|
||||
The proposal page shows live counts as votes come in, computes whether quorum is met, shows the outstanding required count if not. No spreadsheet, no manual tally.
|
||||
|
||||
### 1.2 `incidents` (CoC complaints, safety reports, conflicts)
|
||||
|
||||
Generalises Pattern 1 (the CoC complaint black hole) plus the chain-of-custody concern that recurs in every cooperative.
|
||||
|
||||
```
|
||||
incidents
|
||||
id uuid PK
|
||||
space_id uuid FK → spaces
|
||||
reporter_id uuid FK → space_members (nullable; null when anonymous)
|
||||
reporter_token text (server-generated, opaque; lets anonymous reporter check status)
|
||||
is_anonymous boolean
|
||||
subjects uuid[] (optional FKs to space_members; the people the report is about)
|
||||
category text (e.g. 'code_of_conduct', 'safety', 'theft', 'harassment')
|
||||
— free-form so each space defines its own taxonomy
|
||||
severity enum: 'low' | 'medium' | 'high' | 'critical'
|
||||
title text
|
||||
body text
|
||||
status enum: 'received' | 'under_review' | 'decided' | 'appealed' | 'closed'
|
||||
disposition text (markdown; what the board/admin decided)
|
||||
decision_makers uuid[] (FKs to space_members)
|
||||
appeal_proposal_id uuid FK → proposals (set when an appeal is filed)
|
||||
sla_response_by timestamptz (computed: created_at + space.incident_sla_days)
|
||||
created_at
|
||||
acknowledged_at timestamptz (when board first marked under_review)
|
||||
decided_at timestamptz
|
||||
closed_at timestamptz
|
||||
|
||||
incident_updates
|
||||
id uuid PK
|
||||
incident_id uuid FK
|
||||
author_id uuid FK → space_members
|
||||
body text
|
||||
visibility enum: 'reporter_only' | 'all_parties' | 'board_only'
|
||||
created_at
|
||||
```
|
||||
|
||||
**RLS**:
|
||||
- SELECT: reporter + admins/board + named subjects (configurable per space). Anonymous reporters use the `reporter_token` to fetch their own report via a public route; the token never appears in lists.
|
||||
- INSERT: any active member, or anonymous via a rate-limited public endpoint that requires a turnstile/captcha.
|
||||
- UPDATE: admin/board (status, disposition); reporter can add `incident_updates` only.
|
||||
- DELETE: never. Incident records are retained.
|
||||
|
||||
**Status notifications**: every status change inserts a row into the existing `activity_log` AND triggers a notification to the reporter (in-app comms message; email if configured). Silence becomes structurally impossible because the schema *requires* a status transition for action, and every transition notifies the filer.
|
||||
|
||||
**Appeal pathway**: closing an incident in `disposition='dismissed'` exposes a "request membership review" button visible to the reporter. Clicking it creates a `proposals` row with `parent_incident_id` set, pre-populated body, and `proposal_type='recall'` or `'membership_vote'`. The appeal is then a normal proposal with normal voting mechanics.
|
||||
|
||||
**Pattern surfacing**: an admin/board view groups incidents by overlapping subjects. Three independent incidents naming the same member surface as a single card, even if each individual incident was dismissed. No machine-learning needed; just `SELECT subjects, count(*) FROM incidents WHERE subjects @> ARRAY[<member_id>]`.
|
||||
|
||||
**On anonymity**: I would not promise cryptographic anonymity. The honest implementation is "the database admin can read the report; the rest of the board cannot tie it to a member". A `reporter_token` provides status-tracking without UI-level identity. If a space wants stronger anonymity it can disable `reporter_id` storage entirely via a space setting; the cost is that the reporter cannot prove authorship if disputed later.
|
||||
|
||||
### 1.3 `policies` (versioned bylaws, code of conduct, station rules)
|
||||
|
||||
Generalises Patterns 4 and 12.
|
||||
|
||||
```
|
||||
policies
|
||||
id uuid PK
|
||||
space_id uuid FK → spaces
|
||||
slug text (e.g. 'bylaws', 'code-of-conduct', 'laser-station-rules')
|
||||
section_ref text (free-form; each space defines its own structure, e.g. '8.4' or 'Article III §2')
|
||||
parent_policy_id uuid FK → policies (nullable; nests policies, e.g. CoC under Bylaws)
|
||||
title text
|
||||
body_formal text (markdown; legalese version)
|
||||
body_plain text (markdown; plain-language summary — Pattern 12, Kirk's "explain to a five-year-old")
|
||||
version integer
|
||||
prior_version_id uuid FK → policies (null for v1, points back for v2+)
|
||||
status enum: 'draft' | 'active' | 'deprecated' | 'superseded'
|
||||
effective_at timestamptz
|
||||
adopted_by_proposal_id uuid FK → proposals (nullable; null for migrated-in initial versions)
|
||||
created_at, updated_at
|
||||
UNIQUE (space_id, slug, version)
|
||||
```
|
||||
|
||||
**RLS**:
|
||||
- SELECT: members.
|
||||
- INSERT: admin/board.
|
||||
- UPDATE: never (immutability). Edits insert a new row with incremented `version` and `prior_version_id` pointing back.
|
||||
- DELETE: never. Deprecation flips `status='deprecated'`.
|
||||
|
||||
**Diff view**: a UI route renders any two versions side-by-side. Implemented client-side from the two rows.
|
||||
|
||||
**Citation linking**: any `proposals.body` markdown supports a `[policy:bylaws#8.4]` syntax that renders as a hover-card with the clause's current text. Same for `incidents.body`. Members vote knowing what they're voting on.
|
||||
|
||||
**Why versioning matters**: Pattern 4 is "Why is this rule here?" The chain `policy v3 → adopted_by_proposal → proposal.created_at → proposal_votes` is the answer. A query reconstructs the institutional history of any clause.
|
||||
|
||||
---
|
||||
|
||||
## Tier 2: information-equity extensions (Phase 2) — SHIPPED 2026-05-14
|
||||
|
||||
Status: schema fully in place (migration 018). `/financials` route shipped end-to-end (gated by `space.financial_visibility`). Member directory visibility column + COI disclosure + skills/interests/willing_to columns ready; UI for those follows.
|
||||
|
||||
These are smaller and mostly extend existing tables. Generalise Pattern 2 (information asymmetry).
|
||||
|
||||
### 2.1 Member directory transparency
|
||||
|
||||
The pain: an incumbent withholding the cardholder count. Fix in three lines.
|
||||
|
||||
- Add a space setting: `member_directory_visibility` enum (`public_members_visible`, `member_count_visible`, `board_only`). Default: `member_count_visible`.
|
||||
- The members page renders an aggregate count box visible to whatever the setting allows. The page already exists; this is one query.
|
||||
- A space setting `member_directory_public` exposes the directory to non-members at `<host>/<slug>/members`. Off by default.
|
||||
|
||||
No new tables. Just settings + queries.
|
||||
|
||||
### 2.2 Financial dashboard
|
||||
|
||||
The pain: members not knowing about rent increases until "the numbers were posted".
|
||||
|
||||
- A new server-component route `/financials` that renders aggregates from `payments`: income by month, by platform, by member tier; counts of linked vs unlinked.
|
||||
- A space setting `financial_visibility` enum (`treasurer_only`, `board_visible`, `all_members_visible`). Default: `board_visible`.
|
||||
- Optional: an `expenses` table for non-Stripe outflows (rent, insurance, supplies). One row per expense with category, amount, paid_on, payee, note. RLS gates by `financial_visibility`.
|
||||
- Optional: a `recurring_obligations` table (rent, insurance, ISP) with monthly amount and renewal_at. The dashboard sums these into a "monthly burn" figure.
|
||||
|
||||
The "runway" metric is `(cash_on_hand - 3*monthly_burn) / monthly_burn`. Display it. Members making decisions about expansion need this.
|
||||
|
||||
### 2.3 External-account control registry
|
||||
|
||||
The pain: Flores controlling Facebook with no transfer mechanism.
|
||||
|
||||
- Extend `integrations` (already exists) to also model non-OAuth platforms: Facebook page, Twitter, mailing list, domain registrar. Add fields: `account_holder_member_id`, `recovery_email`, `transfer_documented_at`.
|
||||
- A board-visible page lists every external account with the responsible member and last-verified date.
|
||||
- An annual `transfer_documented_at` check: if a board member rotates, the page surfaces "these accounts have not been re-verified since the rotation."
|
||||
|
||||
No new table; columns on `integrations`.
|
||||
|
||||
### 2.4 Board meeting minutes auto-distribution
|
||||
|
||||
The pain: "we decided X privately for privacy reasons."
|
||||
|
||||
- New `kb_visibility` enum value: `board_meeting`. Any KB entry tagged board_meeting is visible to all members and pushed as a comms notification when published.
|
||||
- Optionally: a space setting `board_minutes_sla_hours` (default 48). A scheduled job alerts admins if a board meeting (logged via a separate `meetings` table or a calendar feature) ended more than that many hours ago without published minutes.
|
||||
|
||||
Light-touch. Mostly UI work on top of `knowledge_base`.
|
||||
|
||||
---
|
||||
|
||||
## Tier 3: member-state extensions (Phase 2 continued) — SHIPPED 2026-05-14
|
||||
|
||||
Status: all schema in migration 018. Server actions for self-profile and COI disclosure built. Card-access mid-tenure review trigger live. `inactive_members` view live. UI for self-editing profile / disclosing affiliations follows.
|
||||
|
||||
Each one is a small column-set extension to `space_members`. None requires new tables.
|
||||
|
||||
### 3.1 Skill / interest / willing-to fields
|
||||
|
||||
Generalises Pattern 10 (succession infrastructure).
|
||||
|
||||
Add to `space_members`:
|
||||
- `skills text[]`
|
||||
- `interests text[]`
|
||||
- `willing_to text[]` (e.g. `'board_candidate'`, `'treasurer'`, `'host_volunteer'`, `'area_lead:laser'`)
|
||||
|
||||
A new server-component route `/recruitment` (board-visible) queries `willing_to`. Recruitment becomes "show me members who marked `'board_candidate'`" instead of one person's manual outreach.
|
||||
|
||||
Strictly opt-in. Members write their own values. No automatic skill inference, no behavioural scoring.
|
||||
|
||||
### 3.2 Conflict-of-interest disclosure
|
||||
|
||||
Generalises Pattern 5.
|
||||
|
||||
Add to `space_members`:
|
||||
- `affiliations text[]` (free-form: organizations the member is associated with)
|
||||
- `coi_last_disclosed_at timestamptz`
|
||||
|
||||
A space setting `coi_disclosure_required_for_roles text[]` lists roles that must keep COI current (default: `['admin', 'board', 'treasurer']`). When a member in one of those roles has `coi_last_disclosed_at < now() - 365 days`, the dashboard surfaces a "disclose your affiliations" reminder. Affiliations are visible to all members of the space; the role-vs-affiliation comparison is the institutional check.
|
||||
|
||||
### 3.3 Mid-tenure card review
|
||||
|
||||
Generalises Pattern 7.
|
||||
|
||||
- When `has_card_access` flips from false → true, the schema fires a trigger that inserts a `tasks` row: `task_type='admin'`, `title='Card-access review: <display_name>'`, `due_date=now() + 180 days`, `assigned_to_role='board'`.
|
||||
- The existing tasks page surfaces it like any other due task. Resolution is a normal task completion, with an optional note that appears in the member's profile.
|
||||
|
||||
Pure schema work; uses the `tasks` table that already exists.
|
||||
|
||||
### 3.4 Re-engagement detection
|
||||
|
||||
Generalises Pattern 7's "faded-out members are still members."
|
||||
|
||||
- A view `inactive_members` that computes membership rows with no entries in `activity_log` for the past 180 days.
|
||||
- The members page renders a filter chip "Inactive (N)" that toggles the view.
|
||||
- An optional "send re-engagement message" action that drops a templated message into the member's DM channel (when DMs ship — not in Phase 1).
|
||||
|
||||
No new table; one view.
|
||||
|
||||
---
|
||||
|
||||
## Tier 4: operational additions (Phase 3)
|
||||
|
||||
Lower frequency but real. Build only after Tier 1 and 2 land.
|
||||
|
||||
### 4.1 Hosting calendar
|
||||
|
||||
Generalises Pattern 9.
|
||||
|
||||
```
|
||||
host_shifts
|
||||
id uuid PK
|
||||
space_id uuid FK
|
||||
host_id uuid FK → space_members
|
||||
starts_at timestamptz
|
||||
ends_at timestamptz
|
||||
swap_requested boolean
|
||||
swap_note text
|
||||
created_at, updated_at
|
||||
```
|
||||
|
||||
Members claim, swap, cancel. The space page renders the next 30 days. Notifications when a shift is unclaimed within 24 hours of starting.
|
||||
|
||||
### 4.2 Sponsor / partner registry
|
||||
|
||||
Generalises Pattern 8.
|
||||
|
||||
Two options. Pick one.
|
||||
|
||||
Option A (cheaper): extend `contacts` with `status` enum (`active, paused, withdrawn, prospective`) and `liaison_member_id`. Contacts already supports a `contact_type='partner'` value.
|
||||
|
||||
Option B (cleaner): a new `organizations` table separate from `contacts` (which is people-shaped). Add `engagement_log` rows for status changes with notes.
|
||||
|
||||
I'd start with Option A. Migrate to Option B if it outgrows.
|
||||
|
||||
### 4.3 Community survey
|
||||
|
||||
Generalises Pattern 11.
|
||||
|
||||
```
|
||||
surveys
|
||||
id uuid PK
|
||||
space_id uuid FK
|
||||
title text
|
||||
body text
|
||||
opens_at, closes_at
|
||||
status enum: 'draft' | 'open' | 'closed'
|
||||
is_anonymous boolean
|
||||
visibility_of_results enum: 'admin_only' | 'board_visible' | 'all_members'
|
||||
|
||||
survey_questions
|
||||
id, survey_id, prompt, response_type (likert | text | multi_choice), options jsonb, required
|
||||
|
||||
survey_responses
|
||||
id, survey_id, member_id (nullable iff anonymous), submitted_at
|
||||
|
||||
survey_answers
|
||||
id, response_id, question_id, value text / value_int
|
||||
```
|
||||
|
||||
A space sets an annual baseline survey (trust in board, welcoming to marginalized groups, project completion vs frustration, mission alignment). Results render as a time series across years. No scoring, no leaderboard. Read-only analytics for the membership.
|
||||
|
||||
### 4.4 Mission statement display
|
||||
|
||||
Generalises Pattern 11.
|
||||
|
||||
Add `mission_statement text` to `spaces`. Each space writes its own. Renders in the page footer everywhere. No template; pure freeform.
|
||||
|
||||
---
|
||||
|
||||
## What I would not build
|
||||
|
||||
These are real concerns in the source analysis but they fail one of the three filters (don't generalise, infrastructure-too-heavy, or solve a workflow problem better solved by workflow).
|
||||
|
||||
| Item from the source | Why I'd skip |
|
||||
|----------------------|--------------|
|
||||
| Cryptographic anonymity for complaints | Over-promises. The DB admin can always read. Honest "anonymous-reported, opaque token for status" covers 95% of cases; a hackerspace that needs stronger anonymity should use Signal. |
|
||||
| Discourse / Loomio integration | We already ship comms + KB. Adding a third governance forum on top fragments more than it consolidates. If a space wants Discourse they can self-host it; we don't need to be it. |
|
||||
| Door-system access logging | Hardware integration. Out of scope for a CRUD app. Operators can wire whatever access controller they have to write to `activity_log` via the existing service-role API if they want to. |
|
||||
| Cross-platform identity unification (SAML/OIDC across Slack/Discord/wiki) | Each space's other tools are different. We provide a stable internal identity. Federation is out of scope. |
|
||||
| External-perception dashboard (social media monitoring) | Belongs in a separate marketing tool. Not core to running the space. |
|
||||
| Pattern-detection ML on incidents | Just surface raw co-occurrence. Humans are better at "is this a pattern?" than a classifier on 12 incidents/year. |
|
||||
| Term-limits enforcement | Configurable bylaws can vary too widely. Display "term ends on YYYY-MM-DD" if a member has one. Don't auto-kick. |
|
||||
| Onboarding curriculum modules per station | Too space-specific. Each space defines its own stations. A station might be a laser, a forge, or a bee hive. Generic "modules" become useless abstraction. The existing KB plus a `prerequisite_kb_ids` field on tasks covers the 80% case. |
|
||||
| Public-private framing surveillance (tracking DM contradictions) | Even the source analysis flagged this as wrong. Make the institutional record so visible that drift is self-evident. Don't watch private conversations. |
|
||||
| Email digest summarisation with sentiment scoring | Building a "drama detector" is a tar pit. Provide structured decision logs and let members read those instead of chat backlog. |
|
||||
|
||||
---
|
||||
|
||||
## Schema and code impact summary
|
||||
|
||||
If Tier 1 ships, the changes are:
|
||||
|
||||
**New tables**: `proposals`, `proposal_votes`, `incidents`, `incident_updates`, `policies`.
|
||||
|
||||
**Modified tables**: `spaces` gains ~6 settings columns (defaults for quorum, voting window, visibility flags, SLA, mission statement).
|
||||
|
||||
**New enums**: `proposal_type`, `proposal_status`, `vote_position`, `threshold_rule`, `incident_status`, `incident_severity`, `incident_visibility`, `policy_status`.
|
||||
|
||||
**New RLS policies**: ~20 (4 per new table × 5 tables, roughly).
|
||||
|
||||
**New routes**: `/proposals`, `/proposals/[id]`, `/proposals/new`, `/incidents`, `/incidents/[id]`, `/incidents/new`, `/incidents/track/[token]` (public, for anonymous status), `/policies`, `/policies/[slug]`, `/policies/[slug]/[version]`.
|
||||
|
||||
**New server actions in `lib/actions/`**: a new `governance.ts` (or split into `proposals.ts`, `incidents.ts`, `policies.ts`). Each uses the existing `requireMember` / `requireMemberWithRole` / `parseInput` helpers. Each emits an `activity_log` row via the existing `logActivity` helper.
|
||||
|
||||
**New triggers in `scripts/schema.sql`**:
|
||||
- One to insert a card-review task when `has_card_access` flips true.
|
||||
- One to freeze `outcome_*` on `proposals` when status transitions to `decided`.
|
||||
- One to update `policies.prior_version_id` and increment `version` when a new row replaces an active one.
|
||||
|
||||
All within the architecture already in place. No new dependencies, no new auth providers, no new background workers (the proposal-close action can be a cron-triggered DO endpoint that runs once an hour).
|
||||
|
||||
---
|
||||
|
||||
## Sequencing recommendation
|
||||
|
||||
Build Tier 1 first, as one cohesive thing, because the three modules reference each other and shipping them piecemeal leaves gaps:
|
||||
|
||||
1. `proposals` and `proposal_votes` (basic CRUD + voting)
|
||||
2. `policies` with versioning
|
||||
3. `incidents` with reporter notification and appeal-as-proposal linking
|
||||
4. Cross-references between the three working (citation linking, appeal pathway)
|
||||
|
||||
This is roughly 3 months of focused work for one engineer including UI. The schema work is a week; the rest is forms, lists, detail pages, RLS testing, and writing the in-product help that explains what each thing does to a non-political member.
|
||||
|
||||
Then Tier 2: financials and member-state extensions. Mostly UI on top of existing tables; 1 month.
|
||||
|
||||
Then Tier 3 as appetite allows.
|
||||
|
||||
---
|
||||
|
||||
## A meta-observation
|
||||
|
||||
The source analysis warns: "Cooperatives die more often from bureaucratic creep than from under-tooling." This is correct. The features above only help if they make institutional work *easier*, not if they make it more elaborate. The test for any new screen: would a tired board member at 11pm on a Tuesday actually use this, or would they slack-message the chair instead? If the answer is "slack-message the chair," the feature is wrong.
|
||||
|
||||
The simplest test for the proposals module: can a member, in two minutes, file a vote-of-confidence proposal that hits all the required procedural elements (quorum stated, threshold stated, voting window defined, bylaws clause cited)? If yes, it's good. If they have to read three help pages first, simplify the form.
|
||||
|
||||
The simplest test for the incidents module: when a complaint is filed and the board dismisses it, does the reporter receive an unambiguous in-app notification within 24 hours that names the decision-makers and provides a one-click path to a membership appeal? If yes, the black hole is closed. If they have to ask, the feature failed.
|
||||
|
||||
Build for what hurts repeatedly. Defer what hurts occasionally. Reject what hurts hypothetically.
|
||||
+752
@@ -0,0 +1,752 @@
|
||||
# Handoff Log
|
||||
|
||||
Append-only. Newest entries on top. Keep each entry to one screen.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-14 (pass 9) — Datetime fix + configurable areas
|
||||
|
||||
Branch: `main`. Author: Claude (no commits made).
|
||||
|
||||
### What I did
|
||||
|
||||
Two user-reported issues from running the local app:
|
||||
|
||||
1. **"Invalid datetime" error when creating a task with a due_date.** HTML `<input type="date">` sends `"2026-05-22"` (date-only) but `z.string().datetime()` requires full RFC 3339. Eight other schemas had the same latent bug (`createProjectSchema.due_date`, `addMemberSchema.joined_at`, `logCashPaymentSchema.transaction_date`, both proposal voting timestamps, `createPolicySchema.effective_at`, `supersedePolicySchema.effective_at`, `meetingMinutesSchema.meeting_date`).
|
||||
2. **Areas were hardcoded constants** in `tasks-client.tsx` (9 strings) and `projects-client.tsx` (9 different strings). Not configurable per-space. No defaults seeded for new spaces.
|
||||
|
||||
### Fix 1: shared `flexibleDateTime` preprocessor
|
||||
|
||||
`lib/validations.ts` exports `flexibleDateTime()`. A `z.preprocess` that maps `""`/`null`/`undefined` → `null`, passes through full RFC 3339 unchanged, normalises `YYYY-MM-DD` and `YYYY-MM-DDTHH:MM` through `new Date(v).toISOString()`, and leaves unparseable input for Zod to reject. Wired into all nine date fields. The original bug screenshot is gone: `"2026-05-22"` validates and inserts as `"2026-05-22T00:00:00.000Z"`. Added 7 new tests.
|
||||
|
||||
### Fix 2: `space_areas` table + Settings UI
|
||||
|
||||
Migration `scripts/020_areas.sql`:
|
||||
|
||||
- `space_areas` table: `(id, space_id, code, name, icon, sort_order, is_archived)`. UNIQUE per `(space_id, code)` and `(space_id, name)`.
|
||||
- Sort index `(space_id, sort_order, name)`.
|
||||
- RLS: members read, admin/board write, admin delete.
|
||||
- `seed_default_areas()` trigger fires after spaces INSERT, seeding ten defaults: 3D Printing, Electronics, Woodshop, Laser, Metal Shop, Facilities, Software, Kitchen, Admin, General. Codes are stable slugs; names are display labels.
|
||||
- Backfill populates every existing space that has no areas. Idempotent.
|
||||
- Folded into `scripts/schema.sql` as Section 12.
|
||||
|
||||
Library surface:
|
||||
|
||||
- `lib/types.ts`: `SpaceArea` type.
|
||||
- `lib/validations.ts`: `createAreaSchema`, `updateAreaSchema` (slug regex, length caps).
|
||||
- `lib/actions/areas.ts`: `createArea`, `updateArea`, `deleteArea`. Use `requireMemberWithRole(ADMIN_ROLES)`. Re-exported via the barrel.
|
||||
|
||||
UI wiring:
|
||||
|
||||
- `app/(app)/tasks/page.tsx` and `app/(app)/projects/page.tsx` fetch `space_areas` in parallel and pass `areas: string[]` to the client.
|
||||
- `tasks-client.tsx` and `projects-client.tsx` accept the new prop; old hardcoded arrays renamed `DEFAULT_AREAS` as a fallback.
|
||||
- `app/(app)/settings/page.tsx` fetches the full area list and passes to `SettingsClient`.
|
||||
- `settings-client.tsx`: new **Areas** card on the Space tab. Lists every area with inline-editable name + sort_order, archive/restore toggle, admin-only delete with confirm prompt. "Add area" form takes code + display name.
|
||||
|
||||
### Verification (live local stack)
|
||||
|
||||
- Migration applied to the running Postgres. 20 area rows across 2 spaces (Demo Hackerspace + the user's HeatSync Labs space created earlier through the UI), 10 each.
|
||||
- Test user fetched `space_areas` via REST with their JWT; got the 10 defaults sorted by `sort_order`. RLS clean, no cross-tenant leak.
|
||||
- **266 tests across 8 files** (was 259; +7 for flexibleDateTime).
|
||||
- `pnpm build` clean, 31 routes.
|
||||
- Dev server still running, hot-reloaded the changes.
|
||||
|
||||
### Files changed
|
||||
|
||||
```
|
||||
Added:
|
||||
scripts/020_areas.sql
|
||||
lib/actions/areas.ts
|
||||
|
||||
Edited:
|
||||
lib/validations.ts (flexibleDateTime + area schemas)
|
||||
lib/types.ts (SpaceArea)
|
||||
lib/actions/index.ts (export areas)
|
||||
scripts/schema.sql (Section 12)
|
||||
scripts/README.md (020 listed)
|
||||
app/(app)/tasks/page.tsx (fetch + pass areas)
|
||||
app/(app)/tasks/tasks-client.tsx (accept areas prop)
|
||||
app/(app)/projects/page.tsx (fetch + pass areas)
|
||||
app/(app)/projects/projects-client.tsx (accept areas prop)
|
||||
app/(app)/settings/page.tsx (fetch areas)
|
||||
app/(app)/settings/settings-client.tsx (Areas management card)
|
||||
__tests__/validations.test.ts (7 new tests)
|
||||
types/database.ts (regenerated; includes space_areas)
|
||||
docs/HANDOFF.md (this entry)
|
||||
```
|
||||
|
||||
In the browser: `/tasks` → New Task → due_date `2026-05-22` saves cleanly. `/settings` → Space tab → scroll to Areas → add a custom area; it appears in the Tasks and Projects dropdowns on next render.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-14 (pass 8) — Local stack verified end-to-end, seed + LOCAL_DEV docs
|
||||
|
||||
Branch: `main`. Author: Claude (no commits made).
|
||||
|
||||
### What I did
|
||||
|
||||
Brought the system from "passes tests + builds" to "runs against a real Supabase stack on this machine, end-to-end."
|
||||
|
||||
1. Installed Supabase CLI via `brew install supabase/tap/supabase`. Started a local stack (`supabase start`) — Postgres, GoTrue, PostgREST, Realtime, Storage, Studio, Mailpit all up in Docker.
|
||||
2. Applied `scripts/schema.sql` from scratch against the empty local DB. **18 tables created**.
|
||||
3. Wrote `scripts/seed.sql` and applied it: 1 demo space, 5 offline members across all roles, 1 active code-of-conduct policy v1, 3 tasks, 1 project, 1 KB entry, 3 proposals (open / draft / decided) with 7 votes already cast, 1 open incident, 8 activity-log rows.
|
||||
4. **Migration 019** (`scripts/019_proposal_expiry.sql`): `public.expire_proposals()` flips open proposals past their deadline to `decided` (if quorum met) or `expired`. Schedules itself hourly via `pg_cron` if the extension is enabled. Folded into `schema.sql` as Section 11.
|
||||
5. Wrote `docs/LOCAL_DEV.md`: 8-step quickstart, wipe + re-seed recipe, troubleshooting section.
|
||||
6. Regenerated `types/database.ts` from the live local DB. 1696 lines, 18 tables, 1 view, 3 RPC functions.
|
||||
7. Wrote `.env.local` with the local Supabase URL and keys.
|
||||
8. Started `pnpm dev`. Health endpoint returns 200, public routes return 200, `/dashboard` correctly 307s to `/login`.
|
||||
|
||||
### Verification done with real data
|
||||
|
||||
- Anon-key RLS gate confirmed (empty array on no-session).
|
||||
- Service-role bypass confirmed.
|
||||
- Created `tester@demo.local` via Auth Admin API, signed in via `/auth/v1/token`, got a real ES256 JWT, queried proposals + channels + policies — all RLS-scoped, all returning seeded data.
|
||||
- **Privilege-escalation guard (migration 015)**: simulated a plain-member self-promotion via JWT-claimed authenticated role. Postgres rejected with the exact `Members cannot change their own role, tier, status, approval, card access, or space.` message.
|
||||
- `expire_proposals()` runs cleanly, returns 0.
|
||||
- HTML smoke: `/login` title correct, `/signup` contains expected copy.
|
||||
- **259 tests across 8 files passing**.
|
||||
|
||||
### Files changed
|
||||
|
||||
```
|
||||
Added:
|
||||
supabase/config.toml (from supabase init)
|
||||
scripts/019_proposal_expiry.sql
|
||||
scripts/seed.sql
|
||||
docs/LOCAL_DEV.md
|
||||
.env.local (gitignored)
|
||||
|
||||
Edited:
|
||||
scripts/schema.sql (Section 11)
|
||||
types/database.ts (regenerated)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-14 (pass 7) — Tier 2/3 UI closure + Markdown + proxy rename
|
||||
|
||||
Branch: `main`
|
||||
Author: Claude (no commits made).
|
||||
|
||||
### What I did
|
||||
|
||||
Finishing the user-facing surface for the schema work passes 5 and 6 added.
|
||||
|
||||
1. **Renamed `middleware.ts` → `proxy.ts`** for Next.js 16. Renamed the exported `middleware` function to `proxy`. The deprecation warning that the build had been emitting since pass 4 is now gone.
|
||||
2. **Markdown rendering**: installed `react-markdown` + `remark-gfm`. Created `components/markdown.tsx` (`MarkdownBody`) with safe defaults — HTML escaped, no `rehypeRaw`, every link gets `rel="noopener noreferrer"` and `target="_blank"`. Wired it into the three governance detail pages so proposal bodies, incident bodies, incident dispositions, incident updates, and policy formal/plain text now render headings, lists, links, code, blockquotes, tables, strikethrough.
|
||||
3. **`/profile` route** (Tier 3.1 + 3.2 UI closure):
|
||||
- Server page reads the caller's `space_members` row.
|
||||
- `ProfileForm` (client) lets a member edit display_name, handle, phone, skills, interests, willing_to. Uses a tiny custom `ChipInput` component for tag-style multi-value fields with `Enter`/`,` to commit and `Backspace` to delete.
|
||||
- `AffiliationsForm` (client) lets the member disclose COI affiliations. Submit stamps `coi_last_disclosed_at` via `discloseAffiliations`. Surfaces an orange callout for privileged-role members who haven't disclosed yet.
|
||||
- The `willing_to` field ships with suggestion chips: `board_candidate`, `treasurer_candidate`, `host_volunteer`, `area_lead_candidate`, `event_organizer`, `safety_committee`, `docs_steward`. Members can also free-form.
|
||||
4. **`/recruitment` route** (Tier 3 succession view):
|
||||
- Board / admin only (server-side redirect for others).
|
||||
- Queries `space_members` for the current space; groups by each `willing_to` tag; renders one card per tag with the willing members listed underneath.
|
||||
- For privileged-role members (admin / board / treasurer) the card shows a "COI on file" or "no COI" badge based on `coi_last_disclosed_at`.
|
||||
- A separate "Skills declared (no role opt-in)" section catches members who declared skills without willing_to.
|
||||
5. **Settings extensions** (Tier 2.1 + Tier 2.2 + mission statement):
|
||||
- Added `mission_statement` field to `updateSpaceSettingsSchema` (max 5000 chars, nullable).
|
||||
- The Space tab on `/settings` now has a Mission Statement textarea inside the existing space-settings form.
|
||||
- A new Visibility card on the Space tab exposes two dropdowns: `financial_visibility` (3 options) and `member_directory_visibility` (4 options). Saves via the existing `updateSpaceVisibility` action from pass 6.
|
||||
6. **Mission statement display in the app shell**: when `space.mission_statement` is set, the app layout renders it in a small italic footer on every protected page — the "stated-mission visibility on every page" principle from `docs/GOVERNANCE_FEATURES.md` section 1.
|
||||
7. **Dashboard widget**: above the main grid, two side-by-side cards.
|
||||
- **Open proposals awaiting your vote**: queries open proposals, joins against the caller's `proposal_votes` to filter out ones they've already voted on, shows up to 5 with the voting-window close timestamp and a live quorum percentage.
|
||||
- **Incidents needing response** (admin/board only): shows `received` and `under_review` incidents, sorted newest first, with a red "respond by" date if the SLA has elapsed.
|
||||
- The whole section only renders if at least one of the two has content.
|
||||
8. **Sidebar**: added "My profile" (`UserCircle` icon) and admin-gated "Recruitment" (`UserSearch` icon) under the People group.
|
||||
9. **4 new tests** in `__tests__/governance.test.ts` for the mission_statement extension to `updateSpaceSettingsSchema`. Total now **259 across 8 files**.
|
||||
|
||||
### Verification
|
||||
|
||||
- `pnpm vitest run` → **259 tests pass across 8 files**.
|
||||
- `pnpm build` → success. Build emits **31 routes** (added `/profile`, `/recruitment`).
|
||||
- **The middleware-deprecation warning is gone**. Only remaining build warning is the stray lockfile at `/Users/obsidian/Projects/package-lock.json`, which is outside the repo and harmless on DO.
|
||||
|
||||
### Open items, priority order
|
||||
|
||||
1. **Apply pending migrations** on production (015, 017, 018) if not already done. Fresh deploys get them all via `scripts/schema.sql`.
|
||||
2. **Regenerate `types/database.ts`** with the Supabase CLI to clear the ~217 TS errors that `typescript.ignoreBuildErrors` masks. Once clean, remove `ignoreBuildErrors` so future drift surfaces in CI.
|
||||
3. **Configure OAuth providers** in Supabase if you want the GitHub / Google buttons to work.
|
||||
4. **Auto-expire open proposals**: when `voting_closes_at < now()`, status should auto-flip from `open` to `expired`. Currently manual via the admin's "Mark decided" button. A cron-triggered server action would close this.
|
||||
5. **Mark KB entry as meeting minutes**: schema (column added in 018) and validation schema in place, but no UI control yet.
|
||||
6. **Notifications on incident status change**: activity_log row written but no in-app comms message or email triggered. Add when notification infra is ready.
|
||||
7. **Hosting calendar, sponsor / partner registry, community surveys**: Tier 4, deferred indefinitely until adoption signals demand.
|
||||
8. **Secrets encryption at rest**: `secrets.value` and `integrations.config` still plain text. Address before storing real production credentials.
|
||||
|
||||
### Files changed this pass
|
||||
|
||||
```
|
||||
Added:
|
||||
proxy.ts (replaces middleware.ts)
|
||||
components/markdown.tsx
|
||||
app/(app)/profile/page.tsx
|
||||
app/(app)/profile/profile-form.tsx
|
||||
app/(app)/profile/affiliations-form.tsx
|
||||
app/(app)/profile/chip-input.tsx
|
||||
app/(app)/recruitment/page.tsx
|
||||
|
||||
Removed:
|
||||
middleware.ts (renamed to proxy.ts)
|
||||
|
||||
Edited:
|
||||
app/(app)/layout.tsx (mission statement footer)
|
||||
app/(app)/dashboard/page.tsx (governance widget)
|
||||
app/(app)/settings/settings-client.tsx (mission statement + visibility section)
|
||||
app/(app)/proposals/[id]/page.tsx (MarkdownBody)
|
||||
app/(app)/incidents/[id]/page.tsx (MarkdownBody, ×3)
|
||||
app/(app)/policies/[slug]/page.tsx (MarkdownBody, ×2)
|
||||
components/app-sidebar.tsx (Profile, Recruitment links)
|
||||
lib/validations.ts (mission_statement on updateSpaceSettingsSchema)
|
||||
__tests__/governance.test.ts (4 new tests)
|
||||
package.json (react-markdown, remark-gfm)
|
||||
docs/HANDOFF.md (this entry)
|
||||
```
|
||||
|
||||
### How to verify locally
|
||||
|
||||
```bash
|
||||
pnpm install # picks up react-markdown + remark-gfm
|
||||
pnpm vitest run # 259 tests, 8 files
|
||||
pnpm build # 31 routes, no middleware deprecation warning
|
||||
```
|
||||
|
||||
In the browser:
|
||||
- Visit `/profile`, edit skills / willing_to (chip input), save.
|
||||
- Visit `/recruitment` as admin/board — see members grouped by willing_to.
|
||||
- Visit `/settings`, scroll to "Visibility", change financial_visibility to `treasurer_only`, save, then visit `/financials` as a plain member — should be blocked. Switch back to `all_members_visible` — visible again.
|
||||
- Set a mission statement on `/settings`. It appears as a footer on every protected page.
|
||||
- Open a proposal, edit body to include `# heading\n\n- list\n- items\n\n[link](https://example.com)`. Renders rich.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-14 (pass 6) — Audit fixes + Tier 2/3 partial-ship
|
||||
|
||||
Branch: `main`
|
||||
Author: Claude (no commits made).
|
||||
|
||||
### Audit findings
|
||||
|
||||
Read my pass-5 work hard. Four real defects, plus several lower-severity items I documented but didn't change.
|
||||
|
||||
**Critical (fixed in migration 017):**
|
||||
1. `proposal_votes.votes_insert` accepted a vote where `member_id` was a `space_members` row from space A but the proposal was in space B, because the policy never joined them. Fixed: insert WITH CHECK now requires `m.space_id = p.space_id` for the joined member.
|
||||
2. `votes_update` had the same bug. Same fix.
|
||||
3. `incidents.incidents_insert` let the reporter set `reporter_id` to any `space_members.id` they owned — across spaces. Fixed: WITH CHECK requires the row's `space_id` match the incident's.
|
||||
4. `incident_updates.incident_updates_insert` did not validate `author_id` at all. Fixed: WITH CHECK requires the author is the calling user and in the incident's space.
|
||||
|
||||
Plus: added partial unique index `policies_one_active_per_slug ON policies (space_id, slug) WHERE status = 'active'` so two admins concurrently activating different drafts cannot leave two `active` rows.
|
||||
|
||||
**Lower-severity (documented, deferred):**
|
||||
- `requireMember().single()` makes multi-space membership effectively impossible. The app design has always assumed one membership per user.
|
||||
- `appealIncident` does insert-then-update without a transaction; mid-flight failure leaves an orphaned proposal. Acceptable risk.
|
||||
- `supersedePolicy` race between two admins computing `version + 1` simultaneously is caught by the existing `UNIQUE (space_id, slug, version)`.
|
||||
- Reporter can post `visibility='board_only'` on their own incident_update via raw API and then can't read it. Self-inflicted.
|
||||
- `/proposals` and `/incidents` lists don't paginate. Defer.
|
||||
- Incident detail never shows the reporter's name to admin/board. Future ergonomic improvement.
|
||||
- Dashboard doesn't surface open proposals or pending incidents.
|
||||
|
||||
### Built in this pass (audit fixes + Tier 2/3 schema + one Tier 2 UI)
|
||||
|
||||
1. **Migration 017** `scripts/017_governance_rls_hardening.sql`: the four RLS fixes + the partial unique index. Mirrored into `scripts/schema.sql`.
|
||||
2. **Migration 018** `scripts/018_member_state_and_visibility.sql`:
|
||||
- `space_members` gains `skills`, `interests`, `willing_to`, `affiliations` (all `text[]`) and `coi_last_disclosed_at`. GIN indexes on `skills` and `willing_to`.
|
||||
- `spaces` gains `financial_visibility` and `member_directory_visibility` enums.
|
||||
- `knowledge_base` gains `is_meeting_minutes` and `meeting_date` (partial index).
|
||||
- `schedule_card_review` trigger inserts a 180-day task whenever `has_card_access` flips on.
|
||||
- `inactive_members` view: members in good standing with no `activity_log` entry in 180 days.
|
||||
3. **New server actions**:
|
||||
- `members.ts → updateMyProfile({...})`: member self-edit of display_name, handle, phone, skills, interests, willing_to. The privilege-escalation trigger from migration 015 keeps role/tier/status off-limits.
|
||||
- `members.ts → discloseAffiliations({ affiliations })`: stamps `coi_last_disclosed_at` and logs to activity_log.
|
||||
- `settings.ts → updateSpaceVisibility({...})`: admin-only.
|
||||
4. **Validation schemas**: `updateMyProfileSchema`, `discloseAffiliationsSchema`, `updateSpaceVisibilitySchema`, `meetingMinutesSchema`, plus exported tuples (`financialVisibilities`, `directoryVisibilities`).
|
||||
5. **13 new tests** in `__tests__/governance.test.ts`. Total now **255 across 8 files**.
|
||||
6. **`/financials` route**: server-component aggregations from `payments` (this month, this year, lifetime, linked vs unlinked, by platform with sparkline-style bars, last 12 months). Gated by `space.financial_visibility` against caller's role.
|
||||
7. **Sidebar**: added "Financials" under People with the `LineChart` icon.
|
||||
|
||||
### Verification
|
||||
|
||||
- `pnpm vitest run` → **255 tests pass across 8 files**.
|
||||
- `pnpm build` → success. **29 routes emit** (added `/financials`).
|
||||
- `pnpm exec tsc --noEmit` → same Supabase generic-drift count as before. No new error categories.
|
||||
|
||||
### Deployment steps for this pass
|
||||
|
||||
Run on every existing database, in order:
|
||||
|
||||
```sql
|
||||
\i scripts/017_governance_rls_hardening.sql
|
||||
\i scripts/018_member_state_and_visibility.sql
|
||||
```
|
||||
|
||||
Then restart PostgREST (`docker compose exec rest restart` on self-hosted; managed Supabase auto-refreshes).
|
||||
|
||||
Fresh deploys get both via `scripts/schema.sql` (sections 9.8 and 10). No additional config needed; new columns ship with sensible defaults.
|
||||
|
||||
### Open items, priority order
|
||||
|
||||
1. **Apply 017 on production immediately** if any chance of multi-space membership (admin client could have set it up).
|
||||
2. **Apply 018** for the Tier 2/3 columns and the card-review trigger.
|
||||
3. **Profile / COI / settings-visibility UI**: server actions are in place, no UI yet. A `/profile` route + extending Settings would close Tier 3.1, 3.2 and the financial-visibility toggle.
|
||||
4. **Recruitment view**: query `space_members` by `willing_to` for board-only succession planning.
|
||||
5. **Mark KB entry as meeting minutes from UI**: schema ready, no UI control.
|
||||
6. Earlier-pass items still open: regenerate `types/database.ts`, run migration 015 if not already, rename `middleware.ts` → `proxy.ts` for Next.js 16.
|
||||
|
||||
### Files changed this pass
|
||||
|
||||
```
|
||||
Added (schema):
|
||||
scripts/017_governance_rls_hardening.sql
|
||||
scripts/018_member_state_and_visibility.sql
|
||||
|
||||
Added (UI):
|
||||
app/(app)/financials/page.tsx
|
||||
|
||||
Edited:
|
||||
scripts/schema.sql (sections 9.8 + 10)
|
||||
scripts/README.md (017, 018 listed)
|
||||
lib/types.ts (FinancialVisibility + DirectoryVisibility)
|
||||
lib/validations.ts (4 new schemas + tuples)
|
||||
lib/actions/members.ts (updateMyProfile, discloseAffiliations)
|
||||
lib/actions/settings.ts (updateSpaceVisibility)
|
||||
components/app-sidebar.tsx (Financials nav link)
|
||||
__tests__/governance.test.ts (13 new tests)
|
||||
docs/GOVERNANCE_FEATURES.md (Tier 2 + 3 marked partial-shipped)
|
||||
docs/HANDOFF.md (this entry)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-14 (pass 5) — Tier 1 governance kernel shipped
|
||||
|
||||
Branch: `main`
|
||||
Author: Claude (no commits made).
|
||||
|
||||
### What I did
|
||||
|
||||
Built the full Tier 1 governance kernel from `docs/GOVERNANCE_FEATURES.md`. Three new tightly integrated modules: proposals, incidents, policies. Schema + server actions + validations + tests + UI + sidebar nav.
|
||||
|
||||
1. **Schema**: `scripts/016_governance_kernel.sql` (and mirrored into `scripts/schema.sql` as Section 9). 5 new tables (`proposals`, `proposal_votes`, `incidents`, `incident_updates`, `policies`), 8 new enums, 6 new columns on `spaces` for governance defaults (quorum percent/floor, voting window, incident SLA, mission statement). Triggers: `compute_proposal_quorum`, `refresh_proposal_tally`, `compute_incident_sla`, `touch_updated_at`. Full RLS: any member proposes / files / reads; admin+board transition status; votes are immutable post-window; incidents readable only to reporter + admin/board. Idempotent re-run safe (`DROP POLICY IF EXISTS`, `CREATE OR REPLACE`, `EXCEPTION WHEN duplicate_object`).
|
||||
2. **Types** in `lib/types.ts`: hand-typed `Proposal`, `ProposalVote`, `Incident`, `IncidentUpdateRow`, `Policy` and all 8 governance enums. `types/database.ts` should be regenerated via Supabase CLI to fold these in.
|
||||
3. **Validations** in `lib/validations.ts`: 11 new Zod schemas (`createProposalSchema`, `castVoteSchema`, `decideProposalSchema`, `withdrawProposalSchema`, `openProposalSchema`, `fileIncidentSchema`, `updateIncidentStatusSchema`, `addIncidentUpdateSchema`, `appealIncidentSchema`, `createPolicySchema`, `supersedePolicySchema`, `updatePolicyStatusSchema`). Tested before being wired into actions (test-driven).
|
||||
4. **Server actions**: `lib/actions/proposals.ts`, `lib/actions/incidents.ts`, `lib/actions/policies.ts`. Use the existing `requireMember` / `requireMemberWithRole` / `parseInput` / `logActivity` helpers. Re-exported via the barrel in `lib/actions/index.ts`. Notable: `fileIncident` generates an opaque `reporter_token` when anonymous; `appealIncident` atomically spawns a draft `membership_vote` proposal and flips the incident to `appealed`; `supersedePolicy` inserts a new version with `prior_version_id` set rather than mutating the prior row; `updatePolicyStatus(... , 'active')` atomically supersedes any prior active version with the same slug.
|
||||
5. **Tests**: `__tests__/governance.test.ts` adds 40 tests covering every new Zod schema (valid input, invalid enum values, required field enforcement, recusal-requires-reason cross-field validation, slug character set, etc). The cross-cutting "governance contract surface" suite enforces that the enum tuples we export match the DB enums in workflow order.
|
||||
6. **UI**: server-component-driven, minimal client islands.
|
||||
- `/proposals`: open / draft / archive sections with live tally and quorum hints.
|
||||
- `/proposals/[id]`: title + body + status, tally panel with quorum progress, vote form (yes/no/abstain/recused-with-required-reason), management buttons (open / withdraw / decide) gated by role, vote list with comments and recusal reasons.
|
||||
- `/proposals/new`: form with type, threshold, "open immediately" checkbox.
|
||||
- `/incidents`: open / decided / closed sections. Members see only their own; admin/board sees all (RLS enforced).
|
||||
- `/incidents/[id]`: title + body + disposition + update thread, status transition form (admin/board), post-update form (everyone with access), one-click "request membership appeal" for reporter on dismissed incidents.
|
||||
- `/incidents/new`: title, body, category, severity, optional subjects multi-select, anonymous checkbox with honest disclaimer; on anonymous submit, surfaces the opaque tracking token to the reporter once.
|
||||
- `/policies`: one row per slug showing the active (or latest) version.
|
||||
- `/policies/[slug]`: plain-language + formal text + version history, with "activate" / "supersede" / "deprecate" buttons for admins.
|
||||
- `/policies/new`: slug + title + section ref + plain + formal text. Admin-only route.
|
||||
7. **Sidebar**: new "Governance" section with three links (Proposals, Incidents, Policies). Lucide icons: `Vote`, `ShieldAlert`, `ScrollText`.
|
||||
8. **Docs updated**:
|
||||
- `docs/GOVERNANCE_FEATURES.md`: Tier 1 marked SHIPPED with implementation pointers.
|
||||
- `scripts/README.md`: migration 016 listed.
|
||||
- This handoff entry.
|
||||
|
||||
### Verification
|
||||
|
||||
- `pnpm vitest run` → **242 tests pass across 8 files** (was 202 / 7). The 40 new tests are in `__tests__/governance.test.ts`.
|
||||
- `pnpm build` → success. **9 new routes emit cleanly** (`/proposals`, `/proposals/[id]`, `/proposals/new`, `/incidents`, `/incidents/[id]`, `/incidents/new`, `/policies`, `/policies/[slug]`, `/policies/new`). 28 routes total.
|
||||
- `pnpm exec tsc --noEmit` → 217 errors (was 157). All 60 new errors are the same Supabase generic-drift pattern (`Property 'X' does not exist on type 'never'`) that the pre-existing 157 are. Runtime is unaffected. Regenerating `types/database.ts` with the Supabase CLI clears them all.
|
||||
|
||||
### Open items, priority order
|
||||
|
||||
1. **Apply `scripts/016_governance_kernel.sql`** to any existing production database. Fresh deploys get it via `scripts/schema.sql` automatically.
|
||||
2. **Regenerate `types/database.ts`** with `supabase gen types typescript --project-id <ref>`. Clears the 60 + 157 = 217 TS errors. Then remove `typescript.ignoreBuildErrors` from `next.config.mjs`.
|
||||
3. **Pass 4 priority 1 still applies**: run `scripts/015_prevent_member_self_role_change.sql` before external users sign in.
|
||||
4. **Auto-expiry of open proposals**: when `voting_closes_at < now()`, the status should flip from `open` to `expired` (or `decided` if board acts). Currently manual. A cron-triggered server action or DB job can do this.
|
||||
5. **Markdown rendering**: proposals' body is currently rendered as `<pre>` whitespace-preserved text. Pulling in `react-markdown` would give links, lists, and the `[policy:slug#section]` citation syntax from the roadmap doc. Defer until adoption is real.
|
||||
6. **No notifications on incident status change**. The activity_log row is inserted but no in-app comms message or email is sent. Add later when notification infra is ready.
|
||||
7. **Rename `middleware.ts` → `proxy.ts`** for Next.js 16 (still works, deprecation warning only).
|
||||
|
||||
### Decisions pending
|
||||
|
||||
- Whether to back-port Tier 2 features (skill/interest fields, COI disclosure, mid-tenure card review trigger, financial dashboard visibility settings) in the next pass.
|
||||
- Whether to add markdown rendering with citation hover-cards now or wait.
|
||||
- Whether to add a worker / cron to auto-flip expired proposals.
|
||||
|
||||
### Files changed this pass
|
||||
|
||||
```
|
||||
Added (schema):
|
||||
scripts/016_governance_kernel.sql
|
||||
|
||||
Added (lib):
|
||||
lib/actions/proposals.ts
|
||||
lib/actions/incidents.ts
|
||||
lib/actions/policies.ts
|
||||
|
||||
Added (tests):
|
||||
__tests__/governance.test.ts
|
||||
|
||||
Added (UI):
|
||||
app/(app)/proposals/page.tsx
|
||||
app/(app)/proposals/proposal-badges.tsx
|
||||
app/(app)/proposals/new/page.tsx
|
||||
app/(app)/proposals/new/new-proposal-form.tsx
|
||||
app/(app)/proposals/[id]/page.tsx
|
||||
app/(app)/proposals/[id]/proposal-actions.tsx
|
||||
app/(app)/incidents/page.tsx
|
||||
app/(app)/incidents/new/page.tsx
|
||||
app/(app)/incidents/new/incident-form.tsx
|
||||
app/(app)/incidents/[id]/page.tsx
|
||||
app/(app)/incidents/[id]/incident-actions.tsx
|
||||
app/(app)/policies/page.tsx
|
||||
app/(app)/policies/[slug]/page.tsx
|
||||
app/(app)/policies/[slug]/policy-actions.tsx
|
||||
app/(app)/policies/new/page.tsx
|
||||
app/(app)/policies/new/new-policy-form.tsx
|
||||
|
||||
Edited:
|
||||
scripts/schema.sql (added Section 9: governance kernel)
|
||||
scripts/README.md (listed migration 016)
|
||||
lib/types.ts (governance entity types + enum unions)
|
||||
lib/validations.ts (11 new Zod schemas + enum exports)
|
||||
lib/actions/index.ts (re-export the three new modules)
|
||||
components/app-sidebar.tsx (Governance section with 3 links)
|
||||
docs/GOVERNANCE_FEATURES.md (Tier 1 marked SHIPPED)
|
||||
docs/HANDOFF.md (this entry)
|
||||
```
|
||||
|
||||
### Deployment steps for this pass
|
||||
|
||||
On any existing database:
|
||||
|
||||
```sql
|
||||
-- In Supabase SQL editor or psql:
|
||||
\i scripts/016_governance_kernel.sql
|
||||
```
|
||||
|
||||
For a fresh deploy, the canonical `scripts/schema.sql` already includes it (Section 9). No app-level config changes needed; the new `spaces` columns ship with sensible defaults (`default_quorum_percent=10`, `default_quorum_floor=1`, `default_voting_window_hours=216` (9 days), `default_threshold='simple_majority'`, `incident_sla_hours=72`).
|
||||
|
||||
After applying, restart PostgREST (`docker compose restart rest` on self-hosted) so the schema cache refreshes.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-14 (pass 4) — Deploy readiness + self-hosted DO guide
|
||||
|
||||
Branch: `main`
|
||||
Author: Claude (no commits made).
|
||||
|
||||
### What I did
|
||||
|
||||
1. **Verified deploy readiness** end to end:
|
||||
- `pnpm vitest run`: 202 tests pass across 7 files.
|
||||
- `pnpm build`: success. 19 routes emitted (including `/api/health`, `/api/paypal/sync`, `/auth/callback`, `(landing) /`, all `(app)/*` routes).
|
||||
- `pnpm exec tsc --noEmit`: 157 errors, all pre-existing supabase-js generic drift, all masked by `typescript.ignoreBuildErrors: true` in `next.config.mjs`. No new errors introduced.
|
||||
2. **Fixed one self-inflicted test bug**: `__tests__/auth-helpers.test.ts` was written in pass 3 using the old `r.error` access pattern, which does not narrow now that `Result<T>` is a tagged union (`ok: true | false`). Rewrote each test to gate on `if (r.ok)` / `if (!r.ok)` before reading `data` or `error`.
|
||||
3. **Surfaced two non-blocking build warnings** that the user should know about:
|
||||
- Next.js 16 deprecates the `middleware` file convention in favour of `proxy`. Build still works with `middleware.ts`; rename to `proxy.ts` whenever convenient.
|
||||
- Next.js detected a stray `/Users/obsidian/Projects/package-lock.json` outside the repo (workspace root inference). Local-only nuisance, does not affect CI or DO builds.
|
||||
4. **Wrote `docs/DEPLOY_DO_SELFHOSTED.md`**: complete end-to-end guide for running the entire stack on one DigitalOcean Droplet with self-hosted open-source Supabase. 16 sections, ~700 lines, copy-pasteable commands throughout. Covers:
|
||||
- Architecture and cost (~$48/mo for 8 GB Droplet).
|
||||
- Droplet provisioning, hardening, Docker/Nginx/Certbot install.
|
||||
- DNS records for `yourdomain.com`, `supabase.yourdomain.com`, `studio.yourdomain.com`.
|
||||
- Self-hosting Supabase: cloning `supabase/supabase`, generating `JWT_SECRET` / `ANON_KEY` / `SERVICE_ROLE_KEY` via a Node one-liner, full `.env` template, port-remap override (Studio defaults to 3000 and collides with the Next.js app; doc rebinds it to 3001 and forces all internal ports to `127.0.0.1` to keep them off the public internet).
|
||||
- Applying `scripts/schema.sql`, `014_*.sql`, `015_*.sql` via `docker compose exec db psql`.
|
||||
- Building the app image with `NEXT_PUBLIC_*` build args (otherwise the client bundle ships with `undefined` for them — common gotcha).
|
||||
- Nginx server blocks for app, Supabase API, and basic-auth-gated Studio.
|
||||
- Let's Encrypt SSL covering all four hostnames.
|
||||
- Post-deploy verification including a regression test for migration 015's privilege-escalation guard.
|
||||
- Daily `pg_dump` backups with 14-day local rotation and an off-site path to DO Spaces.
|
||||
- Operations: app upgrades, incremental schema migrations (with `docker compose restart rest` to refresh PostgREST's schema cache), Supabase version upgrades.
|
||||
- Hardening checklist (14 items) and a troubleshooting section covering the eight problems people actually hit.
|
||||
5. **Linked the new doc** from `README.md` (deployment table + body section) and from the top of `docs/DEPLOYMENT.md`. Made it explicit that `DEPLOYMENT.md` covers managed-Supabase paths and `DEPLOY_DO_SELFHOSTED.md` covers full self-hosting.
|
||||
|
||||
### Deployment quick reference (so the next session does not have to re-read everything)
|
||||
|
||||
| Path | When to use | Guide |
|
||||
|------|-------------|-------|
|
||||
| Vercel + managed Supabase | Fastest path, no infra to manage | `docs/DEPLOYMENT.md` section 4 |
|
||||
| DO App Platform + managed Supabase | Want DO but not infra | `docs/DEPLOYMENT.md` section 5, spec at `.do/app.yaml` |
|
||||
| DO Droplet + managed Supabase | Container-only on your VPS, Supabase Cloud for DB | `docs/DEPLOYMENT.md` section 6 |
|
||||
| DO Droplet + self-hosted Supabase | Full data ownership, single Droplet | `docs/DEPLOY_DO_SELFHOSTED.md` |
|
||||
|
||||
Migrations to run on every fresh deploy, in order:
|
||||
1. `scripts/schema.sql` (canonical, idempotent)
|
||||
2. `scripts/014_member_user_id_nullable.sql`
|
||||
3. `scripts/015_prevent_member_self_role_change.sql` (security; do not skip)
|
||||
|
||||
### Open items, priority order
|
||||
|
||||
1. **Run migration 015 on any live database** before external users sign in. Without it, any authenticated member can `UPDATE space_members SET role = 'admin'` on their own row via direct PostgREST.
|
||||
2. **Rename `middleware.ts` to `proxy.ts`** to clear the Next.js 16 deprecation. Single-file rename; the export and matcher contract is unchanged.
|
||||
3. **Regenerate `types/database.ts`** via `supabase gen types typescript --project-id <ref>` (managed) or against the self-hosted Postgres. Clears the 157 TS errors. Then drop `typescript.ignoreBuildErrors: true` from `next.config.mjs` so future type drift surfaces in CI.
|
||||
4. **Wire OAuth providers** in Supabase dashboard (GitHub, Google). The client code already calls `supabase.auth.signInWithOAuth`; only the provider configuration is missing.
|
||||
5. **Encrypt `secrets.value` and `integrations.config`** before storing real production credentials.
|
||||
6. **Stale docs**: `DB_SCHEMA_MAP.md` and `docs/ARCHITECTURE.md` still claim 4 default channels. Schema creates 3 (`general`, `announcements`, `ops`).
|
||||
7. **`signUp` trigger metadata mismatch** in `scripts/schema.sql` (`handle_space_signup` reads `space_id` but the app never passes it). Benign; trigger short-circuits. Either remove the trigger or rewire it.
|
||||
|
||||
### Decisions pending from the user
|
||||
|
||||
- Whether to rename `middleware.ts` → `proxy.ts` now or wait for a future Next.js major version that removes the alias.
|
||||
- Whether to regenerate `types/database.ts` against managed Supabase (`supabase gen types` requires the project ref) or wait until self-hosting is up and run it against that.
|
||||
- Whether to commit the pass 1–4 changes as one large commit, four sequenced commits matching the pass boundaries, or branch-and-PR per concern.
|
||||
|
||||
### How to verify locally
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm vitest run # 202 tests, 7 files
|
||||
pnpm build # success in ~3s; warnings about middleware/lockfile are non-blocking
|
||||
pnpm exec tsc --noEmit 2>&1 | grep -v never | wc -l # remaining non-Supabase errors should be 0
|
||||
```
|
||||
|
||||
### Files changed this pass
|
||||
|
||||
```
|
||||
Added:
|
||||
docs/DEPLOY_DO_SELFHOSTED.md
|
||||
|
||||
Edited:
|
||||
__tests__/auth-helpers.test.ts
|
||||
README.md
|
||||
docs/DEPLOYMENT.md
|
||||
docs/HANDOFF.md
|
||||
```
|
||||
|
||||
No application code, no schema, no UI, no business rules were changed in pass 4. The pass added a major deployment guide and confirmed the work from passes 1–3 produces a clean build and a green test suite.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-14 (pass 3) — Architecture refactor + real tests
|
||||
|
||||
Branch: `main`
|
||||
Author: Claude (no commits made).
|
||||
|
||||
### What I did
|
||||
|
||||
1. **Split `lib/actions.ts` (914 lines, 29 functions) into 10 domain files** under `lib/actions/`. Each file owns one entity:
|
||||
- `tasks.ts`, `projects.ts`, `members.ts`, `contacts.ts`, `payments.ts`, `knowledge-base.ts`, `secrets.ts`, `area-leads.ts`, `settings.ts`, `imports.ts`
|
||||
- `lib/actions/index.ts` re-exports everything via `export * from './foo'`
|
||||
- Every client import `from '@/lib/actions'` still resolves correctly.
|
||||
2. **Created `lib/permissions.ts`**: role constants (`ROLES`, `ADMIN_ROLES`, `TREASURER_ROLES`, `ACTIVE_STATUSES`), `Role` type alias, `hasRole(role, allowed)` helper. Removes magic-string role chains.
|
||||
3. **Created `lib/auth-helpers.ts`**: `getAuthMember`, `requireMember`, `requireMemberWithRole`, `parseInput`, `logActivity`. Result types use the canonical `ok: boolean` discriminator so TypeScript narrows on `if (!r.ok)` reliably.
|
||||
4. **Wired validation into every server action** that has a schema (was previously only `createTask` and `claimTask`). 18 more actions now reject bad input at the boundary instead of hoping the database rejects it.
|
||||
5. **Expanded `lib/types.ts`** with re-exports for `Role`, `Member`, `MemberSummary`, `Result`, `MemberResult`, `ActionResult`, and the role/permission constants. One-stop import for typed work.
|
||||
6. **Deleted dead code**: `app/auth/login/page.tsx` (duplicate login linking to nowhere), `components/app-shell.tsx` (unused, hardcoded badge counts).
|
||||
7. **Wrote real tests**: 76 new tests across four files exercise real project code instead of inline assertions.
|
||||
- `__tests__/validations.test.ts`: 37 tests verifying every Zod schema accepts real DB enums and rejects legacy ones.
|
||||
- `__tests__/permissions.test.ts`: 10 tests for role constants and `hasRole`.
|
||||
- `__tests__/security.test.ts`: 25 tests for `sanitize*`, `escapeHtml`, `checkRateLimit`, `isValidUuid`.
|
||||
- `__tests__/auth-helpers.test.ts`: 4 tests for `parseInput`.
|
||||
- Full suite: **202 tests, 7 files, all passing**.
|
||||
8. **Fixed vitest config**:
|
||||
- `vitest.setup.ts` had inline JSX in a `.ts` file (esbuild refuses). Replaced with `React.createElement`.
|
||||
- `vitest.config.ts` was picking up Playwright e2e specs. Added `include: ['__tests__/**/*.{test,spec}.{ts,tsx}']` and `exclude: ['e2e/**']`.
|
||||
9. **Added `scripts/README.md`** documenting the migration convention (canonical `schema.sql` + numbered incremental files, idempotency rules, security baseline, verification queries).
|
||||
|
||||
### Open items, priority order
|
||||
|
||||
1. **TypeScript drift between `@supabase/supabase-js` generics and our `Database` type**: 38 errors in `lib/actions/*` of the form `'X' is not assignable to parameter of type 'never'`. These existed in the original `lib/actions.ts` too (the build masks them via `next.config.mjs: typescript.ignoreBuildErrors: true`). Fix is to regenerate `types/database.ts` with the latest supabase CLI so the generated `Database` shape matches what `@supabase/ssr` expects.
|
||||
2. **103 app/ TypeScript errors** in client components (`comms-client.tsx`, `members-client.tsx`, etc). Pre-existing v0 codegen output; same root cause as item 1. Untouched in this pass to avoid UI regressions.
|
||||
3. **`getCurrentMember` was removed** from `lib/actions.ts` when I split it; no caller referenced it. If anything starts to need it, define a server action in `lib/actions/index.ts` or use `getAuthMember` from `lib/auth-helpers.ts`.
|
||||
4. **Pass 2 priority 1 (run `scripts/015_*.sql` on production) is still outstanding** until the user runs it.
|
||||
5. **`signUp` trigger metadata mismatch** remains documented but unfixed.
|
||||
6. **Stale docs**: `DB_SCHEMA_MAP.md` and `docs/ARCHITECTURE.md` say 4 default channels. Schema creates 3.
|
||||
|
||||
### Decisions pending
|
||||
|
||||
- Whether to regenerate `types/database.ts` via the Supabase CLI to clear the remaining 141 TS errors.
|
||||
- Whether to also run `next.config.mjs` without `typescript.ignoreBuildErrors: true` once types are clean.
|
||||
- Whether to add CI to run `pnpm vitest` and `pnpm exec tsc --noEmit` on every push.
|
||||
|
||||
### Files changed this pass
|
||||
|
||||
```
|
||||
Added:
|
||||
lib/permissions.ts
|
||||
lib/auth-helpers.ts
|
||||
lib/actions/tasks.ts
|
||||
lib/actions/projects.ts
|
||||
lib/actions/members.ts
|
||||
lib/actions/contacts.ts
|
||||
lib/actions/payments.ts
|
||||
lib/actions/knowledge-base.ts
|
||||
lib/actions/secrets.ts
|
||||
lib/actions/area-leads.ts
|
||||
lib/actions/settings.ts
|
||||
lib/actions/imports.ts
|
||||
lib/actions/index.ts
|
||||
__tests__/validations.test.ts
|
||||
__tests__/permissions.test.ts
|
||||
__tests__/security.test.ts
|
||||
__tests__/auth-helpers.test.ts
|
||||
scripts/README.md
|
||||
|
||||
Edited:
|
||||
lib/types.ts
|
||||
vitest.config.ts
|
||||
vitest.setup.ts
|
||||
|
||||
Removed:
|
||||
lib/actions.ts (replaced by lib/actions/ directory)
|
||||
app/auth/login/page.tsx (dead duplicate)
|
||||
components/app-shell.tsx (dead, unused)
|
||||
```
|
||||
|
||||
### How to verify locally
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm vitest run
|
||||
# expect: 202 tests passing across 7 files
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-13 (pass 2) — Application repairs
|
||||
|
||||
Branch: `main`
|
||||
Author: Claude (no commits made).
|
||||
|
||||
### What I did
|
||||
|
||||
1. **Fixed dashboard "Quick Chores" bug** (`app/(app)/dashboard/page.tsx:49`). The filter was `.neq('status','done')` but `completeTask()` writes `'completed'`. Completed tasks were leaking into the open list. Replaced with a positive filter on the open statuses.
|
||||
2. **Fixed signup copy**: said default channels were "general, announcements, random". Schema creates "general, announcements, ops". Aligned.
|
||||
3. **Fixed `lib/validations.ts` enum drift**: nine schemas had values that did not match the database. Now aligned: task type and recurrence, project status, member role, contact type, KB visibility, cash payment shape, settings, area lead.
|
||||
4. **Wired validation into high-risk server actions**: `createProject`, `updateProjectStatus`, `addMember`, `logCashPayment`, `linkPaymentToMember`. Other actions still pass raw `formData`; documented in `docs/AUDIT.md` section 4.4.
|
||||
5. **Patched privilege escalation in `space_members`**: added a `BEFORE UPDATE` trigger (`prevent_member_self_role_change`) that rejects self-updates of `role`, `tier`, `status`, `approved`, `has_card_access`, `space_id` unless the user is already admin/board/treasurer in that space. Applied to `schema.sql`. Shipped as incremental migration `scripts/015_prevent_member_self_role_change.sql` for existing databases. Run this on any production database immediately.
|
||||
6. **Removed duplicate auth functions** in `lib/actions.ts` (`signIn`, `signOut`, `getUser`). Canonical versions live in `lib/auth-actions.ts`. Verified no caller imports them from `actions.ts`.
|
||||
7. **Deleted dead scripts**: `fix-actions.js`, `fix-sidebar.js`, `fix-sidebar.mjs`, `patch-dashboard.mjs`. All had hardcoded `/vercel/share/v0-project` paths.
|
||||
8. **Synced `types/database.ts`** for `space_members.user_id` to `string | null` matching the schema fix from pass 1.
|
||||
|
||||
### Open items, priority order
|
||||
|
||||
1. **Run `scripts/015_prevent_member_self_role_change.sql` on production** before any external user can authenticate. Until then, any member can self-promote to admin via direct PostgREST.
|
||||
2. **`app/auth/login/page.tsx`** is a dead duplicate login page linking to non-existent `/auth/onboarding`. Delete the directory.
|
||||
3. **`components/app-shell.tsx`** is dead (no importer). Delete.
|
||||
4. **Wire validation into remaining actions** listed in `docs/AUDIT.md` 4.4 (updateMember, contacts, KB, secrets, area lead, settings, integrations, imports).
|
||||
5. **Secrets at rest**: still plain text.
|
||||
6. **`signUp` trigger metadata mismatch** is benign (trigger short-circuits) but should be removed or re-aligned. See `docs/AUDIT.md` 4.5.
|
||||
7. **Stale docs**: `DB_SCHEMA_MAP.md` and `docs/ARCHITECTURE.md` still say 4 default channels. Schema creates 3.
|
||||
|
||||
### Decisions pending
|
||||
|
||||
- Whether to wire validation into the remaining actions in the next session.
|
||||
- Whether to delete `app/auth/login/` and `components/app-shell.tsx` now.
|
||||
- Whether to regenerate `types/database.ts` properly via `supabase gen types` instead of the manual edit applied here.
|
||||
|
||||
### How to verify the security fix
|
||||
|
||||
```sql
|
||||
-- Connect to your Supabase project's SQL editor as a regular user (NOT service role):
|
||||
-- The following should now fail with "Members cannot change their own role..."
|
||||
UPDATE public.space_members SET role = 'admin' WHERE user_id = auth.uid();
|
||||
```
|
||||
|
||||
### Files changed this pass
|
||||
|
||||
```
|
||||
Edited:
|
||||
app/(app)/dashboard/page.tsx
|
||||
app/signup/page.tsx
|
||||
lib/validations.ts
|
||||
lib/actions.ts
|
||||
scripts/schema.sql
|
||||
types/database.ts
|
||||
docs/AUDIT.md
|
||||
docs/HANDOFF.md
|
||||
|
||||
Added:
|
||||
scripts/015_prevent_member_self_role_change.sql
|
||||
|
||||
Removed:
|
||||
scripts/fix-actions.js
|
||||
scripts/fix-sidebar.js
|
||||
scripts/fix-sidebar.mjs
|
||||
scripts/patch-dashboard.mjs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-13 (pass 1) — Deep audit + fresh-deploy hardening
|
||||
|
||||
Branch: `main`
|
||||
Author: Claude (per CLAUDE.md, no commits made this session).
|
||||
|
||||
### What I did
|
||||
|
||||
1. Wrote `CLAUDE.md` at the repo root. It is the standing brief: no Claude attribution in commits, no emojis, no em dashes, no AI tells, test-driven where practical, always document, handoff after long sessions, smart separation of concerns.
|
||||
2. Audited every source file. Findings in `docs/AUDIT.md`.
|
||||
3. Fixed three idempotency / fresh-deploy issues in `scripts/schema.sql`:
|
||||
- `space_members.user_id` is now nullable (offline members work).
|
||||
- Realtime `ALTER PUBLICATION` wrapped in `DO ... EXCEPTION` blocks.
|
||||
- All RLS policies preceded by `DROP POLICY IF EXISTS`.
|
||||
4. Added `scripts/014_member_user_id_nullable.sql` for upgrading existing databases.
|
||||
5. Added every missing deployment artifact: `Dockerfile`, `.dockerignore`, `docker-compose.yml`, `.do/app.yaml`, `.env.example`, `app/api/health/route.ts`.
|
||||
6. Updated `middleware.ts` to let `/api/health` through without auth.
|
||||
7. Updated `docs/DEPLOYMENT.md` to reference bundled artifacts instead of asking the user to write their own.
|
||||
|
||||
### What I did not touch
|
||||
|
||||
- Application logic (`lib/actions.ts`, `lib/auth-actions.ts`, all `*-client.tsx`).
|
||||
- UI components.
|
||||
- Tests.
|
||||
- Validation schemas (they are wrong in places, see audit section 4.1, but fixing them is a behavior change).
|
||||
|
||||
### Open items, in priority order
|
||||
|
||||
1. **Privilege escalation on `space_members.role`** (audit 4.11). A self-update via the `members_update` RLS policy lets a member rewrite their own row, including `role`. Fix before production.
|
||||
2. **Validation schema enum mismatches** (audit 4.1). Six schemas in `lib/validations.ts` reject valid database values. Wire schemas into actions and align the enums.
|
||||
3. **Dead code**: four `scripts/fix-*.{js,mjs}` files with hardcoded `/vercel/share` paths, and `components/app-shell.tsx` which no page imports. Delete on next cleanup.
|
||||
4. **Duplicate auth functions**: `lib/actions.ts` lines 30 to 47 redeclare `signIn`, `signOut`, `getUser` that already live in `lib/auth-actions.ts`. Consolidate.
|
||||
5. **Secrets at rest**: `secrets.value` and `integrations.config` are plain text. Encrypt before production.
|
||||
6. **UI-only features**: OAuth buttons, CSV import processor, webhook endpoint, payment platform syncs (except PayPal), email notifications. See audit 4.7.
|
||||
7. **`package.json` name is `"my-project"`**. Cosmetic.
|
||||
8. **Older docs reference 4 default chat channels**; schema creates 3 (`general`, `announcements`, `ops`). `DB_SCHEMA_MAP.md` and `docs/ARCHITECTURE.md` are stale on this point.
|
||||
|
||||
### Decisions pending from the user
|
||||
|
||||
- Whether to delete dead scripts now (low risk) or in a separate change.
|
||||
- Whether to apply `scripts/014_member_user_id_nullable.sql` against the live database (production may already have user_id nullable per `DB_SCHEMA_MAP.md`, this migration is idempotent).
|
||||
- Whether to wire validation schemas into existing server actions in the next session.
|
||||
|
||||
### How to verify the fresh-deploy fixes locally
|
||||
|
||||
```bash
|
||||
# 1. Spin up a fresh Supabase project at supabase.com
|
||||
# 2. In Supabase SQL Editor, paste scripts/schema.sql and run it. Should report no errors.
|
||||
# 3. Re-run scripts/schema.sql. Should still report no errors (idempotent).
|
||||
# 4. Copy .env.example to .env.local, fill in Supabase URL + anon + service role.
|
||||
pnpm install
|
||||
pnpm dev
|
||||
# 5. Visit http://localhost:3000, sign up, create a space, confirm /dashboard renders.
|
||||
# 6. curl http://localhost:3000/api/health -> {"status":"ok"}
|
||||
```
|
||||
|
||||
### How to verify the Docker build
|
||||
|
||||
```bash
|
||||
# Requires .env.local populated.
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
curl http://localhost:3000/api/health
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
### Files changed this session
|
||||
|
||||
```
|
||||
Edited:
|
||||
middleware.ts
|
||||
scripts/schema.sql
|
||||
docs/DEPLOYMENT.md
|
||||
|
||||
Added:
|
||||
CLAUDE.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
.do/app.yaml
|
||||
.env.example
|
||||
app/api/health/route.ts
|
||||
scripts/014_member_user_id_nullable.sql
|
||||
docs/AUDIT.md
|
||||
docs/HANDOFF.md
|
||||
```
|
||||
|
||||
No git commits were made. The working tree carries all changes; the user decides what to commit and when.
|
||||
@@ -0,0 +1,203 @@
|
||||
# Local development quickstart
|
||||
|
||||
Fresh-clone to running app in under five minutes. Uses the Supabase CLI to spin up a complete local stack (Postgres + Auth + PostgREST + Realtime + Storage + Studio) in Docker. No Supabase account needed.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- macOS, Linux, or WSL2 on Windows.
|
||||
- **Docker** (running). `docker info` should print a server section.
|
||||
- **Homebrew** on macOS, or follow the [Supabase CLI install docs](https://supabase.com/docs/guides/cli/getting-started) for your OS.
|
||||
- **Node 20+** and **pnpm 9+**. nvm works fine.
|
||||
|
||||
## 1. Install the Supabase CLI
|
||||
|
||||
```bash
|
||||
brew install supabase/tap/supabase
|
||||
supabase --version # 1.x or 2.x
|
||||
```
|
||||
|
||||
## 2. Clone and install
|
||||
|
||||
```bash
|
||||
git clone <repo-url>
|
||||
cd hackerspace-management
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## 3. Initialize and start the local Supabase stack
|
||||
|
||||
```bash
|
||||
supabase init # writes supabase/config.toml; safe if it already exists
|
||||
supabase start
|
||||
```
|
||||
|
||||
First run pulls a few GB of images and takes 1–3 minutes. Subsequent starts are seconds.
|
||||
|
||||
When it finishes, the CLI prints something like:
|
||||
|
||||
```
|
||||
API URL: http://127.0.0.1:54321
|
||||
GraphQL URL: http://127.0.0.1:54321/graphql/v1
|
||||
S3 Storage URL: http://127.0.0.1:54321/storage/v1/s3
|
||||
DB URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres
|
||||
Studio URL: http://127.0.0.1:54323
|
||||
Inbucket URL: http://127.0.0.1:54324
|
||||
JWT secret: super-secret-jwt-token-with-at-least-32-characters-long
|
||||
anon key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
service_role key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
Save the **anon key** and **service_role key**. You can re-print them anytime with `supabase status`.
|
||||
|
||||
The DB is at `postgresql://postgres:postgres@127.0.0.1:54322/postgres`. Studio (Supabase dashboard, locally) is at `http://127.0.0.1:54323`. The Inbucket email catcher is at `http://127.0.0.1:54324` — every email the app would send shows up there.
|
||||
|
||||
## 4. Apply the schema and the seed
|
||||
|
||||
```bash
|
||||
# Schema (canonical, covers every migration through 019)
|
||||
psql "postgresql://postgres:postgres@127.0.0.1:54322/postgres" -f scripts/schema.sql
|
||||
|
||||
# Optional but recommended for local: load a demo space with members, tasks,
|
||||
# proposals, an incident, a policy, and activity-log entries.
|
||||
psql "postgresql://postgres:postgres@127.0.0.1:54322/postgres" -f scripts/seed.sql
|
||||
```
|
||||
|
||||
You can verify with Studio: open `http://127.0.0.1:54323`, switch to the `public` schema, and see 18 tables including `proposals`, `incidents`, `policies`.
|
||||
|
||||
## 5. Generate TypeScript types from the live local schema
|
||||
|
||||
```bash
|
||||
supabase gen types typescript --local > types/database.ts
|
||||
```
|
||||
|
||||
This clears the masked TS errors that `next.config.mjs: typescript.ignoreBuildErrors: true` was hiding. After this you can drop that ignoreBuildErrors flag and any future schema drift surfaces in CI.
|
||||
|
||||
## 6. Write `.env.local`
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Edit `.env.local`:
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=<paste anon key from step 3>
|
||||
SUPABASE_SERVICE_ROLE_KEY=<paste service_role key from step 3>
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
NODE_ENV=development
|
||||
```
|
||||
|
||||
## 7. Run the dev server
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open `http://localhost:3000`. Health check: `curl http://localhost:3000/api/health` returns `{"status":"ok"}`.
|
||||
|
||||
## 8. Sign up as a real user and join the demo space
|
||||
|
||||
1. Visit `/signup`.
|
||||
2. Pick **Join a Space** and enter invite code: `DEMO-2026-TEST`.
|
||||
3. Fill in your email / password.
|
||||
4. Local Supabase is configured to skip email confirmation by default, so signup takes you straight to `/dashboard`.
|
||||
5. The dashboard now shows the seeded activity. Visit `/proposals`, `/incidents`, `/policies`, `/financials` to see the full feature surface.
|
||||
|
||||
If you want admin permissions for the demo: connect to the DB (or via Studio) and run:
|
||||
|
||||
```sql
|
||||
UPDATE public.space_members
|
||||
SET role = 'admin'
|
||||
WHERE email = 'your-email@example.com';
|
||||
```
|
||||
|
||||
## Common dev tasks
|
||||
|
||||
### Wipe and re-seed
|
||||
|
||||
```bash
|
||||
supabase db reset # drops & re-runs the database
|
||||
psql "postgresql://postgres:postgres@127.0.0.1:54322/postgres" -f scripts/schema.sql
|
||||
psql "postgresql://postgres:postgres@127.0.0.1:54322/postgres" -f scripts/seed.sql
|
||||
```
|
||||
|
||||
### Apply a new incremental migration
|
||||
|
||||
```bash
|
||||
psql "postgresql://postgres:postgres@127.0.0.1:54322/postgres" -f scripts/0NN_my_change.sql
|
||||
```
|
||||
|
||||
PostgREST auto-refreshes on schema changes locally; no restart needed.
|
||||
|
||||
### Run tests
|
||||
|
||||
```bash
|
||||
pnpm vitest run # 259 tests across 8 files; no DB needed
|
||||
pnpm exec playwright test # E2E (Playwright will spin up the dev server)
|
||||
```
|
||||
|
||||
### Type check
|
||||
|
||||
```bash
|
||||
pnpm exec tsc --noEmit
|
||||
```
|
||||
|
||||
After step 5, this should be clean. If it isn't, regenerate types.
|
||||
|
||||
### Stop the local stack
|
||||
|
||||
```bash
|
||||
supabase stop
|
||||
```
|
||||
|
||||
Containers are stopped but volumes persist. To nuke them entirely: `supabase stop --no-backup` then `supabase start` for a fresh slate.
|
||||
|
||||
### Tail logs
|
||||
|
||||
```bash
|
||||
supabase logs # all services
|
||||
supabase logs --type api
|
||||
supabase logs --type db
|
||||
```
|
||||
|
||||
### Run the proposal-expiry job manually
|
||||
|
||||
```sql
|
||||
SELECT public.expire_proposals();
|
||||
-- returns the number of proposals flipped from 'open' to 'decided' or 'expired'
|
||||
```
|
||||
|
||||
In production you'd schedule this via pg_cron (the schema's migration 019 sets this up automatically when pg_cron is available) or via a Next.js cron endpoint guarded by a `CRON_SECRET`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `supabase start` hangs or fails
|
||||
|
||||
Make sure Docker Desktop is open and `docker info` works. Some corporate VPNs interfere with the local images; turn the VPN off, run `supabase start`, then turn it back on.
|
||||
|
||||
### "relation does not exist" when the app runs
|
||||
|
||||
You skipped step 4. Apply `scripts/schema.sql`.
|
||||
|
||||
### "Invalid API key" toasts in the app
|
||||
|
||||
The anon key in `.env.local` doesn't match `supabase status`. `supabase stop --no-backup && supabase start` will regenerate the keys cleanly; paste the new ones into `.env.local`.
|
||||
|
||||
### Emails aren't arriving
|
||||
|
||||
Local Supabase routes outbound mail to Inbucket. Open `http://127.0.0.1:54324`.
|
||||
|
||||
### Signup says "Database error saving new user"
|
||||
|
||||
The `handle_space_signup` trigger fired but couldn't insert the space_members row. Almost always because `space_id` metadata was missing. The app's signup flow handles this correctly via the admin client; if you're testing direct `auth.users` inserts, that path doesn't write a space_members row by design.
|
||||
|
||||
### Realtime chat doesn't refresh
|
||||
|
||||
Confirm `comms_messages` and `comms_channels` are in `supabase_realtime`:
|
||||
|
||||
```sql
|
||||
SELECT tablename FROM pg_publication_tables WHERE pubname = 'supabase_realtime';
|
||||
```
|
||||
|
||||
If they aren't, re-run `scripts/schema.sql` (Section 8 handles it idempotently).
|
||||
-905
@@ -1,905 +0,0 @@
|
||||
'use server'
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Database } from '@/types/database'
|
||||
import {
|
||||
createTaskSchema,
|
||||
taskIdSchema,
|
||||
createProjectSchema,
|
||||
updateProjectStatusSchema,
|
||||
addMemberSchema,
|
||||
updateMemberSchema,
|
||||
createContactSchema,
|
||||
updateContactSchema,
|
||||
createKbEntrySchema,
|
||||
updateKbEntrySchema,
|
||||
createSecretSchema,
|
||||
logCashPaymentSchema,
|
||||
linkPaymentSchema,
|
||||
updateSpaceSettingsSchema,
|
||||
saveIntegrationSchema,
|
||||
upsertAreaLeadSchema,
|
||||
uuidSchema,
|
||||
} from '@/lib/validations'
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function signIn(email: string, password: string) {
|
||||
const supabase = await createClient()
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password })
|
||||
if (error) return { error: error.message }
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function signOut() {
|
||||
const supabase = await createClient()
|
||||
await supabase.auth.signOut()
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
export async function getUser() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
return user
|
||||
}
|
||||
|
||||
// ─── Helper: get current member (any non-inactive status) ────────────────────
|
||||
async function getMember(supabase: SupabaseClient<Database>, userId: string) {
|
||||
const { data } = await supabase
|
||||
.from('space_members')
|
||||
.select('space_id, role, display_name')
|
||||
.eq('user_id', userId)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.single()
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getCurrentMember() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return null
|
||||
const { data } = await supabase
|
||||
.from('space_members')
|
||||
.select('*, spaces(*)')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['current', 'unverified', 'late'])
|
||||
.single()
|
||||
return data
|
||||
}
|
||||
|
||||
// ─── Tasks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createTask(formData: {
|
||||
title: string
|
||||
description?: string
|
||||
type: string
|
||||
area?: string
|
||||
recurrence?: string
|
||||
due_date?: string
|
||||
}) {
|
||||
// Validate input
|
||||
const parsed = createTaskSchema.safeParse(formData)
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.errors[0]?.message || 'Invalid input' }
|
||||
}
|
||||
const input = parsed.data
|
||||
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { data, error } = await supabase.from('tasks').insert({
|
||||
space_id: member.space_id,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
task_type: input.type || 'task',
|
||||
area: input.area,
|
||||
recurrence: input.recurrence || 'none',
|
||||
due_date: input.due_date || null,
|
||||
status: 'open',
|
||||
requested_by: user.id,
|
||||
requested_by_name: member.display_name,
|
||||
}).select().single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
// Log activity
|
||||
await supabase.from('activity_log').insert({
|
||||
space_id: member.space_id,
|
||||
user_id: user.id,
|
||||
display_name: member.display_name,
|
||||
action: 'created',
|
||||
entity_type: 'task',
|
||||
entity_id: data.id,
|
||||
details: formData.title,
|
||||
})
|
||||
|
||||
revalidatePath('/tasks')
|
||||
revalidatePath('/dashboard')
|
||||
return { data }
|
||||
}
|
||||
|
||||
export async function claimTask(taskId: string) {
|
||||
const idResult = taskIdSchema.safeParse(taskId)
|
||||
if (!idResult.success) return { error: 'Invalid task ID' }
|
||||
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { error } = await supabase.from('tasks').update({
|
||||
claimed_by: user.id,
|
||||
claimed_by_name: member.display_name,
|
||||
status: 'claimed',
|
||||
}).eq('id', idResult.data).eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await supabase.from('activity_log').insert({
|
||||
space_id: member.space_id,
|
||||
user_id: user.id,
|
||||
display_name: member.display_name,
|
||||
action: 'claimed',
|
||||
entity_type: 'task',
|
||||
entity_id: taskId,
|
||||
})
|
||||
|
||||
revalidatePath('/tasks')
|
||||
revalidatePath('/dashboard')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function completeTask(taskId: string) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { error } = await supabase.from('tasks').update({
|
||||
status: 'completed',
|
||||
completed_at: new Date().toISOString(),
|
||||
last_done_at: new Date().toISOString(),
|
||||
}).eq('id', taskId).eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await supabase.from('activity_log').insert({
|
||||
space_id: member.space_id,
|
||||
user_id: user.id,
|
||||
display_name: member.display_name,
|
||||
action: 'completed',
|
||||
entity_type: 'task',
|
||||
entity_id: taskId,
|
||||
})
|
||||
|
||||
revalidatePath('/tasks')
|
||||
revalidatePath('/dashboard')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function deleteTask(taskId: string) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { error } = await supabase.from('tasks').delete()
|
||||
.eq('id', taskId).eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/tasks')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ─── Projects ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createProject(formData: {
|
||||
title: string
|
||||
description?: string
|
||||
area?: string
|
||||
tags?: string[]
|
||||
due_date?: string
|
||||
}) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { data, error } = await supabase.from('projects').insert({
|
||||
space_id: member.space_id,
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
area: formData.area,
|
||||
tags: formData.tags ?? [],
|
||||
due_date: formData.due_date || null,
|
||||
status: 'backlog',
|
||||
}).select().single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await supabase.from('activity_log').insert({
|
||||
space_id: member.space_id,
|
||||
user_id: user.id,
|
||||
display_name: member.display_name,
|
||||
action: 'created',
|
||||
entity_type: 'project',
|
||||
entity_id: data.id,
|
||||
details: formData.title,
|
||||
})
|
||||
|
||||
revalidatePath('/projects')
|
||||
return { data }
|
||||
}
|
||||
|
||||
export async function updateProjectStatus(projectId: string, status: string) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { error } = await supabase.from('projects').update({ status, updated_at: new Date().toISOString() })
|
||||
.eq('id', projectId).eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/projects')
|
||||
revalidatePath('/dashboard')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function deleteProject(projectId: string) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { error } = await supabase.from('projects').delete()
|
||||
.eq('id', projectId).eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/projects')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ─── Members ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function addMember(formData: {
|
||||
display_name: string
|
||||
email: string
|
||||
phone?: string
|
||||
handle?: string
|
||||
tier: string
|
||||
role: string
|
||||
joined_at?: string
|
||||
has_card_access?: boolean
|
||||
}) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: self } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!self || (self.role !== 'admin' && self.role !== 'board')) return { error: 'Admin access required' }
|
||||
|
||||
const { data, error } = await supabase.from('space_members').insert({
|
||||
space_id: self.space_id,
|
||||
display_name: formData.display_name,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
handle: formData.handle,
|
||||
tier: formData.tier,
|
||||
role: formData.role,
|
||||
status: 'current',
|
||||
approved: true,
|
||||
joined_at: formData.joined_at || new Date().toISOString(),
|
||||
has_card_access: formData.has_card_access ?? false,
|
||||
}).select().single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/members')
|
||||
return { data }
|
||||
}
|
||||
|
||||
export async function updateMember(memberId: string, updates: {
|
||||
display_name?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
handle?: string
|
||||
tier?: string
|
||||
role?: string
|
||||
status?: string
|
||||
has_card_access?: boolean
|
||||
payment_status?: string
|
||||
payment_note?: string
|
||||
}) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: self } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!self || (self.role !== 'admin' && self.role !== 'board')) return { error: 'Admin access required' }
|
||||
|
||||
const { error } = await supabase.from('space_members').update(updates)
|
||||
.eq('id', memberId).eq('space_id', self.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/members')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function approveMember(memberId: string) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: self } = await supabase
|
||||
.from('space_members').select('space_id, role, display_name').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!self || (self.role !== 'admin' && self.role !== 'board')) return { error: 'Admin access required' }
|
||||
|
||||
const { error } = await supabase.from('space_members').update({ status: 'current', approved: true })
|
||||
.eq('id', memberId).eq('space_id', self.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await supabase.from('activity_log').insert({
|
||||
space_id: self.space_id,
|
||||
user_id: user.id,
|
||||
display_name: self.display_name,
|
||||
action: 'approved',
|
||||
entity_type: 'member',
|
||||
entity_id: memberId,
|
||||
})
|
||||
|
||||
revalidatePath('/members')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function removeMember(memberId: string) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: self } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!self || self.role !== 'admin') return { error: 'Admin access required' }
|
||||
|
||||
const { error } = await supabase.from('space_members').delete()
|
||||
.eq('id', memberId).eq('space_id', self.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/members')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ─── Contacts ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createContact(formData: {
|
||||
name: string
|
||||
contact_type: string
|
||||
email?: string
|
||||
phone?: string
|
||||
details?: string
|
||||
note?: string
|
||||
group_label?: string
|
||||
tags?: string[]
|
||||
}) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const code = formData.name.slice(0, 3).toUpperCase() + Math.floor(Math.random() * 900 + 100)
|
||||
|
||||
const { data, error } = await supabase.from('contacts').insert({
|
||||
space_id: member.space_id,
|
||||
name: formData.name,
|
||||
contact_type: formData.contact_type,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
details: formData.details || formData.note,
|
||||
note: formData.note,
|
||||
group_label: formData.group_label,
|
||||
tags: formData.tags ?? [],
|
||||
code,
|
||||
}).select().single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/contacts')
|
||||
return { data }
|
||||
}
|
||||
|
||||
export async function updateContact(contactId: string, updates: {
|
||||
name?: string
|
||||
contact_type?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
details?: string
|
||||
note?: string
|
||||
group_label?: string
|
||||
tags?: string[]
|
||||
}) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { error } = await supabase.from('contacts').update({ ...updates, updated_at: new Date().toISOString() })
|
||||
.eq('id', contactId).eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/contacts')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function deleteContact(contactId: string) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const member = await getMember(supabase, user.id)
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { error } = await supabase.from('contacts').delete()
|
||||
.eq('id', contactId).eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/contacts')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ─── Payments ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function logCashPayment(formData: {
|
||||
amount: number
|
||||
from_note: string
|
||||
member_id?: string
|
||||
transaction_date?: string
|
||||
}) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role, display_name').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || (member.role !== 'admin' && member.role !== 'board' && member.role !== 'treasurer')) {
|
||||
return { error: 'Treasurer access required' }
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.from('payments').insert({
|
||||
space_id: member.space_id,
|
||||
platform: 'cash',
|
||||
amount: formData.amount,
|
||||
from_identifier: 'Cash',
|
||||
from_note: formData.from_note,
|
||||
member_id: formData.member_id || null,
|
||||
link_status: formData.member_id ? 'linked' : 'unlinked',
|
||||
transaction_date: formData.transaction_date || new Date().toISOString(),
|
||||
}).select().single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
// Update member's last_paid_at if linked
|
||||
if (formData.member_id) {
|
||||
await supabase.from('space_members').update({
|
||||
last_paid_at: formData.transaction_date || new Date().toISOString(),
|
||||
payment_status: 'current',
|
||||
}).eq('id', formData.member_id).eq('space_id', member.space_id)
|
||||
}
|
||||
|
||||
await supabase.from('activity_log').insert({
|
||||
space_id: member.space_id,
|
||||
user_id: user.id,
|
||||
display_name: member.display_name,
|
||||
action: 'logged',
|
||||
entity_type: 'payment',
|
||||
entity_id: data.id,
|
||||
details: `$${formData.amount} cash — ${formData.from_note}`,
|
||||
})
|
||||
|
||||
revalidatePath('/payments')
|
||||
revalidatePath('/dashboard')
|
||||
return { data }
|
||||
}
|
||||
|
||||
export async function linkPaymentToMember(paymentId: string, memberId: string) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: self } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!self || (self.role !== 'admin' && self.role !== 'board' && self.role !== 'treasurer')) {
|
||||
return { error: 'Treasurer access required' }
|
||||
}
|
||||
|
||||
// Get payment to find the amount and date
|
||||
const { data: payment } = await supabase
|
||||
.from('payments').select('amount, transaction_date').eq('id', paymentId).single()
|
||||
|
||||
const { error } = await supabase.from('payments').update({
|
||||
member_id: memberId,
|
||||
link_status: 'linked',
|
||||
}).eq('id', paymentId).eq('space_id', self.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
// Update member payment status
|
||||
if (payment) {
|
||||
await supabase.from('space_members').update({
|
||||
last_paid_at: payment.transaction_date,
|
||||
payment_status: 'current',
|
||||
}).eq('id', memberId).eq('space_id', self.space_id)
|
||||
}
|
||||
|
||||
revalidatePath('/payments')
|
||||
revalidatePath('/members')
|
||||
revalidatePath('/dashboard')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function importPaymentsCsv(rows: Array<{
|
||||
platform: string
|
||||
amount: number
|
||||
from_identifier: string
|
||||
from_note?: string
|
||||
transaction_date: string
|
||||
}>) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || (member.role !== 'admin' && member.role !== 'board' && member.role !== 'treasurer')) {
|
||||
return { error: 'Treasurer access required' }
|
||||
}
|
||||
|
||||
const inserts = rows.map(r => ({
|
||||
space_id: member.space_id,
|
||||
platform: r.platform as any,
|
||||
amount: r.amount,
|
||||
from_identifier: r.from_identifier,
|
||||
from_note: r.from_note,
|
||||
link_status: 'unlinked' as const,
|
||||
transaction_date: r.transaction_date,
|
||||
}))
|
||||
|
||||
const { data, error } = await supabase.from('payments').insert(inserts).select()
|
||||
if (error) return { error: error.message }
|
||||
|
||||
revalidatePath('/payments')
|
||||
revalidatePath('/dashboard')
|
||||
return { data, count: data.length }
|
||||
}
|
||||
|
||||
// ─── Knowledge Base ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function createKbEntry(formData: {
|
||||
title: string
|
||||
content: string
|
||||
description?: string
|
||||
area?: string
|
||||
visibility?: string
|
||||
is_pinned?: boolean
|
||||
tags?: string[]
|
||||
icon?: string
|
||||
}) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, display_name, handle').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { data, error } = await supabase.from('knowledge_base').insert({
|
||||
space_id: member.space_id,
|
||||
title: formData.title,
|
||||
content: formData.content,
|
||||
area: formData.area,
|
||||
visibility: formData.visibility || 'all_members',
|
||||
is_pinned: formData.is_pinned ?? false,
|
||||
tags: formData.tags ?? [],
|
||||
icon: formData.icon,
|
||||
updated_by_id: user.id,
|
||||
updated_by_name: member.display_name,
|
||||
}).select().single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/ops')
|
||||
return { data }
|
||||
}
|
||||
|
||||
export async function updateKbEntry(entryId: string, updates: {
|
||||
title?: string
|
||||
content?: string
|
||||
area?: string
|
||||
visibility?: string
|
||||
is_pinned?: boolean
|
||||
tags?: string[]
|
||||
}) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, display_name').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
// Only pass columns that actually exist in the DB
|
||||
const { is_pinned, ...rest } = updates
|
||||
const { error } = await supabase.from('knowledge_base').update({
|
||||
...rest,
|
||||
...(is_pinned !== undefined ? { is_pinned } : {}),
|
||||
updated_by_id: user.id,
|
||||
updated_by_name: member.display_name,
|
||||
updated_at: new Date().toISOString(),
|
||||
}).eq('id', entryId).eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/ops')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function deleteKbEntry(entryId: string) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member) return { error: 'No active membership' }
|
||||
|
||||
const { error } = await supabase.from('knowledge_base').delete()
|
||||
.eq('id', entryId).eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/ops')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ─── Secrets ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createSecret(formData: {
|
||||
title: string
|
||||
value: string
|
||||
description?: string
|
||||
area?: string
|
||||
icon?: string
|
||||
}) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || (member.role !== 'admin' && member.role !== 'board')) return { error: 'Admin access required' }
|
||||
|
||||
const { data, error } = await supabase.from('secrets').insert({
|
||||
space_id: member.space_id,
|
||||
title: formData.title,
|
||||
label: formData.title,
|
||||
value: formData.value,
|
||||
description: formData.description,
|
||||
area: formData.area,
|
||||
icon: formData.icon,
|
||||
}).select().single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/ops')
|
||||
return { data }
|
||||
}
|
||||
|
||||
export async function deleteSecret(secretId: string) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
|
||||
|
||||
const { error } = await supabase.from('secrets').delete()
|
||||
.eq('id', secretId).eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/ops')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ─── Area Leads ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function upsertAreaLead(formData: {
|
||||
area_code: string
|
||||
area_name: string
|
||||
lead_id?: string
|
||||
lead_handle?: string
|
||||
status?: string
|
||||
}) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || (member.role !== 'admin' && member.role !== 'board')) return { error: 'Admin access required' }
|
||||
|
||||
const { data, error } = await supabase.from('area_leads').upsert({
|
||||
space_id: member.space_id,
|
||||
area_code: formData.area_code,
|
||||
area_name: formData.area_name,
|
||||
lead_id: formData.lead_id || null,
|
||||
lead_handle: formData.lead_handle || null,
|
||||
status: formData.status || 'active',
|
||||
updated_at: new Date().toISOString(),
|
||||
}, { onConflict: 'space_id,area_code' }).select().single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/ops')
|
||||
return { data }
|
||||
}
|
||||
|
||||
// ─── Settings ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function updateSpaceSettings(updates: {
|
||||
name?: string
|
||||
slug?: string
|
||||
city?: string
|
||||
require_approval?: boolean
|
||||
public_member_directory?: boolean
|
||||
}) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
|
||||
|
||||
const { error } = await supabase.from('spaces').update({
|
||||
...updates,
|
||||
updated_at: new Date().toISOString(),
|
||||
}).eq('id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/settings')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function rotateWebhookSecret() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
|
||||
|
||||
// Generate a new webhook secret
|
||||
const newSecret = 'whsec_' + Array.from(crypto.getRandomValues(new Uint8Array(24)))
|
||||
.map(b => b.toString(16).padStart(2, '0')).join('')
|
||||
|
||||
const { error } = await supabase.from('spaces').update({ webhook_secret: newSecret, updated_at: new Date().toISOString() })
|
||||
.eq('id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/settings')
|
||||
return { secret: newSecret }
|
||||
}
|
||||
|
||||
export async function saveIntegration(platform: string, config: Record<string, string>) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
|
||||
|
||||
// Mask sensitive values — store but indicate they're set
|
||||
const safeConfig: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
safeConfig[key] = value ? value : ''
|
||||
safeConfig[`${key}_set`] = value ? 'true' : 'false'
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('integrations').upsert({
|
||||
space_id: member.space_id,
|
||||
platform,
|
||||
name: platform,
|
||||
is_connected: Object.values(config).some(v => v.length > 0),
|
||||
config: safeConfig,
|
||||
updated_at: new Date().toISOString(),
|
||||
}, { onConflict: 'space_id,platform' })
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/settings')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function disconnectIntegration(platform: string) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || member.role !== 'admin') return { error: 'Admin access required' }
|
||||
|
||||
const { error } = await supabase.from('integrations').update({
|
||||
is_connected: false,
|
||||
config: {},
|
||||
updated_at: new Date().toISOString(),
|
||||
}).eq('space_id', member.space_id).eq('platform', platform)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/settings')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
// ─── Import Members ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function importMembers(rows: Array<{
|
||||
display_name: string
|
||||
email: string
|
||||
phone?: string
|
||||
tier?: string
|
||||
joined_at?: string
|
||||
last_paid_at?: string
|
||||
has_card_access?: boolean
|
||||
}>) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return { error: 'Not authenticated' }
|
||||
|
||||
const { data: member } = await supabase
|
||||
.from('space_members').select('space_id, role').eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()
|
||||
if (!member || (member.role !== 'admin' && member.role !== 'board')) return { error: 'Admin access required' }
|
||||
|
||||
const inserts = rows.map(r => ({
|
||||
space_id: member.space_id,
|
||||
display_name: r.display_name,
|
||||
email: r.email,
|
||||
phone: r.phone,
|
||||
tier: r.tier || 'basic',
|
||||
role: 'member',
|
||||
status: 'current',
|
||||
approved: true,
|
||||
joined_at: r.joined_at || new Date().toISOString(),
|
||||
last_paid_at: r.last_paid_at || null,
|
||||
has_card_access: r.has_card_access ?? false,
|
||||
}))
|
||||
|
||||
// Upsert by email to avoid duplicates
|
||||
const { data, error } = await supabase.from('space_members').upsert(inserts, {
|
||||
onConflict: 'space_id,email',
|
||||
ignoreDuplicates: false,
|
||||
}).select()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/members')
|
||||
return { data, count: data.length }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
|
||||
import { ADMIN_ROLES } from '@/lib/permissions'
|
||||
import { upsertAreaLeadSchema } from '@/lib/validations'
|
||||
|
||||
export async function upsertAreaLead(formData: {
|
||||
area_code: string
|
||||
area_name: string
|
||||
lead_id?: string
|
||||
lead_handle?: string
|
||||
status?: string
|
||||
}) {
|
||||
const v = parseInput(upsertAreaLeadSchema, formData)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('area_leads')
|
||||
.upsert(
|
||||
{
|
||||
space_id: member.space_id,
|
||||
area_code: v.data.area_code,
|
||||
area_name: v.data.area_name,
|
||||
lead_id: v.data.lead_id ?? null,
|
||||
lead_handle: v.data.lead_handle ?? null,
|
||||
status: v.data.status,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: 'space_id,area_code' },
|
||||
)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/ops')
|
||||
return { data }
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
|
||||
import { ADMIN_ROLES } from '@/lib/permissions'
|
||||
import { createAreaSchema, updateAreaSchema, uuidSchema } from '@/lib/validations'
|
||||
|
||||
/**
|
||||
* Add a new area to the current space. Admin / board only.
|
||||
* `code` is the stable identifier (slug); `name` is what shows up in
|
||||
* dropdowns. Renames change the name but keep the code, so existing
|
||||
* tasks tagged with the old name will not auto-rename — admins re-tag
|
||||
* if they care.
|
||||
*/
|
||||
export async function createArea(formData: {
|
||||
code: string
|
||||
name: string
|
||||
icon?: string | null
|
||||
sort_order?: number
|
||||
}) {
|
||||
const v = parseInput(createAreaSchema, formData)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin or board access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('space_areas')
|
||||
.insert({
|
||||
space_id: member.space_id,
|
||||
code: v.data.code,
|
||||
name: v.data.name,
|
||||
icon: v.data.icon ?? null,
|
||||
sort_order: v.data.sort_order ?? 100,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
revalidatePath('/settings')
|
||||
revalidatePath('/tasks')
|
||||
revalidatePath('/projects')
|
||||
revalidatePath('/ops')
|
||||
return { data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit an area's name, icon, sort order, or archive state.
|
||||
*/
|
||||
export async function updateArea(formData: {
|
||||
areaId: string
|
||||
name?: string
|
||||
icon?: string | null
|
||||
sort_order?: number
|
||||
is_archived?: boolean
|
||||
}) {
|
||||
const v = parseInput(updateAreaSchema, formData)
|
||||
if (!v.ok) return { error: v.error }
|
||||
const { areaId, ...patch } = v.data
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin or board access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('space_areas')
|
||||
.update(patch)
|
||||
.eq('id', areaId)
|
||||
.eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
revalidatePath('/settings')
|
||||
revalidatePath('/tasks')
|
||||
revalidatePath('/projects')
|
||||
revalidatePath('/ops')
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an area entirely. Admin-only. Prefer archiving via
|
||||
* `updateArea({ is_archived: true })` unless the area has never been used.
|
||||
*/
|
||||
export async function deleteArea(areaId: string) {
|
||||
const v = parseInput(uuidSchema, areaId)
|
||||
if (!v.ok) return { error: 'Invalid area ID' }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ['admin'], 'Admin access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('space_areas')
|
||||
.delete()
|
||||
.eq('id', v.data)
|
||||
.eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
revalidatePath('/settings')
|
||||
revalidatePath('/tasks')
|
||||
revalidatePath('/projects')
|
||||
revalidatePath('/ops')
|
||||
return { success: true as const }
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { requireMember, parseInput } from '@/lib/auth-helpers'
|
||||
import {
|
||||
createContactSchema,
|
||||
updateContactSchema,
|
||||
uuidSchema,
|
||||
} from '@/lib/validations'
|
||||
|
||||
function generateContactCode(name: string): string {
|
||||
const prefix = name.slice(0, 3).toUpperCase().replace(/[^A-Z]/g, 'X').padEnd(3, 'X')
|
||||
const suffix = Math.floor(Math.random() * 900 + 100).toString()
|
||||
return `${prefix}${suffix}`
|
||||
}
|
||||
|
||||
export async function createContact(formData: {
|
||||
name: string
|
||||
contact_type: string
|
||||
email?: string
|
||||
phone?: string
|
||||
details?: string
|
||||
note?: string
|
||||
group_label?: string
|
||||
tags?: string[]
|
||||
}) {
|
||||
const v = parseInput(createContactSchema, formData)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('contacts')
|
||||
.insert({
|
||||
space_id: member.space_id,
|
||||
name: v.data.name,
|
||||
contact_type: v.data.contact_type,
|
||||
email: v.data.email ?? null,
|
||||
phone: v.data.phone ?? null,
|
||||
details: v.data.details ?? v.data.note ?? null,
|
||||
note: v.data.note ?? null,
|
||||
group_label: v.data.group_label ?? null,
|
||||
tags: v.data.tags ?? [],
|
||||
code: v.data.code ?? generateContactCode(v.data.name),
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/contacts')
|
||||
return { data }
|
||||
}
|
||||
|
||||
export async function updateContact(
|
||||
contactId: string,
|
||||
updates: {
|
||||
name?: string
|
||||
contact_type?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
details?: string
|
||||
note?: string
|
||||
group_label?: string
|
||||
tags?: string[]
|
||||
},
|
||||
) {
|
||||
const v = parseInput(updateContactSchema, { contactId, ...updates })
|
||||
if (!v.ok) return { error: v.error }
|
||||
const { contactId: id, ...patch } = v.data
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('contacts')
|
||||
.update({ ...patch, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/contacts')
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
export async function deleteContact(contactId: string) {
|
||||
const v = parseInput(uuidSchema, contactId)
|
||||
if (!v.ok) return { error: 'Invalid contact ID' }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('contacts')
|
||||
.delete()
|
||||
.eq('id', v.data)
|
||||
.eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/contacts')
|
||||
return { success: true as const }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { requireMemberWithRole } from '@/lib/auth-helpers'
|
||||
import { ADMIN_ROLES } from '@/lib/permissions'
|
||||
|
||||
export async function importMembers(
|
||||
rows: Array<{
|
||||
display_name: string
|
||||
email: string
|
||||
phone?: string
|
||||
tier?: string
|
||||
joined_at?: string
|
||||
last_paid_at?: string
|
||||
has_card_access?: boolean
|
||||
}>,
|
||||
) {
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return { error: 'No rows to import' }
|
||||
}
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const inserts = rows
|
||||
.filter(r => typeof r.display_name === 'string' && typeof r.email === 'string')
|
||||
.map(r => ({
|
||||
space_id: member.space_id,
|
||||
display_name: r.display_name,
|
||||
email: r.email,
|
||||
phone: r.phone ?? null,
|
||||
tier: r.tier ?? 'basic',
|
||||
role: 'member',
|
||||
status: 'current',
|
||||
approved: true,
|
||||
joined_at: r.joined_at ?? new Date().toISOString(),
|
||||
last_paid_at: r.last_paid_at ?? null,
|
||||
has_card_access: r.has_card_access ?? false,
|
||||
}))
|
||||
|
||||
if (inserts.length === 0) {
|
||||
return { error: 'No valid rows to import' }
|
||||
}
|
||||
|
||||
// Upsert by (space_id, email) to dedupe re-imports.
|
||||
const { data, error } = await supabase
|
||||
.from('space_members')
|
||||
.upsert(inserts, { onConflict: 'space_id,email', ignoreDuplicates: false })
|
||||
.select()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/members')
|
||||
return { data, count: data.length }
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import {
|
||||
requireMember,
|
||||
requireMemberWithRole,
|
||||
logActivity,
|
||||
parseInput,
|
||||
} from '@/lib/auth-helpers'
|
||||
import { ADMIN_ROLES } from '@/lib/permissions'
|
||||
import {
|
||||
fileIncidentSchema,
|
||||
updateIncidentStatusSchema,
|
||||
addIncidentUpdateSchema,
|
||||
appealIncidentSchema,
|
||||
} from '@/lib/validations'
|
||||
|
||||
/**
|
||||
* Generate a short opaque tracking token for anonymous reports.
|
||||
* 24 bytes of randomness, base64url-encoded. Reporter receives this once;
|
||||
* server never reverses it to a member.
|
||||
*/
|
||||
function generateReporterToken(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(24))
|
||||
return Array.from(bytes)
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* File an incident (CoC complaint, safety report, conflict). When
|
||||
* is_anonymous=true the reporter_id is omitted and an opaque tracking
|
||||
* token is generated; the caller MUST surface the token to the reporter
|
||||
* because it is the only way to look the case up later.
|
||||
*/
|
||||
export async function fileIncident(formData: {
|
||||
title: string
|
||||
body: string
|
||||
category?: string
|
||||
severity?: string
|
||||
subjects?: string[]
|
||||
is_anonymous?: boolean
|
||||
}) {
|
||||
const v = parseInput(fileIncidentSchema, formData)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const isAnonymous = v.data.is_anonymous === true
|
||||
const reporterToken = isAnonymous ? generateReporterToken() : null
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('incidents')
|
||||
.insert({
|
||||
space_id: member.space_id,
|
||||
reporter_id: isAnonymous ? null : member.id,
|
||||
reporter_token: reporterToken,
|
||||
is_anonymous: isAnonymous,
|
||||
subjects: v.data.subjects ?? [],
|
||||
category: v.data.category,
|
||||
severity: v.data.severity,
|
||||
title: v.data.title,
|
||||
body: v.data.body,
|
||||
status: 'received',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
// Don't include the reporter's identity in the activity log if anonymous.
|
||||
await logActivity(
|
||||
supabase,
|
||||
isAnonymous
|
||||
? { space_id: member.space_id, user_id: null, display_name: 'Anonymous' }
|
||||
: member,
|
||||
'filed',
|
||||
'incident',
|
||||
data.id,
|
||||
v.data.title,
|
||||
)
|
||||
|
||||
revalidatePath('/incidents')
|
||||
return { data, token: reporterToken }
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin/board action: transition an incident's status and (optionally)
|
||||
* record a disposition. Stamps acknowledged_at / decided_at / closed_at
|
||||
* as appropriate.
|
||||
*/
|
||||
export async function updateIncidentStatus(
|
||||
incidentId: string,
|
||||
status: string,
|
||||
disposition?: string | null,
|
||||
) {
|
||||
const v = parseInput(updateIncidentStatusSchema, { incidentId, status, disposition })
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin or board access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const patch: Record<string, unknown> = { status: v.data.status }
|
||||
if (v.data.disposition !== undefined) patch.disposition = v.data.disposition
|
||||
|
||||
const now = new Date().toISOString()
|
||||
if (v.data.status === 'under_review') patch.acknowledged_at = now
|
||||
if (v.data.status === 'decided') patch.decided_at = now
|
||||
if (v.data.status === 'closed') patch.closed_at = now
|
||||
|
||||
// Track decision makers when deciding.
|
||||
if (v.data.status === 'decided') {
|
||||
const { data: current } = await supabase
|
||||
.from('incidents')
|
||||
.select('decision_maker_ids')
|
||||
.eq('id', v.data.incidentId)
|
||||
.single()
|
||||
const existing: string[] = (current as { decision_maker_ids?: string[] } | null)?.decision_maker_ids ?? []
|
||||
if (!existing.includes(member.id)) {
|
||||
patch.decision_maker_ids = [...existing, member.id]
|
||||
}
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('incidents')
|
||||
.update(patch)
|
||||
.eq('id', v.data.incidentId)
|
||||
.eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, v.data.status, 'incident', v.data.incidentId)
|
||||
|
||||
revalidatePath('/incidents')
|
||||
revalidatePath(`/incidents/${v.data.incidentId}`)
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an update / note to an incident. Reporter and admin/board can post.
|
||||
* Visibility controls who can read the note via RLS.
|
||||
*/
|
||||
export async function addIncidentUpdate(formData: {
|
||||
incidentId: string
|
||||
body: string
|
||||
visibility?: string
|
||||
}) {
|
||||
const v = parseInput(addIncidentUpdateSchema, formData)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('incident_updates')
|
||||
.insert({
|
||||
incident_id: v.data.incidentId,
|
||||
author_id: member.id,
|
||||
author_name: member.display_name,
|
||||
body: v.data.body,
|
||||
visibility: v.data.visibility,
|
||||
})
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
revalidatePath(`/incidents/${v.data.incidentId}`)
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
/**
|
||||
* Appeal a dismissed incident by spawning a membership-vote proposal.
|
||||
* The reporter or admin/board can trigger the appeal; the proposal goes
|
||||
* to draft status so the petitioner (or board) can review before opening.
|
||||
*/
|
||||
export async function appealIncident(formData: {
|
||||
incidentId: string
|
||||
title: string
|
||||
body?: string
|
||||
}) {
|
||||
const v = parseInput(appealIncidentSchema, formData)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
// Confirm the incident exists and is closeable-into-appeal (status='decided' or 'closed').
|
||||
const { data: incident, error: incidentErr } = await supabase
|
||||
.from('incidents')
|
||||
.select('id, status, space_id')
|
||||
.eq('id', v.data.incidentId)
|
||||
.single()
|
||||
if (incidentErr || !incident) return { error: 'Incident not found' }
|
||||
if (incident.space_id !== member.space_id) return { error: 'Cross-space appeal not allowed' }
|
||||
|
||||
// Create the appeal proposal as a draft.
|
||||
const { data: proposal, error: proposalErr } = await supabase
|
||||
.from('proposals')
|
||||
.insert({
|
||||
space_id: member.space_id,
|
||||
proposer_id: member.id,
|
||||
proposer_name: member.display_name,
|
||||
title: v.data.title,
|
||||
body: v.data.body,
|
||||
proposal_type: 'membership_vote',
|
||||
threshold: 'simple_majority',
|
||||
parent_incident_id: v.data.incidentId,
|
||||
status: 'draft',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
if (proposalErr) return { error: proposalErr.message }
|
||||
|
||||
// Mark the incident as appealed and link it to the proposal.
|
||||
const { error: linkErr } = await supabase
|
||||
.from('incidents')
|
||||
.update({ status: 'appealed', appeal_proposal_id: proposal.id })
|
||||
.eq('id', v.data.incidentId)
|
||||
if (linkErr) return { error: linkErr.message }
|
||||
|
||||
await logActivity(supabase, member, 'appealed', 'incident', v.data.incidentId)
|
||||
|
||||
revalidatePath('/incidents')
|
||||
revalidatePath(`/incidents/${v.data.incidentId}`)
|
||||
revalidatePath(`/proposals/${proposal.id}`)
|
||||
return { data: proposal }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Barrel re-export for server actions.
|
||||
*
|
||||
* Client components and pages import action functions via `@/lib/actions`.
|
||||
* Each domain lives in its own file in this directory; this barrel keeps
|
||||
* the import path stable as the directory grows.
|
||||
*
|
||||
* Do NOT add `'use server'` here. The directive lives on the domain files
|
||||
* themselves; re-exports preserve the marking.
|
||||
*/
|
||||
|
||||
export * from './tasks'
|
||||
export * from './projects'
|
||||
export * from './members'
|
||||
export * from './contacts'
|
||||
export * from './payments'
|
||||
export * from './knowledge-base'
|
||||
export * from './secrets'
|
||||
export * from './area-leads'
|
||||
export * from './settings'
|
||||
export * from './imports'
|
||||
|
||||
// Governance kernel (Tier 1)
|
||||
export * from './proposals'
|
||||
export * from './incidents'
|
||||
export * from './policies'
|
||||
|
||||
// Per-space configurable taxonomies
|
||||
export * from './areas'
|
||||
@@ -0,0 +1,106 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { requireMember, parseInput } from '@/lib/auth-helpers'
|
||||
import {
|
||||
createKbEntrySchema,
|
||||
updateKbEntrySchema,
|
||||
uuidSchema,
|
||||
} from '@/lib/validations'
|
||||
|
||||
export async function createKbEntry(formData: {
|
||||
title: string
|
||||
content: string
|
||||
description?: string
|
||||
area?: string
|
||||
visibility?: string
|
||||
is_pinned?: boolean
|
||||
tags?: string[]
|
||||
icon?: string
|
||||
}) {
|
||||
const v = parseInput(createKbEntrySchema, formData)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('knowledge_base')
|
||||
.insert({
|
||||
space_id: member.space_id,
|
||||
title: v.data.title,
|
||||
content: v.data.content,
|
||||
area: v.data.area ?? null,
|
||||
visibility: v.data.visibility,
|
||||
is_pinned: v.data.is_pinned,
|
||||
tags: v.data.tags ?? [],
|
||||
icon: v.data.icon ?? null,
|
||||
updated_by_id: member.id,
|
||||
updated_by_name: member.display_name,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/ops')
|
||||
return { data }
|
||||
}
|
||||
|
||||
export async function updateKbEntry(
|
||||
entryId: string,
|
||||
updates: {
|
||||
title?: string
|
||||
content?: string
|
||||
area?: string
|
||||
visibility?: string
|
||||
is_pinned?: boolean
|
||||
tags?: string[]
|
||||
},
|
||||
) {
|
||||
const v = parseInput(updateKbEntrySchema, { entryId, ...updates })
|
||||
if (!v.ok) return { error: v.error }
|
||||
const { entryId: id, ...patch } = v.data
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('knowledge_base')
|
||||
.update({
|
||||
...patch,
|
||||
updated_by_id: member.id,
|
||||
updated_by_name: member.display_name,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/ops')
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
export async function deleteKbEntry(entryId: string) {
|
||||
const v = parseInput(uuidSchema, entryId)
|
||||
if (!v.ok) return { error: 'Invalid entry ID' }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('knowledge_base')
|
||||
.delete()
|
||||
.eq('id', v.data)
|
||||
.eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/ops')
|
||||
return { success: true as const }
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import {
|
||||
requireMember,
|
||||
requireMemberWithRole,
|
||||
logActivity,
|
||||
parseInput,
|
||||
} from '@/lib/auth-helpers'
|
||||
import { ADMIN_ROLES } from '@/lib/permissions'
|
||||
import {
|
||||
addMemberSchema,
|
||||
updateMemberSchema,
|
||||
updateMyProfileSchema,
|
||||
discloseAffiliationsSchema,
|
||||
uuidSchema,
|
||||
} from '@/lib/validations'
|
||||
|
||||
export async function addMember(formData: {
|
||||
display_name: string
|
||||
email: string
|
||||
phone?: string
|
||||
handle?: string
|
||||
tier: string
|
||||
role: string
|
||||
joined_at?: string
|
||||
has_card_access?: boolean
|
||||
}) {
|
||||
const v = parseInput(addMemberSchema, formData)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member: self } = auth
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('space_members')
|
||||
.insert({
|
||||
space_id: self.space_id,
|
||||
display_name: v.data.display_name,
|
||||
email: v.data.email,
|
||||
phone: v.data.phone ?? null,
|
||||
handle: v.data.handle ?? null,
|
||||
tier: v.data.tier,
|
||||
role: v.data.role,
|
||||
status: 'current',
|
||||
approved: true,
|
||||
joined_at: v.data.joined_at ?? new Date().toISOString(),
|
||||
has_card_access: v.data.has_card_access ?? false,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/members')
|
||||
return { data }
|
||||
}
|
||||
|
||||
export async function updateMember(
|
||||
memberId: string,
|
||||
updates: {
|
||||
display_name?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
handle?: string
|
||||
tier?: string
|
||||
role?: string
|
||||
status?: string
|
||||
has_card_access?: boolean
|
||||
payment_status?: string
|
||||
payment_note?: string
|
||||
},
|
||||
) {
|
||||
const v = parseInput(updateMemberSchema, { memberId, ...updates })
|
||||
if (!v.ok) return { error: v.error }
|
||||
const { memberId: id, ...patch } = v.data
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member: self } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('space_members')
|
||||
.update(patch)
|
||||
.eq('id', id)
|
||||
.eq('space_id', self.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/members')
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
export async function approveMember(memberId: string) {
|
||||
const v = parseInput(uuidSchema, memberId)
|
||||
if (!v.ok) return { error: 'Invalid member ID' }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member: self } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('space_members')
|
||||
.update({ status: 'current', approved: true })
|
||||
.eq('id', v.data)
|
||||
.eq('space_id', self.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, self, 'approved', 'member', v.data)
|
||||
|
||||
revalidatePath('/members')
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-edit profile fields: display_name, handle, phone, skills, interests,
|
||||
* willing_to. The privilege-escalation trigger (migration 015) blocks
|
||||
* non-privileged members from touching role/tier/status/approved/
|
||||
* has_card_access/space_id. RLS allows self-update generally.
|
||||
*/
|
||||
export async function updateMyProfile(updates: {
|
||||
display_name?: string
|
||||
handle?: string | null
|
||||
phone?: string | null
|
||||
skills?: string[]
|
||||
interests?: string[]
|
||||
willing_to?: string[]
|
||||
}) {
|
||||
const v = parseInput(updateMyProfileSchema, updates)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('space_members')
|
||||
.update(v.data)
|
||||
.eq('id', member.id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/members')
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
/**
|
||||
* Disclose conflict-of-interest affiliations. Records the disclosure
|
||||
* timestamp so the dashboard can remind privileged roles to refresh
|
||||
* disclosures annually.
|
||||
*/
|
||||
export async function discloseAffiliations(formData: { affiliations: string[] }) {
|
||||
const v = parseInput(discloseAffiliationsSchema, formData)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('space_members')
|
||||
.update({
|
||||
affiliations: v.data.affiliations,
|
||||
coi_last_disclosed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', member.id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'disclosed', 'coi', member.id)
|
||||
|
||||
revalidatePath('/members')
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
export async function removeMember(memberId: string) {
|
||||
const v = parseInput(uuidSchema, memberId)
|
||||
if (!v.ok) return { error: 'Invalid member ID' }
|
||||
|
||||
const supabase = await createClient()
|
||||
// Admin-only (per RLS members_delete policy).
|
||||
const auth = await requireMemberWithRole(supabase, ['admin'], 'Admin access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member: self } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('space_members')
|
||||
.delete()
|
||||
.eq('id', v.data)
|
||||
.eq('space_id', self.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/members')
|
||||
return { success: true as const }
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import {
|
||||
requireMemberWithRole,
|
||||
logActivity,
|
||||
parseInput,
|
||||
} from '@/lib/auth-helpers'
|
||||
import { TREASURER_ROLES } from '@/lib/permissions'
|
||||
import {
|
||||
logCashPaymentSchema,
|
||||
linkPaymentSchema,
|
||||
} from '@/lib/validations'
|
||||
import type { Enums } from '@/types/database'
|
||||
|
||||
type PaymentPlatform = Enums<'payment_platform'>
|
||||
|
||||
export async function logCashPayment(formData: {
|
||||
amount: number
|
||||
from_note: string
|
||||
member_id?: string
|
||||
transaction_date?: string
|
||||
}) {
|
||||
const v = parseInput(logCashPaymentSchema, formData)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, TREASURER_ROLES, 'Treasurer access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const transactionDate = v.data.transaction_date ?? new Date().toISOString()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('payments')
|
||||
.insert({
|
||||
space_id: member.space_id,
|
||||
platform: 'cash',
|
||||
amount: v.data.amount,
|
||||
from_identifier: 'Cash',
|
||||
from_note: v.data.from_note,
|
||||
member_id: v.data.member_id ?? null,
|
||||
link_status: v.data.member_id ? 'linked' : 'unlinked',
|
||||
transaction_date: transactionDate,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
if (v.data.member_id) {
|
||||
await supabase
|
||||
.from('space_members')
|
||||
.update({
|
||||
last_paid_at: transactionDate,
|
||||
payment_status: 'current',
|
||||
})
|
||||
.eq('id', v.data.member_id)
|
||||
.eq('space_id', member.space_id)
|
||||
}
|
||||
|
||||
await logActivity(
|
||||
supabase,
|
||||
member,
|
||||
'logged',
|
||||
'payment',
|
||||
data.id,
|
||||
`$${v.data.amount} cash, ${v.data.from_note}`,
|
||||
)
|
||||
|
||||
revalidatePath('/payments')
|
||||
revalidatePath('/dashboard')
|
||||
return { data }
|
||||
}
|
||||
|
||||
export async function linkPaymentToMember(paymentId: string, memberId: string) {
|
||||
const v = parseInput(linkPaymentSchema, { paymentId, memberId })
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, TREASURER_ROLES, 'Treasurer access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member: self } = auth
|
||||
|
||||
const { data: payment } = await supabase
|
||||
.from('payments')
|
||||
.select('amount, transaction_date')
|
||||
.eq('id', v.data.paymentId)
|
||||
.single()
|
||||
|
||||
const { error } = await supabase
|
||||
.from('payments')
|
||||
.update({ member_id: v.data.memberId, link_status: 'linked' })
|
||||
.eq('id', v.data.paymentId)
|
||||
.eq('space_id', self.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
if (payment) {
|
||||
await supabase
|
||||
.from('space_members')
|
||||
.update({
|
||||
last_paid_at: payment.transaction_date,
|
||||
payment_status: 'current',
|
||||
})
|
||||
.eq('id', v.data.memberId)
|
||||
.eq('space_id', self.space_id)
|
||||
}
|
||||
|
||||
revalidatePath('/payments')
|
||||
revalidatePath('/members')
|
||||
revalidatePath('/dashboard')
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
export async function importPaymentsCsv(
|
||||
rows: Array<{
|
||||
platform: string
|
||||
amount: number
|
||||
from_identifier: string
|
||||
from_note?: string
|
||||
transaction_date: string
|
||||
}>,
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, TREASURER_ROLES, 'Treasurer access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const validPlatforms: PaymentPlatform[] = ['paypal', 'zeffy', 'venmo', 'cash']
|
||||
|
||||
const inserts = rows
|
||||
.filter(r => validPlatforms.includes(r.platform as PaymentPlatform))
|
||||
.map(r => ({
|
||||
space_id: member.space_id,
|
||||
platform: r.platform as PaymentPlatform,
|
||||
amount: r.amount,
|
||||
from_identifier: r.from_identifier,
|
||||
from_note: r.from_note ?? null,
|
||||
link_status: 'unlinked' as const,
|
||||
transaction_date: r.transaction_date,
|
||||
}))
|
||||
|
||||
if (inserts.length === 0) {
|
||||
return { error: 'No valid rows to import' }
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.from('payments').insert(inserts).select()
|
||||
if (error) return { error: error.message }
|
||||
|
||||
revalidatePath('/payments')
|
||||
revalidatePath('/dashboard')
|
||||
return { data, count: data.length }
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { requireMemberWithRole, logActivity, parseInput } from '@/lib/auth-helpers'
|
||||
import { ADMIN_ROLES } from '@/lib/permissions'
|
||||
import {
|
||||
createPolicySchema,
|
||||
supersedePolicySchema,
|
||||
updatePolicyStatusSchema,
|
||||
} from '@/lib/validations'
|
||||
|
||||
/**
|
||||
* Create a new policy at version 1, in draft status. Admin/board only.
|
||||
* To activate it, call updatePolicyStatus(id, 'active').
|
||||
*/
|
||||
export async function createPolicy(formData: {
|
||||
slug: string
|
||||
title: string
|
||||
section_ref?: string | null
|
||||
parent_policy_id?: string | null
|
||||
body_formal?: string
|
||||
body_plain?: string | null
|
||||
effective_at?: string | null
|
||||
}) {
|
||||
const v = parseInput(createPolicySchema, formData)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin or board access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
// Ensure (space_id, slug) is unique for v1 by checking for existing rows.
|
||||
const { data: existing } = await supabase
|
||||
.from('policies')
|
||||
.select('id')
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('slug', v.data.slug)
|
||||
.limit(1)
|
||||
if (existing && existing.length > 0) {
|
||||
return { error: 'A policy with that slug already exists. Use supersedePolicy to publish a new version.' }
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('policies')
|
||||
.insert({
|
||||
space_id: member.space_id,
|
||||
slug: v.data.slug,
|
||||
title: v.data.title,
|
||||
section_ref: v.data.section_ref ?? null,
|
||||
parent_policy_id: v.data.parent_policy_id ?? null,
|
||||
body_formal: v.data.body_formal,
|
||||
body_plain: v.data.body_plain ?? null,
|
||||
version: 1,
|
||||
status: 'draft',
|
||||
effective_at: v.data.effective_at ?? null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'created', 'policy', data.id, v.data.title)
|
||||
|
||||
revalidatePath('/policies')
|
||||
return { data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Supersede a policy by inserting a new version. The new row has
|
||||
* prior_version_id=<old policy id>, version=<old.version+1>, status='draft'.
|
||||
* Activating it (via updatePolicyStatus -> 'active') marks the prior version
|
||||
* as 'superseded' atomically inside this function.
|
||||
*/
|
||||
export async function supersedePolicy(formData: {
|
||||
policyId: string
|
||||
body_formal?: string
|
||||
body_plain?: string | null
|
||||
section_ref?: string | null
|
||||
title?: string
|
||||
adopted_by_proposal_id?: string | null
|
||||
effective_at?: string | null
|
||||
}) {
|
||||
const v = parseInput(supersedePolicySchema, formData)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin or board access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { data: prior, error: priorErr } = await supabase
|
||||
.from('policies')
|
||||
.select('id, space_id, slug, title, section_ref, parent_policy_id, body_formal, body_plain, version, status')
|
||||
.eq('id', v.data.policyId)
|
||||
.single()
|
||||
if (priorErr || !prior) return { error: 'Prior policy not found' }
|
||||
if (prior.space_id !== member.space_id) return { error: 'Cross-space supersede not allowed' }
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('policies')
|
||||
.insert({
|
||||
space_id: member.space_id,
|
||||
slug: prior.slug,
|
||||
title: v.data.title ?? prior.title,
|
||||
section_ref: v.data.section_ref ?? prior.section_ref,
|
||||
parent_policy_id: prior.parent_policy_id,
|
||||
body_formal: v.data.body_formal,
|
||||
body_plain: v.data.body_plain ?? null,
|
||||
version: prior.version + 1,
|
||||
prior_version_id: prior.id,
|
||||
status: 'draft',
|
||||
effective_at: v.data.effective_at ?? null,
|
||||
adopted_by_proposal_id: v.data.adopted_by_proposal_id ?? null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'superseded', 'policy', data.id, `v${prior.version} -> v${prior.version + 1}`)
|
||||
|
||||
revalidatePath('/policies')
|
||||
revalidatePath(`/policies/${prior.slug}`)
|
||||
return { data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip a policy's status. Activating supersedes any prior active version
|
||||
* (same slug) atomically.
|
||||
*/
|
||||
export async function updatePolicyStatus(policyId: string, status: string) {
|
||||
const v = parseInput(updatePolicyStatusSchema, { policyId, status })
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin or board access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { data: policy, error: pErr } = await supabase
|
||||
.from('policies')
|
||||
.select('id, slug, space_id, version, prior_version_id')
|
||||
.eq('id', v.data.policyId)
|
||||
.single()
|
||||
if (pErr || !policy) return { error: 'Policy not found' }
|
||||
if (policy.space_id !== member.space_id) return { error: 'Cross-space policy edit not allowed' }
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// When activating, supersede the prior active version (if any) of the same slug.
|
||||
if (v.data.status === 'active') {
|
||||
await supabase
|
||||
.from('policies')
|
||||
.update({ status: 'superseded' })
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('slug', policy.slug)
|
||||
.eq('status', 'active')
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = { status: v.data.status }
|
||||
if (v.data.status === 'active') patch.effective_at = now
|
||||
|
||||
const { error } = await supabase
|
||||
.from('policies')
|
||||
.update(patch)
|
||||
.eq('id', v.data.policyId)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, v.data.status, 'policy', v.data.policyId)
|
||||
|
||||
revalidatePath('/policies')
|
||||
revalidatePath(`/policies/${policy.slug}`)
|
||||
return { success: true as const }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { requireMember, logActivity, parseInput } from '@/lib/auth-helpers'
|
||||
import {
|
||||
createProjectSchema,
|
||||
updateProjectStatusSchema,
|
||||
uuidSchema,
|
||||
} from '@/lib/validations'
|
||||
|
||||
export async function createProject(formData: {
|
||||
title: string
|
||||
description?: string
|
||||
area?: string
|
||||
tags?: string[]
|
||||
due_date?: string
|
||||
}) {
|
||||
const v = parseInput(createProjectSchema, formData)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('projects')
|
||||
.insert({
|
||||
space_id: member.space_id,
|
||||
title: v.data.title,
|
||||
description: v.data.description,
|
||||
area: v.data.area,
|
||||
tags: v.data.tags ?? [],
|
||||
due_date: v.data.due_date ?? null,
|
||||
status: 'backlog',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'created', 'project', data.id, v.data.title)
|
||||
|
||||
revalidatePath('/projects')
|
||||
return { data }
|
||||
}
|
||||
|
||||
export async function updateProjectStatus(projectId: string, status: string) {
|
||||
const v = parseInput(updateProjectStatusSchema, { projectId, status })
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('projects')
|
||||
.update({ status: v.data.status, updated_at: new Date().toISOString() })
|
||||
.eq('id', v.data.projectId)
|
||||
.eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/projects')
|
||||
revalidatePath('/dashboard')
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
export async function deleteProject(projectId: string) {
|
||||
const v = parseInput(uuidSchema, projectId)
|
||||
if (!v.ok) return { error: 'Invalid project ID' }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('projects')
|
||||
.delete()
|
||||
.eq('id', v.data)
|
||||
.eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/projects')
|
||||
return { success: true as const }
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import {
|
||||
requireMember,
|
||||
requireMemberWithRole,
|
||||
logActivity,
|
||||
parseInput,
|
||||
} from '@/lib/auth-helpers'
|
||||
import { ADMIN_ROLES } from '@/lib/permissions'
|
||||
import {
|
||||
createProposalSchema,
|
||||
openProposalSchema,
|
||||
castVoteSchema,
|
||||
decideProposalSchema,
|
||||
withdrawProposalSchema,
|
||||
uuidSchema,
|
||||
} from '@/lib/validations'
|
||||
|
||||
/**
|
||||
* Create a proposal. Defaults to draft status. Pass `open_immediately: true`
|
||||
* to skip the draft step and go straight to voting (trigger computes quorum
|
||||
* and voting window from space defaults).
|
||||
*/
|
||||
export async function createProposal(formData: {
|
||||
title: string
|
||||
body?: string
|
||||
proposal_type?: string
|
||||
threshold?: string
|
||||
policy_ref_id?: string | null
|
||||
parent_incident_id?: string | null
|
||||
voting_opens_at?: string | null
|
||||
voting_closes_at?: string | null
|
||||
open_immediately?: boolean
|
||||
}) {
|
||||
const v = parseInput(createProposalSchema, formData)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const status = v.data.open_immediately ? 'open' : 'draft'
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('proposals')
|
||||
.insert({
|
||||
space_id: member.space_id,
|
||||
proposer_id: member.id,
|
||||
proposer_name: member.display_name,
|
||||
title: v.data.title,
|
||||
body: v.data.body,
|
||||
proposal_type: v.data.proposal_type,
|
||||
threshold: v.data.threshold ?? 'simple_majority',
|
||||
policy_ref_id: v.data.policy_ref_id ?? null,
|
||||
parent_incident_id: v.data.parent_incident_id ?? null,
|
||||
voting_opens_at: v.data.voting_opens_at ?? null,
|
||||
voting_closes_at: v.data.voting_closes_at ?? null,
|
||||
status,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'created', 'proposal', data.id, v.data.title)
|
||||
|
||||
revalidatePath('/proposals')
|
||||
return { data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a draft proposal to open. Trigger computes quorum_required and the
|
||||
* voting window from the space's defaults if voting_closes_at is null.
|
||||
*/
|
||||
export async function openProposal(proposalId: string, voting_closes_at?: string | null) {
|
||||
const v = parseInput(openProposalSchema, { proposalId, voting_closes_at })
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const updates: Record<string, unknown> = { status: 'open' }
|
||||
if (v.data.voting_closes_at) updates.voting_closes_at = v.data.voting_closes_at
|
||||
|
||||
const { error } = await supabase
|
||||
.from('proposals')
|
||||
.update(updates)
|
||||
.eq('id', v.data.proposalId)
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('status', 'draft')
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'opened', 'proposal', v.data.proposalId)
|
||||
|
||||
revalidatePath('/proposals')
|
||||
revalidatePath(`/proposals/${v.data.proposalId}`)
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw a proposal. Proposer can withdraw their own (any status pre-decided);
|
||||
* admin/board can withdraw any. Status becomes 'withdrawn'.
|
||||
*/
|
||||
export async function withdrawProposal(proposalId: string) {
|
||||
const v = parseInput(withdrawProposalSchema, { proposalId })
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('proposals')
|
||||
.update({ status: 'withdrawn' })
|
||||
.eq('id', v.data.proposalId)
|
||||
.eq('space_id', member.space_id)
|
||||
.in('status', ['draft', 'open'])
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'withdrew', 'proposal', v.data.proposalId)
|
||||
|
||||
revalidatePath('/proposals')
|
||||
revalidatePath(`/proposals/${v.data.proposalId}`)
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast or update a vote. Upserts on (proposal_id, member_id). The DB-level
|
||||
* trigger refreshes the proposal's tally on every change. Voting window is
|
||||
* enforced by RLS, not here.
|
||||
*/
|
||||
export async function castVote(formData: {
|
||||
proposalId: string
|
||||
position: string
|
||||
recusal_reason?: string | null
|
||||
comment?: string | null
|
||||
}) {
|
||||
const v = parseInput(castVoteSchema, formData)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('proposal_votes')
|
||||
.upsert(
|
||||
{
|
||||
proposal_id: v.data.proposalId,
|
||||
member_id: member.id,
|
||||
position: v.data.position,
|
||||
recusal_reason: v.data.recusal_reason ?? null,
|
||||
comment: v.data.comment ?? null,
|
||||
voted_at: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: 'proposal_id,member_id' },
|
||||
)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'voted', 'proposal', v.data.proposalId, v.data.position)
|
||||
|
||||
revalidatePath('/proposals')
|
||||
revalidatePath(`/proposals/${v.data.proposalId}`)
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide a proposal: flip status to 'decided' and freeze the outcome. The
|
||||
* tally is already maintained live by the refresh_proposal_tally trigger;
|
||||
* this just commits the decision and stamps decided_at.
|
||||
*/
|
||||
export async function decideProposal(proposalId: string) {
|
||||
const v = parseInput(decideProposalSchema, { proposalId })
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin or board access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('proposals')
|
||||
.update({ status: 'decided', decided_at: new Date().toISOString() })
|
||||
.eq('id', v.data.proposalId)
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('status', 'open')
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'decided', 'proposal', v.data.proposalId)
|
||||
|
||||
revalidatePath('/proposals')
|
||||
revalidatePath(`/proposals/${v.data.proposalId}`)
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a proposal. Admin/board only. Use sparingly; prefer withdraw.
|
||||
*/
|
||||
export async function deleteProposal(proposalId: string) {
|
||||
const v = parseInput(uuidSchema, proposalId)
|
||||
if (!v.ok) return { error: 'Invalid proposal ID' }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin or board access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('proposals')
|
||||
.delete()
|
||||
.eq('id', v.data)
|
||||
.eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'deleted', 'proposal', v.data)
|
||||
|
||||
revalidatePath('/proposals')
|
||||
return { success: true as const }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
|
||||
import { ADMIN_ROLES } from '@/lib/permissions'
|
||||
import { createSecretSchema, uuidSchema } from '@/lib/validations'
|
||||
|
||||
export async function createSecret(formData: {
|
||||
title: string
|
||||
value: string
|
||||
description?: string
|
||||
area?: string
|
||||
icon?: string
|
||||
}) {
|
||||
const v = parseInput(createSecretSchema, formData)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('secrets')
|
||||
.insert({
|
||||
space_id: member.space_id,
|
||||
title: v.data.title,
|
||||
label: v.data.title,
|
||||
value: v.data.value,
|
||||
description: v.data.description ?? null,
|
||||
area: v.data.area ?? null,
|
||||
icon: v.data.icon ?? null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/ops')
|
||||
return { data }
|
||||
}
|
||||
|
||||
export async function deleteSecret(secretId: string) {
|
||||
const v = parseInput(uuidSchema, secretId)
|
||||
if (!v.ok) return { error: 'Invalid secret ID' }
|
||||
|
||||
const supabase = await createClient()
|
||||
// Admin-only per RLS secrets_delete.
|
||||
const auth = await requireMemberWithRole(supabase, ['admin'], 'Admin access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('secrets')
|
||||
.delete()
|
||||
.eq('id', v.data)
|
||||
.eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/ops')
|
||||
return { success: true as const }
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
|
||||
import {
|
||||
updateSpaceSettingsSchema,
|
||||
saveIntegrationSchema,
|
||||
updateSpaceVisibilitySchema,
|
||||
} from '@/lib/validations'
|
||||
|
||||
const ADMIN_ONLY = ['admin'] as const
|
||||
|
||||
export async function updateSpaceSettings(updates: {
|
||||
name?: string
|
||||
slug?: string
|
||||
city?: string
|
||||
require_approval?: boolean
|
||||
public_member_directory?: boolean
|
||||
}) {
|
||||
const v = parseInput(updateSpaceSettingsSchema, updates)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ONLY, 'Admin access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('spaces')
|
||||
.update({ ...v.data, updated_at: new Date().toISOString() })
|
||||
.eq('id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/settings')
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
export async function rotateWebhookSecret() {
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ONLY, 'Admin access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(24))
|
||||
const newSecret =
|
||||
'whsec_' +
|
||||
Array.from(bytes)
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
|
||||
const { error } = await supabase
|
||||
.from('spaces')
|
||||
.update({ webhook_secret: newSecret, updated_at: new Date().toISOString() })
|
||||
.eq('id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/settings')
|
||||
return { secret: newSecret }
|
||||
}
|
||||
|
||||
export async function saveIntegration(platform: string, config: Record<string, string>) {
|
||||
const v = parseInput(saveIntegrationSchema, { platform, config })
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ONLY, 'Admin access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
// Store provided values; also store boolean "_set" flags so the UI can show
|
||||
// a "connected" state without leaking the secret back to the client.
|
||||
const safeConfig: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(v.data.config)) {
|
||||
safeConfig[key] = value ?? ''
|
||||
safeConfig[`${key}_set`] = value && value.length > 0 ? 'true' : 'false'
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('integrations').upsert(
|
||||
{
|
||||
space_id: member.space_id,
|
||||
platform: v.data.platform,
|
||||
name: v.data.platform,
|
||||
is_connected: Object.values(v.data.config).some(value => value && value.length > 0),
|
||||
config: safeConfig,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{ onConflict: 'space_id,platform' },
|
||||
)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/settings')
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-space visibility flags. Lets admins decide whether financial data is
|
||||
* visible to all members, board only, or treasurer-only; and whether the
|
||||
* member directory is fully visible, count-only, or board-only.
|
||||
*/
|
||||
export async function updateSpaceVisibility(updates: {
|
||||
financial_visibility?: string
|
||||
member_directory_visibility?: string
|
||||
}) {
|
||||
const v = parseInput(updateSpaceVisibilitySchema, updates)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ONLY, 'Admin access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('spaces')
|
||||
.update({ ...v.data, updated_at: new Date().toISOString() })
|
||||
.eq('id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/settings')
|
||||
revalidatePath('/financials')
|
||||
revalidatePath('/members')
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
export async function disconnectIntegration(platform: string) {
|
||||
if (typeof platform !== 'string' || platform.length === 0 || platform.length > 50) {
|
||||
return { error: 'Invalid platform' }
|
||||
}
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMemberWithRole(supabase, ADMIN_ONLY, 'Admin access required')
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('integrations')
|
||||
.update({ is_connected: false, config: {}, updated_at: new Date().toISOString() })
|
||||
.eq('space_id', member.space_id)
|
||||
.eq('platform', platform)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/settings')
|
||||
return { success: true as const }
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { requireMember, logActivity, parseInput } from '@/lib/auth-helpers'
|
||||
import { createTaskSchema, taskIdSchema } from '@/lib/validations'
|
||||
|
||||
export async function createTask(formData: {
|
||||
title: string
|
||||
description?: string
|
||||
type: string
|
||||
area?: string
|
||||
recurrence?: string
|
||||
due_date?: string
|
||||
}) {
|
||||
const v = parseInput(createTaskSchema, formData)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tasks')
|
||||
.insert({
|
||||
space_id: member.space_id,
|
||||
title: v.data.title,
|
||||
description: v.data.description,
|
||||
task_type: v.data.type,
|
||||
area: v.data.area,
|
||||
recurrence: v.data.recurrence,
|
||||
due_date: v.data.due_date ?? null,
|
||||
status: 'open',
|
||||
requested_by: member.user_id,
|
||||
requested_by_name: member.display_name,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'created', 'task', data.id, v.data.title)
|
||||
|
||||
revalidatePath('/tasks')
|
||||
revalidatePath('/dashboard')
|
||||
return { data }
|
||||
}
|
||||
|
||||
export async function claimTask(taskId: string) {
|
||||
const v = parseInput(taskIdSchema, taskId)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('tasks')
|
||||
.update({
|
||||
claimed_by: member.user_id,
|
||||
claimed_by_name: member.display_name,
|
||||
status: 'claimed',
|
||||
})
|
||||
.eq('id', v.data)
|
||||
.eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'claimed', 'task', v.data)
|
||||
|
||||
revalidatePath('/tasks')
|
||||
revalidatePath('/dashboard')
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
export async function completeTask(taskId: string) {
|
||||
const v = parseInput(taskIdSchema, taskId)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const { error } = await supabase
|
||||
.from('tasks')
|
||||
.update({
|
||||
status: 'completed',
|
||||
completed_at: now,
|
||||
last_done_at: now,
|
||||
})
|
||||
.eq('id', v.data)
|
||||
.eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
|
||||
await logActivity(supabase, member, 'completed', 'task', v.data)
|
||||
|
||||
revalidatePath('/tasks')
|
||||
revalidatePath('/dashboard')
|
||||
return { success: true as const }
|
||||
}
|
||||
|
||||
export async function deleteTask(taskId: string) {
|
||||
const v = parseInput(taskIdSchema, taskId)
|
||||
if (!v.ok) return { error: v.error }
|
||||
|
||||
const supabase = await createClient()
|
||||
const auth = await requireMember(supabase)
|
||||
if (!auth.ok) return { error: auth.error }
|
||||
const { member } = auth
|
||||
|
||||
const { error } = await supabase
|
||||
.from('tasks')
|
||||
.delete()
|
||||
.eq('id', v.data)
|
||||
.eq('space_id', member.space_id)
|
||||
|
||||
if (error) return { error: error.message }
|
||||
revalidatePath('/tasks')
|
||||
return { success: true as const }
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Tables } from '@/types/database'
|
||||
import type { ZodSchema } from 'zod'
|
||||
import { ACTIVE_STATUSES, hasRole, type Role } from '@/lib/permissions'
|
||||
|
||||
/**
|
||||
* Loose Supabase client type for shared helpers.
|
||||
*
|
||||
* The fully-typed client (`Awaited<ReturnType<typeof createClient>>` from
|
||||
* `lib/supabase/server.ts`) has a 3-generic shape that the supabase-js
|
||||
* version drift makes painful to assign across module boundaries. We use
|
||||
* a wide type at the helper boundary so callers with the precise client
|
||||
* can pass it through; inside the helper, schema-level type checking on
|
||||
* `.from(table).insert(row)` is relaxed. Helpers stay tiny and unit-tested,
|
||||
* so the precision loss is acceptable. Action bodies keep full typing
|
||||
* because they use the client returned by `createClient()` directly.
|
||||
*/
|
||||
export type ServerSupabase = SupabaseClient<any, any, any>
|
||||
|
||||
/** Projection of `space_members` that server actions consume. */
|
||||
export type Member = Pick<
|
||||
Tables<'space_members'>,
|
||||
'id' | 'space_id' | 'user_id' | 'role' | 'display_name' | 'handle'
|
||||
>
|
||||
|
||||
/**
|
||||
* Result types: a `ok: boolean` discriminator narrows reliably under
|
||||
* strict TypeScript. Always `if (!result.ok) return { error: result.error }`.
|
||||
*/
|
||||
export type Result<T> = { ok: true; data: T } | { ok: false; error: string }
|
||||
export type MemberResult = { ok: true; member: Member } | { ok: false; error: string }
|
||||
|
||||
/**
|
||||
* Returns the active member row for the current authenticated user, or
|
||||
* `null` if the user is not authenticated or has no active membership.
|
||||
*
|
||||
* "Active" means one of ACTIVE_STATUSES (current, unverified, late).
|
||||
* Inactive members are blocked from all server actions.
|
||||
*/
|
||||
export async function getAuthMember(supabase: ServerSupabase): Promise<Member | null> {
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
if (!user) return null
|
||||
|
||||
const { data } = await supabase
|
||||
.from('space_members')
|
||||
.select('id, space_id, user_id, role, display_name, handle')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ACTIVE_STATUSES as unknown as string[])
|
||||
.single()
|
||||
|
||||
return (data as Member | null) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Like getAuthMember but in MemberResult shape so callers can early-return
|
||||
* on `!result.ok`.
|
||||
*/
|
||||
export async function requireMember(supabase: ServerSupabase): Promise<MemberResult> {
|
||||
const member = await getAuthMember(supabase)
|
||||
if (!member) return { ok: false, error: 'Not authenticated or no active membership' }
|
||||
return { ok: true, member }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the member iff they hold one of the allowed roles.
|
||||
* Otherwise an error result.
|
||||
*/
|
||||
export async function requireMemberWithRole(
|
||||
supabase: ServerSupabase,
|
||||
allowed: readonly Role[],
|
||||
errorLabel = 'Insufficient permissions',
|
||||
): Promise<MemberResult> {
|
||||
const r = await requireMember(supabase)
|
||||
if (!r.ok) return r
|
||||
if (!hasRole(r.member.role, allowed)) {
|
||||
return { ok: false, error: errorLabel }
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a Zod schema against an input. Returns a discriminated Result
|
||||
* so callers can `if (!v.ok) return { error: v.error }` and have `v.data`
|
||||
* narrowed to T.
|
||||
*/
|
||||
export function parseInput<T>(schema: ZodSchema<T>, input: unknown): Result<T> {
|
||||
const parsed = schema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: parsed.error.errors[0]?.message ?? 'Invalid input' }
|
||||
}
|
||||
return { ok: true, data: parsed.data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a row into activity_log. Errors are swallowed: the log is
|
||||
* advisory and should never block the primary action.
|
||||
*/
|
||||
export async function logActivity(
|
||||
supabase: ServerSupabase,
|
||||
member: Pick<Member, 'space_id' | 'user_id' | 'display_name'>,
|
||||
action: string,
|
||||
entityType: string,
|
||||
entityId: string | null,
|
||||
details?: string | null,
|
||||
): Promise<void> {
|
||||
await supabase.from('activity_log').insert({
|
||||
space_id: member.space_id,
|
||||
user_id: member.user_id,
|
||||
display_name: member.display_name,
|
||||
action,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
details: details ?? null,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Enums } from '@/types/database'
|
||||
|
||||
/**
|
||||
* Single source of truth for role names and role-based permission sets.
|
||||
* Every server action that gates writes should reference one of the named
|
||||
* tuples below instead of writing role strings inline.
|
||||
*/
|
||||
|
||||
export type Role = Enums<'member_role'>
|
||||
export type MemberStatus = Enums<'member_status'>
|
||||
|
||||
/** Every defined role, in privilege order. */
|
||||
export const ROLES = ['admin', 'board', 'treasurer', 'member', 'associate'] as const satisfies readonly Role[]
|
||||
|
||||
/** Roles considered "privileged" for write/admin operations. */
|
||||
export const ADMIN_ROLES = ['admin', 'board'] as const satisfies readonly Role[]
|
||||
|
||||
/** Roles that can sign off on financial operations. */
|
||||
export const TREASURER_ROLES = ['admin', 'board', 'treasurer'] as const satisfies readonly Role[]
|
||||
|
||||
/** Roles that can read but not necessarily write. */
|
||||
export const ALL_ROLES = ROLES
|
||||
|
||||
/** Statuses a member may have and still be allowed to act in the app. */
|
||||
export const ACTIVE_STATUSES = ['current', 'unverified', 'late'] as const satisfies readonly MemberStatus[]
|
||||
|
||||
/**
|
||||
* Returns true if `role` is one of the `allowed` roles.
|
||||
* Use this everywhere instead of `role === 'admin' || role === 'board'` chains.
|
||||
*/
|
||||
export function hasRole(role: Role | null | undefined, allowed: readonly Role[]): boolean {
|
||||
if (!role) return false
|
||||
return allowed.includes(role)
|
||||
}
|
||||
+192
-4
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* Re-export types from the generated database types.
|
||||
* These types are auto-generated from the Supabase schema.
|
||||
*
|
||||
* To regenerate: Use the supabase_generate_typescript_types tool
|
||||
* Project-wide TypeScript types.
|
||||
*
|
||||
* Source of truth for row and enum shapes is `types/database.ts`, which is
|
||||
* generated from the live Supabase schema. This file re-exports those types
|
||||
* with friendlier names and adds composite / convenience types that the
|
||||
* application layer uses.
|
||||
*
|
||||
* To regenerate the underlying types after a schema change:
|
||||
* supabase gen types typescript --project-id <ref> > types/database.ts
|
||||
*/
|
||||
|
||||
import type { Tables, Enums } from '@/types/database'
|
||||
@@ -36,3 +41,186 @@ export type PaymentPlatform = Enums<'payment_platform'>
|
||||
export type PaymentLinkStatus = Enums<'payment_link_status'>
|
||||
export type ChannelType = Enums<'channel_type'>
|
||||
export type AreaLeadStatus = Enums<'area_lead_status'>
|
||||
|
||||
// ─── Convenience aliases ─────────────────────────────────────────────────────
|
||||
/** Common projection of `space_members` used by sidebars / props. */
|
||||
export type MemberSummary = Pick<
|
||||
SpaceMember,
|
||||
'id' | 'space_id' | 'user_id' | 'role' | 'display_name' | 'handle'
|
||||
>
|
||||
|
||||
/** Standard return shape for server actions. */
|
||||
export type ActionResult<T = void> =
|
||||
| { error: string; data?: undefined; success?: undefined }
|
||||
| { error?: undefined; data: T; success?: undefined }
|
||||
| { error?: undefined; data?: undefined; success: true }
|
||||
|
||||
// ─── Governance kernel (Tier 1 — migration 016) ──────────────────────────────
|
||||
// These tables are not yet in types/database.ts (regenerate via supabase CLI
|
||||
// to surface them in the generated types). Until then, hand-typed here.
|
||||
|
||||
export type ProposalType =
|
||||
| 'bylaw_change'
|
||||
| 'board_action'
|
||||
| 'membership_vote'
|
||||
| 'advisory_poll'
|
||||
| 'recall'
|
||||
| 'budget'
|
||||
|
||||
export type ProposalStatus =
|
||||
| 'draft'
|
||||
| 'open'
|
||||
| 'decided'
|
||||
| 'withdrawn'
|
||||
| 'expired'
|
||||
|
||||
export type ThresholdRule =
|
||||
| 'simple_majority'
|
||||
| 'two_thirds'
|
||||
| 'three_fourths'
|
||||
| 'unanimous'
|
||||
|
||||
export type VotePosition = 'yes' | 'no' | 'abstain' | 'recused'
|
||||
|
||||
export type IncidentStatus =
|
||||
| 'received'
|
||||
| 'under_review'
|
||||
| 'decided'
|
||||
| 'appealed'
|
||||
| 'closed'
|
||||
|
||||
export type IncidentSeverity = 'low' | 'medium' | 'high' | 'critical'
|
||||
|
||||
export type IncidentUpdateVisibility =
|
||||
| 'reporter_only'
|
||||
| 'all_parties'
|
||||
| 'board_only'
|
||||
|
||||
export type PolicyStatus = 'draft' | 'active' | 'deprecated' | 'superseded'
|
||||
|
||||
export type SpaceArea = {
|
||||
id: string
|
||||
space_id: string
|
||||
code: string
|
||||
name: string
|
||||
icon: string | null
|
||||
sort_order: number
|
||||
is_archived: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type FinancialVisibility = 'treasurer_only' | 'board_visible' | 'all_members_visible'
|
||||
|
||||
export type DirectoryVisibility =
|
||||
| 'board_only'
|
||||
| 'member_count_visible'
|
||||
| 'members_visible'
|
||||
| 'public_members_visible'
|
||||
|
||||
export type Proposal = {
|
||||
id: string
|
||||
space_id: string
|
||||
proposer_id: string | null
|
||||
proposer_name: string | null
|
||||
title: string
|
||||
body: string
|
||||
proposal_type: ProposalType
|
||||
status: ProposalStatus
|
||||
quorum_required: number
|
||||
quorum_percent: number
|
||||
quorum_floor: number
|
||||
threshold: ThresholdRule
|
||||
voting_opens_at: string | null
|
||||
voting_closes_at: string | null
|
||||
policy_ref_id: string | null
|
||||
parent_incident_id: string | null
|
||||
outcome_yes: number
|
||||
outcome_no: number
|
||||
outcome_abstain: number
|
||||
outcome_recused: number
|
||||
total_voters: number
|
||||
quorum_met: boolean | null
|
||||
passed: boolean | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
decided_at: string | null
|
||||
}
|
||||
|
||||
export type ProposalVote = {
|
||||
id: string
|
||||
proposal_id: string
|
||||
member_id: string
|
||||
position: VotePosition
|
||||
recusal_reason: string | null
|
||||
comment: string | null
|
||||
voted_at: string
|
||||
}
|
||||
|
||||
export type Incident = {
|
||||
id: string
|
||||
space_id: string
|
||||
reporter_id: string | null
|
||||
reporter_token: string | null
|
||||
is_anonymous: boolean
|
||||
subjects: string[]
|
||||
category: string
|
||||
severity: IncidentSeverity
|
||||
title: string
|
||||
body: string
|
||||
status: IncidentStatus
|
||||
disposition: string | null
|
||||
decision_maker_ids: string[]
|
||||
appeal_proposal_id: string | null
|
||||
sla_response_by: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
acknowledged_at: string | null
|
||||
decided_at: string | null
|
||||
closed_at: string | null
|
||||
}
|
||||
|
||||
export type IncidentUpdateRow = {
|
||||
id: string
|
||||
incident_id: string
|
||||
author_id: string | null
|
||||
author_name: string | null
|
||||
body: string
|
||||
visibility: IncidentUpdateVisibility
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type Policy = {
|
||||
id: string
|
||||
space_id: string
|
||||
slug: string
|
||||
section_ref: string | null
|
||||
parent_policy_id: string | null
|
||||
title: string
|
||||
body_formal: string
|
||||
body_plain: string | null
|
||||
version: number
|
||||
prior_version_id: string | null
|
||||
status: PolicyStatus
|
||||
effective_at: string | null
|
||||
adopted_by_proposal_id: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// ─── Re-exports from permissions/auth-helpers for one-stop imports ───────────
|
||||
export type { Role } from '@/lib/permissions'
|
||||
export {
|
||||
ROLES,
|
||||
ADMIN_ROLES,
|
||||
TREASURER_ROLES,
|
||||
ALL_ROLES,
|
||||
ACTIVE_STATUSES,
|
||||
hasRole,
|
||||
} from '@/lib/permissions'
|
||||
export type {
|
||||
Member,
|
||||
Result,
|
||||
MemberResult,
|
||||
ServerSupabase,
|
||||
} from '@/lib/auth-helpers'
|
||||
|
||||
+219
-15
@@ -1,5 +1,36 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Accepts every date-ish string the app produces and normalizes to a
|
||||
* full RFC 3339 datetime so downstream `.datetime()` validation passes.
|
||||
*
|
||||
* - `""`, `null`, `undefined` → `null` (treat as unset)
|
||||
* - `"YYYY-MM-DD"` (HTML date input) → `"YYYY-MM-DDT00:00:00.000Z"`
|
||||
* - `"YYYY-MM-DDTHH:MM"` (datetime-local input) → ISO string in UTC
|
||||
* - `"YYYY-MM-DDTHH:MM:SS"` and full RFC 3339 → unchanged / normalised
|
||||
* - Anything `new Date()` can parse → its ISO form
|
||||
* - Unparseable → passed through so Zod rejects it
|
||||
*
|
||||
* Returns a Zod schema that produces a `string | null` (after preprocess +
|
||||
* `.datetime().nullable()`).
|
||||
*/
|
||||
export const flexibleDateTime = () =>
|
||||
z.preprocess(
|
||||
(val) => {
|
||||
if (val === null || val === undefined || val === '') return null
|
||||
if (typeof val !== 'string') return val
|
||||
// Already full ISO datetime? Pass through.
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:?\d{2}|Z)$/.test(val)) {
|
||||
return val
|
||||
}
|
||||
// Try Date parsing for everything else (date-only, datetime-local, etc).
|
||||
const d = new Date(val)
|
||||
if (Number.isNaN(d.getTime())) return val // let Zod reject
|
||||
return d.toISOString()
|
||||
},
|
||||
z.string().datetime().nullable(),
|
||||
)
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const signInSchema = z.object({
|
||||
@@ -35,10 +66,10 @@ export const joinSpaceSchema = z.object({
|
||||
export const createTaskSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required').max(200, 'Title is too long'),
|
||||
description: z.string().max(2000, 'Description is too long').optional(),
|
||||
type: z.string().max(50).default('task'),
|
||||
type: z.enum(['task', 'chore']).default('task'),
|
||||
area: z.string().max(100).optional(),
|
||||
recurrence: z.enum(['none', 'daily', 'weekly', 'monthly']).default('none'),
|
||||
due_date: z.string().datetime().optional().nullable(),
|
||||
recurrence: z.enum(['none', 'daily', 'weekly', 'biweekly', 'monthly']).default('none'),
|
||||
due_date: flexibleDateTime().optional(),
|
||||
})
|
||||
|
||||
export const taskIdSchema = z.string().uuid('Invalid task ID')
|
||||
@@ -49,12 +80,13 @@ export const createProjectSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required').max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
area: z.string().max(100).optional(),
|
||||
due_date: z.string().datetime().optional().nullable(),
|
||||
tags: z.array(z.string().max(50)).max(20).optional(),
|
||||
due_date: flexibleDateTime().optional(),
|
||||
})
|
||||
|
||||
export const updateProjectStatusSchema = z.object({
|
||||
projectId: z.string().uuid('Invalid project ID'),
|
||||
status: z.enum(['planning', 'active', 'paused', 'completed', 'cancelled']),
|
||||
status: z.enum(['backlog', 'in_progress', 'review', 'done', 'blocked']),
|
||||
})
|
||||
|
||||
// ─── Members ─────────────────────────────────────────────────────────────────
|
||||
@@ -62,8 +94,12 @@ export const updateProjectStatusSchema = z.object({
|
||||
export const addMemberSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
display_name: z.string().min(1, 'Display name is required').max(100),
|
||||
role: z.enum(['admin', 'board', 'treasurer', 'member']).default('member'),
|
||||
phone: z.string().max(20).optional().nullable(),
|
||||
handle: z.string().max(50).optional().nullable(),
|
||||
role: z.enum(['admin', 'board', 'treasurer', 'member', 'associate']).default('member'),
|
||||
tier: z.enum(['plus', 'basic', 'associate']).default('basic'),
|
||||
joined_at: flexibleDateTime().optional(),
|
||||
has_card_access: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const updateMemberSchema = z.object({
|
||||
@@ -72,17 +108,19 @@ export const updateMemberSchema = z.object({
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().max(20).optional().nullable(),
|
||||
handle: z.string().max(50).optional().nullable(),
|
||||
role: z.enum(['admin', 'board', 'treasurer', 'member']).optional(),
|
||||
role: z.enum(['admin', 'board', 'treasurer', 'member', 'associate']).optional(),
|
||||
tier: z.enum(['plus', 'basic', 'associate']).optional(),
|
||||
status: z.enum(['current', 'unverified', 'late', 'inactive']).optional(),
|
||||
has_card_access: z.boolean().optional(),
|
||||
payment_status: z.string().max(50).optional().nullable(),
|
||||
payment_note: z.string().max(500).optional().nullable(),
|
||||
})
|
||||
|
||||
// ─── Contacts ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const createContactSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(200),
|
||||
contact_type: z.enum(['vendor', 'sponsor', 'partner', 'city', 'media', 'other']),
|
||||
contact_type: z.enum(['vendor', 'supplier', 'partner', 'landlord', 'city']),
|
||||
email: z.string().email().optional().nullable(),
|
||||
phone: z.string().max(20).optional().nullable(),
|
||||
details: z.string().max(500).optional().nullable(),
|
||||
@@ -102,7 +140,7 @@ export const createKbEntrySchema = z.object({
|
||||
title: z.string().min(1, 'Title is required').max(200),
|
||||
content: z.string().min(1, 'Content is required').max(50000),
|
||||
area: z.string().max(100).optional().nullable(),
|
||||
visibility: z.enum(['public', 'members', 'board', 'admin']).default('members'),
|
||||
visibility: z.enum(['all_members', 'board', 'admin_only']).default('all_members'),
|
||||
is_pinned: z.boolean().default(false),
|
||||
tags: z.array(z.string().max(50)).max(20).optional(),
|
||||
icon: z.string().max(10).optional().nullable(),
|
||||
@@ -126,9 +164,9 @@ export const createSecretSchema = z.object({
|
||||
|
||||
export const logCashPaymentSchema = z.object({
|
||||
amount: z.number().positive('Amount must be positive'),
|
||||
from_identifier: z.string().min(1, 'Payer name is required').max(200),
|
||||
from_note: z.string().max(500).optional().nullable(),
|
||||
transaction_date: z.string().datetime().optional(),
|
||||
from_note: z.string().min(1, 'Payer note is required').max(500),
|
||||
member_id: z.string().uuid('Invalid member ID').optional().nullable(),
|
||||
transaction_date: flexibleDateTime().optional(),
|
||||
})
|
||||
|
||||
export const linkPaymentSchema = z.object({
|
||||
@@ -140,9 +178,11 @@ export const linkPaymentSchema = z.object({
|
||||
|
||||
export const updateSpaceSettingsSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
slug: z.string().regex(/^[a-z0-9]+$/, 'Slug must be lowercase letters and numbers only').max(50).optional(),
|
||||
city: z.string().max(100).optional().nullable(),
|
||||
require_approval: z.boolean().optional(),
|
||||
public_member_directory: z.boolean().optional(),
|
||||
mission_statement: z.string().max(5000).optional().nullable(),
|
||||
})
|
||||
|
||||
export const saveIntegrationSchema = z.object({
|
||||
@@ -153,10 +193,174 @@ export const saveIntegrationSchema = z.object({
|
||||
// ─── Area Leads ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const upsertAreaLeadSchema = z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
area_code: z.string().min(1, 'Area code is required').max(50),
|
||||
area_name: z.string().min(1, 'Area name is required').max(100),
|
||||
lead_handle: z.string().min(1, 'Lead handle is required').max(100),
|
||||
description: z.string().max(500).optional().nullable(),
|
||||
lead_id: z.string().uuid().optional().nullable(),
|
||||
lead_handle: z.string().max(100).optional().nullable(),
|
||||
status: z.enum(['active', 'vacant', 'handoff']).default('active'),
|
||||
})
|
||||
|
||||
// ─── Governance: proposals ───────────────────────────────────────────────────
|
||||
|
||||
export const proposalTypes = ['bylaw_change','board_action','membership_vote','advisory_poll','recall','budget'] as const
|
||||
export const proposalStatuses = ['draft','open','decided','withdrawn','expired'] as const
|
||||
export const thresholdRules = ['simple_majority','two_thirds','three_fourths','unanimous'] as const
|
||||
export const votePositions = ['yes','no','abstain','recused'] as const
|
||||
|
||||
export const createProposalSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required').max(200, 'Title is too long'),
|
||||
body: z.string().max(20000).default(''),
|
||||
proposal_type: z.enum(proposalTypes).default('advisory_poll'),
|
||||
threshold: z.enum(thresholdRules).optional(),
|
||||
policy_ref_id: z.string().uuid().optional().nullable(),
|
||||
parent_incident_id: z.string().uuid().optional().nullable(),
|
||||
voting_opens_at: flexibleDateTime().optional(),
|
||||
voting_closes_at: flexibleDateTime().optional(),
|
||||
open_immediately: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const openProposalSchema = z.object({
|
||||
proposalId: z.string().uuid('Invalid proposal ID'),
|
||||
voting_closes_at: flexibleDateTime().optional(),
|
||||
})
|
||||
|
||||
export const castVoteSchema = z.object({
|
||||
proposalId: z.string().uuid('Invalid proposal ID'),
|
||||
position: z.enum(votePositions),
|
||||
recusal_reason: z.string().max(1000).optional().nullable(),
|
||||
comment: z.string().max(2000).optional().nullable(),
|
||||
}).refine(
|
||||
v => v.position !== 'recused' || (v.recusal_reason && v.recusal_reason.trim().length > 0),
|
||||
{ message: 'Recusal requires a reason', path: ['recusal_reason'] },
|
||||
)
|
||||
|
||||
export const decideProposalSchema = z.object({
|
||||
proposalId: z.string().uuid('Invalid proposal ID'),
|
||||
})
|
||||
|
||||
export const withdrawProposalSchema = z.object({
|
||||
proposalId: z.string().uuid('Invalid proposal ID'),
|
||||
})
|
||||
|
||||
// ─── Governance: incidents ───────────────────────────────────────────────────
|
||||
|
||||
export const incidentSeverities = ['low','medium','high','critical'] as const
|
||||
export const incidentStatuses = ['received','under_review','decided','appealed','closed'] as const
|
||||
export const incidentUpdateVisibilities = ['reporter_only','all_parties','board_only'] as const
|
||||
|
||||
export const fileIncidentSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required').max(200),
|
||||
body: z.string().min(1, 'Description is required').max(20000),
|
||||
category: z.string().max(50).default('general'),
|
||||
severity: z.enum(incidentSeverities).default('medium'),
|
||||
subjects: z.array(z.string().uuid()).max(20).optional(),
|
||||
is_anonymous: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const updateIncidentStatusSchema = z.object({
|
||||
incidentId: z.string().uuid('Invalid incident ID'),
|
||||
status: z.enum(incidentStatuses),
|
||||
disposition: z.string().max(20000).optional().nullable(),
|
||||
})
|
||||
|
||||
export const addIncidentUpdateSchema = z.object({
|
||||
incidentId: z.string().uuid('Invalid incident ID'),
|
||||
body: z.string().min(1, 'Update body is required').max(20000),
|
||||
visibility: z.enum(incidentUpdateVisibilities).default('all_parties'),
|
||||
})
|
||||
|
||||
export const appealIncidentSchema = z.object({
|
||||
incidentId: z.string().uuid('Invalid incident ID'),
|
||||
title: z.string().min(1).max(200),
|
||||
body: z.string().max(20000).default(''),
|
||||
})
|
||||
|
||||
// ─── Governance: policies ────────────────────────────────────────────────────
|
||||
|
||||
export const policyStatuses = ['draft','active','deprecated','superseded'] as const
|
||||
|
||||
export const createPolicySchema = z.object({
|
||||
slug: z.string().min(1, 'Slug is required').max(80).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase letters, numbers, and hyphens only'),
|
||||
title: z.string().min(1, 'Title is required').max(200),
|
||||
section_ref: z.string().max(80).optional().nullable(),
|
||||
parent_policy_id: z.string().uuid().optional().nullable(),
|
||||
body_formal: z.string().max(100000).default(''),
|
||||
body_plain: z.string().max(100000).optional().nullable(),
|
||||
effective_at: flexibleDateTime().optional(),
|
||||
})
|
||||
|
||||
export const supersedePolicySchema = z.object({
|
||||
policyId: z.string().uuid('Invalid policy ID'),
|
||||
body_formal: z.string().max(100000).default(''),
|
||||
body_plain: z.string().max(100000).optional().nullable(),
|
||||
section_ref: z.string().max(80).optional().nullable(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
adopted_by_proposal_id: z.string().uuid().optional().nullable(),
|
||||
effective_at: flexibleDateTime().optional(),
|
||||
})
|
||||
|
||||
export const updatePolicyStatusSchema = z.object({
|
||||
policyId: z.string().uuid('Invalid policy ID'),
|
||||
status: z.enum(policyStatuses),
|
||||
})
|
||||
|
||||
// ─── Member self-profile and COI ─────────────────────────────────────────────
|
||||
|
||||
export const updateMyProfileSchema = z.object({
|
||||
display_name: z.string().min(1).max(100).optional(),
|
||||
handle: z.string().max(50).optional().nullable(),
|
||||
phone: z.string().max(20).optional().nullable(),
|
||||
skills: z.array(z.string().min(1).max(60)).max(40).optional(),
|
||||
interests: z.array(z.string().min(1).max(60)).max(40).optional(),
|
||||
willing_to: z.array(z.string().min(1).max(60)).max(20).optional(),
|
||||
})
|
||||
|
||||
export const discloseAffiliationsSchema = z.object({
|
||||
affiliations: z.array(z.string().min(1).max(200)).max(50).default([]),
|
||||
})
|
||||
|
||||
// ─── Space settings (visibility) ─────────────────────────────────────────────
|
||||
|
||||
export const financialVisibilities = ['treasurer_only', 'board_visible', 'all_members_visible'] as const
|
||||
export const directoryVisibilities = [
|
||||
'board_only',
|
||||
'member_count_visible',
|
||||
'members_visible',
|
||||
'public_members_visible',
|
||||
] as const
|
||||
|
||||
export const updateSpaceVisibilitySchema = z.object({
|
||||
financial_visibility: z.enum(financialVisibilities).optional(),
|
||||
member_directory_visibility: z.enum(directoryVisibilities).optional(),
|
||||
})
|
||||
|
||||
// ─── Knowledge base meeting minutes ─────────────────────────────────────────
|
||||
|
||||
export const meetingMinutesSchema = z.object({
|
||||
entryId: z.string().uuid('Invalid entry ID'),
|
||||
is_meeting_minutes: z.boolean().default(true),
|
||||
meeting_date: flexibleDateTime().optional(),
|
||||
})
|
||||
|
||||
// ─── Areas ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const createAreaSchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.min(1, 'Code is required')
|
||||
.max(40)
|
||||
.regex(/^[a-z0-9-]+$/, 'Code must be lowercase letters, numbers, and hyphens only'),
|
||||
name: z.string().min(1, 'Name is required').max(80),
|
||||
icon: z.string().max(20).optional().nullable(),
|
||||
sort_order: z.number().int().min(0).max(100000).optional(),
|
||||
})
|
||||
|
||||
export const updateAreaSchema = z.object({
|
||||
areaId: z.string().uuid('Invalid area ID'),
|
||||
name: z.string().min(1).max(80).optional(),
|
||||
icon: z.string().max(20).optional().nullable(),
|
||||
sort_order: z.number().int().min(0).max(100000).optional(),
|
||||
is_archived: z.boolean().optional(),
|
||||
})
|
||||
|
||||
// ─── Generic ID schemas ──────────────────────────────────────────────────────
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
+6
-4
@@ -14,7 +14,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@vercel/analytics": "1.6.1",
|
||||
"@radix-ui/react-accordion": "1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "1.1.8",
|
||||
@@ -42,6 +41,9 @@
|
||||
"@radix-ui/react-toggle": "1.1.10",
|
||||
"@radix-ui/react-toggle-group": "1.1.11",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@supabase/ssr": "^0.6.1",
|
||||
"@supabase/supabase-js": "^2.49.4",
|
||||
"@vercel/analytics": "1.6.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -56,14 +58,14 @@
|
||||
"react-day-picker": "9.13.2",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.54.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^1.7.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.24.1",
|
||||
"@supabase/ssr": "^0.6.1",
|
||||
"@supabase/supabase-js": "^2.49.4"
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.40.0",
|
||||
|
||||
Generated
+868
-41
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,9 @@ import { updateSession } from '@/lib/supabase/proxy'
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
|
||||
const PUBLIC_ROUTES = ['/', '/login', '/signup', '/auth']
|
||||
const PUBLIC_ROUTES = ['/', '/login', '/signup', '/auth', '/api/health']
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
export async function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
// Always update session to refresh cookies
|
||||
@@ -0,0 +1,17 @@
|
||||
-- =============================================================================
|
||||
-- 014: Make space_members.user_id nullable for offline members
|
||||
-- =============================================================================
|
||||
-- Background:
|
||||
-- addMember() and importMembers() in lib/actions.ts insert members that may
|
||||
-- not have a corresponding auth.users row (admin-added or CSV-imported
|
||||
-- members who have not signed up yet). The original schema declared
|
||||
-- space_members.user_id as NOT NULL, which made those inserts fail.
|
||||
--
|
||||
-- This migration:
|
||||
-- 1. Drops the NOT NULL constraint on space_members.user_id.
|
||||
--
|
||||
-- Safe to re-run: ALTER COLUMN ... DROP NOT NULL is idempotent in Postgres.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.space_members
|
||||
ALTER COLUMN user_id DROP NOT NULL;
|
||||
@@ -0,0 +1,63 @@
|
||||
-- =============================================================================
|
||||
-- 015: Prevent self-role-change on space_members
|
||||
-- =============================================================================
|
||||
-- Background:
|
||||
-- The members_update RLS policy allows a user to UPDATE their own row
|
||||
-- (user_id = auth.uid()). This is intentional so members can edit their
|
||||
-- display_name, handle, phone, etc. without going through admin.
|
||||
-- But the policy has no column-level restriction, so a member could
|
||||
-- PATCH their own row via PostgREST and set role = 'admin'.
|
||||
--
|
||||
-- This migration:
|
||||
-- Adds a BEFORE UPDATE trigger that rejects any change to role, tier,
|
||||
-- status, approved, has_card_access, or space_id when the row being
|
||||
-- updated belongs to the current user AND the current user does not
|
||||
-- already hold a privileged role in that space.
|
||||
--
|
||||
-- Service role (no auth.uid) and admin/board/treasurer self-updates are
|
||||
-- unaffected.
|
||||
--
|
||||
-- Safe to re-run: function is CREATE OR REPLACE; trigger is dropped first.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.prevent_member_self_role_change()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_is_privileged boolean;
|
||||
BEGIN
|
||||
IF auth.uid() IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF NEW.user_id IS DISTINCT FROM auth.uid() THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT public.user_has_role_in_space(auth.uid(), NEW.space_id, ARRAY['admin','board','treasurer'])
|
||||
INTO v_is_privileged;
|
||||
|
||||
IF v_is_privileged THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
IF NEW.role IS DISTINCT FROM OLD.role
|
||||
OR NEW.tier IS DISTINCT FROM OLD.tier
|
||||
OR NEW.status IS DISTINCT FROM OLD.status
|
||||
OR NEW.approved IS DISTINCT FROM OLD.approved
|
||||
OR NEW.has_card_access IS DISTINCT FROM OLD.has_card_access
|
||||
OR NEW.space_id IS DISTINCT FROM OLD.space_id THEN
|
||||
RAISE EXCEPTION 'Members cannot change their own role, tier, status, approval, card access, or space.'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_prevent_member_self_role_change ON public.space_members;
|
||||
CREATE TRIGGER trg_prevent_member_self_role_change
|
||||
BEFORE UPDATE ON public.space_members
|
||||
FOR EACH ROW EXECUTE FUNCTION public.prevent_member_self_role_change();
|
||||
@@ -0,0 +1,511 @@
|
||||
-- =============================================================================
|
||||
-- 016: Governance kernel — proposals, incidents, policies
|
||||
-- =============================================================================
|
||||
-- Tier 1 of docs/GOVERNANCE_FEATURES.md.
|
||||
--
|
||||
-- This migration adds three tightly integrated entities:
|
||||
--
|
||||
-- proposals + proposal_votes — async voting (Patterns 3, 4, 5, 12)
|
||||
-- incidents + incident_updates — CoC / safety report tracking (Pattern 1)
|
||||
-- policies — versioned bylaws with provenance (Pattern 4)
|
||||
--
|
||||
-- Plus six new columns on `spaces` for per-space governance defaults
|
||||
-- (quorum percent, voting window, incident SLA, mission statement, etc).
|
||||
--
|
||||
-- The three modules cross-reference: a proposal can cite a policy clause, an
|
||||
-- incident appeal becomes a proposal, a passed bylaw-change proposal supersedes
|
||||
-- a policy version. The circular FKs are added via ALTER TABLE after creation.
|
||||
--
|
||||
-- Safe to re-run: CREATE TABLE IF NOT EXISTS, DROP POLICY IF EXISTS before
|
||||
-- CREATE POLICY, CREATE OR REPLACE FUNCTION, etc.
|
||||
-- =============================================================================
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. ENUMS
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$ BEGIN CREATE TYPE public.proposal_type AS ENUM ('bylaw_change','board_action','membership_vote','advisory_poll','recall','budget'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.proposal_status AS ENUM ('draft','open','decided','withdrawn','expired'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.threshold_rule AS ENUM ('simple_majority','two_thirds','three_fourths','unanimous'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.vote_position AS ENUM ('yes','no','abstain','recused'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.incident_status AS ENUM ('received','under_review','decided','appealed','closed'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.incident_severity AS ENUM ('low','medium','high','critical'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.incident_update_visibility AS ENUM ('reporter_only','all_parties','board_only'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.policy_status AS ENUM ('draft','active','deprecated','superseded'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. SPACES — per-space governance defaults
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_quorum_percent integer NOT NULL DEFAULT 10;
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_quorum_floor integer NOT NULL DEFAULT 1;
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_voting_window_hours integer NOT NULL DEFAULT 216; -- 9 days
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_threshold public.threshold_rule NOT NULL DEFAULT 'simple_majority';
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS incident_sla_hours integer NOT NULL DEFAULT 72; -- 3 days
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS mission_statement text;
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. TABLES
|
||||
-- Order matters because of the circular FKs. Create the tables first without
|
||||
-- the circular FK columns enforced, then add constraints via ALTER TABLE.
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
-- proposals --------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.proposals (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
proposer_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
|
||||
proposer_name text,
|
||||
title text NOT NULL,
|
||||
body text NOT NULL DEFAULT '',
|
||||
proposal_type public.proposal_type NOT NULL DEFAULT 'advisory_poll',
|
||||
status public.proposal_status NOT NULL DEFAULT 'draft',
|
||||
quorum_required integer NOT NULL DEFAULT 0,
|
||||
quorum_percent integer NOT NULL DEFAULT 10,
|
||||
quorum_floor integer NOT NULL DEFAULT 1,
|
||||
threshold public.threshold_rule NOT NULL DEFAULT 'simple_majority',
|
||||
voting_opens_at timestamptz,
|
||||
voting_closes_at timestamptz,
|
||||
policy_ref_id uuid,
|
||||
parent_incident_id uuid,
|
||||
outcome_yes integer NOT NULL DEFAULT 0,
|
||||
outcome_no integer NOT NULL DEFAULT 0,
|
||||
outcome_abstain integer NOT NULL DEFAULT 0,
|
||||
outcome_recused integer NOT NULL DEFAULT 0,
|
||||
total_voters integer NOT NULL DEFAULT 0,
|
||||
quorum_met boolean,
|
||||
passed boolean,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
decided_at timestamptz
|
||||
);
|
||||
|
||||
-- proposal_votes ---------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.proposal_votes (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
proposal_id uuid NOT NULL REFERENCES public.proposals(id) ON DELETE CASCADE,
|
||||
member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE,
|
||||
position public.vote_position NOT NULL,
|
||||
recusal_reason text,
|
||||
comment text,
|
||||
voted_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (proposal_id, member_id),
|
||||
CONSTRAINT recused_requires_reason
|
||||
CHECK (position <> 'recused' OR (recusal_reason IS NOT NULL AND length(trim(recusal_reason)) > 0))
|
||||
);
|
||||
|
||||
-- incidents --------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.incidents (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
reporter_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
|
||||
reporter_token text UNIQUE,
|
||||
is_anonymous boolean NOT NULL DEFAULT false,
|
||||
subjects uuid[] NOT NULL DEFAULT '{}'::uuid[],
|
||||
category text NOT NULL DEFAULT 'general',
|
||||
severity public.incident_severity NOT NULL DEFAULT 'medium',
|
||||
title text NOT NULL,
|
||||
body text NOT NULL,
|
||||
status public.incident_status NOT NULL DEFAULT 'received',
|
||||
disposition text,
|
||||
decision_maker_ids uuid[] NOT NULL DEFAULT '{}'::uuid[],
|
||||
appeal_proposal_id uuid,
|
||||
sla_response_by timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
acknowledged_at timestamptz,
|
||||
decided_at timestamptz,
|
||||
closed_at timestamptz
|
||||
);
|
||||
|
||||
-- incident_updates -------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.incident_updates (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
incident_id uuid NOT NULL REFERENCES public.incidents(id) ON DELETE CASCADE,
|
||||
author_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
|
||||
author_name text,
|
||||
body text NOT NULL,
|
||||
visibility public.incident_update_visibility NOT NULL DEFAULT 'all_parties',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- policies ---------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.policies (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
slug text NOT NULL,
|
||||
section_ref text,
|
||||
parent_policy_id uuid REFERENCES public.policies(id) ON DELETE SET NULL,
|
||||
title text NOT NULL,
|
||||
body_formal text NOT NULL DEFAULT '',
|
||||
body_plain text,
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
prior_version_id uuid REFERENCES public.policies(id) ON DELETE SET NULL,
|
||||
status public.policy_status NOT NULL DEFAULT 'draft',
|
||||
effective_at timestamptz,
|
||||
adopted_by_proposal_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (space_id, slug, version)
|
||||
);
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 4. CIRCULAR FOREIGN KEYS — added after all three tables exist
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE public.proposals
|
||||
ADD CONSTRAINT proposals_policy_ref_fk
|
||||
FOREIGN KEY (policy_ref_id) REFERENCES public.policies(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE public.proposals
|
||||
ADD CONSTRAINT proposals_parent_incident_fk
|
||||
FOREIGN KEY (parent_incident_id) REFERENCES public.incidents(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE public.incidents
|
||||
ADD CONSTRAINT incidents_appeal_proposal_fk
|
||||
FOREIGN KEY (appeal_proposal_id) REFERENCES public.proposals(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE public.policies
|
||||
ADD CONSTRAINT policies_adopted_by_proposal_fk
|
||||
FOREIGN KEY (adopted_by_proposal_id) REFERENCES public.proposals(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 5. INDEXES
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS idx_proposals_space ON public.proposals (space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposals_status ON public.proposals (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposals_proposer ON public.proposals (proposer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposals_closes ON public.proposals (voting_closes_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposal_votes_proposal ON public.proposal_votes (proposal_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposal_votes_member ON public.proposal_votes (member_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_space ON public.incidents (space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_reporter ON public.incidents (reporter_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_status ON public.incidents (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_subjects ON public.incidents USING GIN (subjects);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_incidents_token_unique
|
||||
ON public.incidents (reporter_token)
|
||||
WHERE reporter_token IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_incident_updates_incident ON public.incident_updates (incident_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_policies_space_slug ON public.policies (space_id, slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_policies_status ON public.policies (status);
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 6. TRIGGERS
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
-- 6.1 Computes quorum_required and voting window when a proposal opens.
|
||||
CREATE OR REPLACE FUNCTION public.compute_proposal_quorum()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_member_count integer;
|
||||
v_quorum_percent integer;
|
||||
v_quorum_floor integer;
|
||||
v_voting_window_hours integer;
|
||||
BEGIN
|
||||
IF NEW.status = 'open' AND (TG_OP = 'INSERT' OR OLD.status = 'draft') THEN
|
||||
SELECT default_quorum_percent, default_quorum_floor, default_voting_window_hours
|
||||
INTO v_quorum_percent, v_quorum_floor, v_voting_window_hours
|
||||
FROM public.spaces WHERE id = NEW.space_id;
|
||||
|
||||
SELECT count(*) INTO v_member_count
|
||||
FROM public.space_members
|
||||
WHERE space_id = NEW.space_id
|
||||
AND status IN ('current','late')
|
||||
AND approved = true;
|
||||
|
||||
NEW.quorum_percent := COALESCE(v_quorum_percent, 10);
|
||||
NEW.quorum_floor := COALESCE(v_quorum_floor, 1);
|
||||
NEW.quorum_required := GREATEST(
|
||||
NEW.quorum_floor,
|
||||
CEIL(v_member_count * NEW.quorum_percent / 100.0)::integer
|
||||
);
|
||||
|
||||
IF NEW.voting_opens_at IS NULL THEN
|
||||
NEW.voting_opens_at := now();
|
||||
END IF;
|
||||
IF NEW.voting_closes_at IS NULL THEN
|
||||
NEW.voting_closes_at := NEW.voting_opens_at + (v_voting_window_hours || ' hours')::interval;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_compute_proposal_quorum ON public.proposals;
|
||||
CREATE TRIGGER trg_compute_proposal_quorum
|
||||
BEFORE INSERT OR UPDATE ON public.proposals
|
||||
FOR EACH ROW EXECUTE FUNCTION public.compute_proposal_quorum();
|
||||
|
||||
|
||||
-- 6.2 Recomputes proposal vote tallies on any vote insert / update / delete.
|
||||
CREATE OR REPLACE FUNCTION public.refresh_proposal_tally()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_proposal_id uuid;
|
||||
v_yes integer;
|
||||
v_no integer;
|
||||
v_abstain integer;
|
||||
v_recused integer;
|
||||
v_total integer;
|
||||
v_quorum_required integer;
|
||||
v_threshold public.threshold_rule;
|
||||
v_status public.proposal_status;
|
||||
v_quorum_met boolean;
|
||||
v_passed boolean;
|
||||
BEGIN
|
||||
v_proposal_id := COALESCE(NEW.proposal_id, OLD.proposal_id);
|
||||
|
||||
SELECT
|
||||
count(*) FILTER (WHERE position = 'yes'),
|
||||
count(*) FILTER (WHERE position = 'no'),
|
||||
count(*) FILTER (WHERE position = 'abstain'),
|
||||
count(*) FILTER (WHERE position = 'recused'),
|
||||
count(*)
|
||||
INTO v_yes, v_no, v_abstain, v_recused, v_total
|
||||
FROM public.proposal_votes
|
||||
WHERE proposal_id = v_proposal_id;
|
||||
|
||||
SELECT quorum_required, threshold, status
|
||||
INTO v_quorum_required, v_threshold, v_status
|
||||
FROM public.proposals WHERE id = v_proposal_id;
|
||||
|
||||
-- For quorum, count yes+no+abstain (recused does not count toward quorum).
|
||||
v_quorum_met := (v_yes + v_no + v_abstain) >= COALESCE(v_quorum_required, 0);
|
||||
|
||||
v_passed := CASE
|
||||
WHEN NOT v_quorum_met THEN false
|
||||
WHEN v_yes + v_no = 0 THEN false
|
||||
WHEN v_threshold = 'simple_majority' THEN v_yes > v_no
|
||||
WHEN v_threshold = 'two_thirds' THEN v_yes * 3 >= (v_yes + v_no) * 2
|
||||
WHEN v_threshold = 'three_fourths' THEN v_yes * 4 >= (v_yes + v_no) * 3
|
||||
WHEN v_threshold = 'unanimous' THEN v_no = 0 AND v_yes > 0
|
||||
ELSE false
|
||||
END;
|
||||
|
||||
UPDATE public.proposals
|
||||
SET outcome_yes = v_yes,
|
||||
outcome_no = v_no,
|
||||
outcome_abstain = v_abstain,
|
||||
outcome_recused = v_recused,
|
||||
total_voters = v_total,
|
||||
quorum_met = v_quorum_met,
|
||||
passed = v_passed,
|
||||
updated_at = now()
|
||||
WHERE id = v_proposal_id;
|
||||
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_refresh_proposal_tally ON public.proposal_votes;
|
||||
CREATE TRIGGER trg_refresh_proposal_tally
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.proposal_votes
|
||||
FOR EACH ROW EXECUTE FUNCTION public.refresh_proposal_tally();
|
||||
|
||||
|
||||
-- 6.3 Sets incident.sla_response_by on insert if not provided.
|
||||
CREATE OR REPLACE FUNCTION public.compute_incident_sla()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_hours integer;
|
||||
BEGIN
|
||||
IF NEW.sla_response_by IS NULL THEN
|
||||
SELECT incident_sla_hours INTO v_hours FROM public.spaces WHERE id = NEW.space_id;
|
||||
NEW.sla_response_by := COALESCE(NEW.created_at, now()) + (COALESCE(v_hours, 72) || ' hours')::interval;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_compute_incident_sla ON public.incidents;
|
||||
CREATE TRIGGER trg_compute_incident_sla
|
||||
BEFORE INSERT ON public.incidents
|
||||
FOR EACH ROW EXECUTE FUNCTION public.compute_incident_sla();
|
||||
|
||||
|
||||
-- 6.4 Updated-at maintenance.
|
||||
CREATE OR REPLACE FUNCTION public.touch_updated_at()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_proposals_touch ON public.proposals;
|
||||
CREATE TRIGGER trg_proposals_touch
|
||||
BEFORE UPDATE ON public.proposals
|
||||
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_incidents_touch ON public.incidents;
|
||||
CREATE TRIGGER trg_incidents_touch
|
||||
BEFORE UPDATE ON public.incidents
|
||||
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_policies_touch ON public.policies;
|
||||
CREATE TRIGGER trg_policies_touch
|
||||
BEFORE UPDATE ON public.policies
|
||||
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 7. ROW LEVEL SECURITY
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE public.proposals ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.proposal_votes ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.incidents ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.incident_updates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.policies ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Drop first so re-runs are safe.
|
||||
DROP POLICY IF EXISTS proposals_select ON public.proposals;
|
||||
DROP POLICY IF EXISTS proposals_insert ON public.proposals;
|
||||
DROP POLICY IF EXISTS proposals_update ON public.proposals;
|
||||
DROP POLICY IF EXISTS proposals_delete ON public.proposals;
|
||||
DROP POLICY IF EXISTS votes_select ON public.proposal_votes;
|
||||
DROP POLICY IF EXISTS votes_insert ON public.proposal_votes;
|
||||
DROP POLICY IF EXISTS votes_update ON public.proposal_votes;
|
||||
DROP POLICY IF EXISTS incidents_select ON public.incidents;
|
||||
DROP POLICY IF EXISTS incidents_insert ON public.incidents;
|
||||
DROP POLICY IF EXISTS incidents_update ON public.incidents;
|
||||
DROP POLICY IF EXISTS incident_updates_select ON public.incident_updates;
|
||||
DROP POLICY IF EXISTS incident_updates_insert ON public.incident_updates;
|
||||
DROP POLICY IF EXISTS policies_select ON public.policies;
|
||||
DROP POLICY IF EXISTS policies_insert ON public.policies;
|
||||
DROP POLICY IF EXISTS policies_update ON public.policies;
|
||||
|
||||
-- proposals: any member can read and propose; proposer can edit while draft;
|
||||
-- admin/board can transition status. Delete: admin/board only.
|
||||
CREATE POLICY proposals_select ON public.proposals FOR SELECT
|
||||
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY proposals_insert ON public.proposals FOR INSERT
|
||||
WITH CHECK (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY proposals_update ON public.proposals FOR UPDATE
|
||||
USING (
|
||||
public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
|
||||
OR (
|
||||
status = 'draft'
|
||||
AND proposer_id IN (
|
||||
SELECT id FROM public.space_members WHERE user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
);
|
||||
CREATE POLICY proposals_delete ON public.proposals FOR DELETE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
|
||||
-- proposal_votes: all members see all votes (transparency). Members can only
|
||||
-- write their own vote. Can update while proposal still open. Never deleted.
|
||||
CREATE POLICY votes_select ON public.proposal_votes FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.proposals p
|
||||
WHERE p.id = proposal_votes.proposal_id
|
||||
AND p.space_id IN (SELECT public.get_user_space_ids(auth.uid()))
|
||||
)
|
||||
);
|
||||
CREATE POLICY votes_insert ON public.proposal_votes FOR INSERT
|
||||
WITH CHECK (
|
||||
member_id IN (
|
||||
SELECT id FROM public.space_members WHERE user_id = auth.uid()
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.proposals p
|
||||
WHERE p.id = proposal_votes.proposal_id
|
||||
AND p.status = 'open'
|
||||
AND p.voting_opens_at <= now()
|
||||
AND p.voting_closes_at > now()
|
||||
)
|
||||
);
|
||||
CREATE POLICY votes_update ON public.proposal_votes FOR UPDATE
|
||||
USING (
|
||||
member_id IN (
|
||||
SELECT id FROM public.space_members WHERE user_id = auth.uid()
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.proposals p
|
||||
WHERE p.id = proposal_votes.proposal_id
|
||||
AND p.status = 'open'
|
||||
AND p.voting_closes_at > now()
|
||||
)
|
||||
);
|
||||
|
||||
-- incidents: admin/board see all; reporter sees their own; named subjects do
|
||||
-- not see by default (board-only disposition until policy says otherwise).
|
||||
CREATE POLICY incidents_select ON public.incidents FOR SELECT
|
||||
USING (
|
||||
public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
|
||||
OR reporter_id IN (
|
||||
SELECT id FROM public.space_members WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
CREATE POLICY incidents_insert ON public.incidents FOR INSERT
|
||||
WITH CHECK (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY incidents_update ON public.incidents FOR UPDATE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
-- No DELETE policy: incidents are retained.
|
||||
|
||||
-- incident_updates: visible per the row's `visibility` field, with admin/board
|
||||
-- always able to read.
|
||||
CREATE POLICY incident_updates_select ON public.incident_updates FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.incidents i
|
||||
WHERE i.id = incident_updates.incident_id
|
||||
AND (
|
||||
public.user_has_role_in_space(auth.uid(), i.space_id, ARRAY['admin','board'])
|
||||
OR (
|
||||
incident_updates.visibility <> 'board_only'
|
||||
AND i.reporter_id IN (
|
||||
SELECT id FROM public.space_members WHERE user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
CREATE POLICY incident_updates_insert ON public.incident_updates FOR INSERT
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.incidents i
|
||||
WHERE i.id = incident_updates.incident_id
|
||||
AND (
|
||||
public.user_has_role_in_space(auth.uid(), i.space_id, ARRAY['admin','board'])
|
||||
OR i.reporter_id IN (
|
||||
SELECT id FROM public.space_members WHERE user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- policies: all members read; admin/board insert and supersede (which is an
|
||||
-- INSERT of a new version row, not an UPDATE). UPDATE allowed only for admin/
|
||||
-- board for status flips (deprecate, supersede). No DELETE.
|
||||
CREATE POLICY policies_select ON public.policies FOR SELECT
|
||||
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY policies_insert ON public.policies FOR INSERT
|
||||
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
CREATE POLICY policies_update ON public.policies FOR UPDATE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
@@ -0,0 +1,107 @@
|
||||
-- =============================================================================
|
||||
-- 017: Governance RLS hardening
|
||||
-- =============================================================================
|
||||
-- Pass 5's Tier 1 RLS allowed several cross-tenant edge cases when a user is
|
||||
-- a member of more than one space. The app's `requireMember()` uses `.single()`
|
||||
-- which makes multi-space practically impossible at the app layer, but RLS
|
||||
-- should be airtight regardless.
|
||||
--
|
||||
-- Bugs being closed (defense in depth):
|
||||
-- 1. proposal_votes.votes_insert / votes_update accepted a vote where
|
||||
-- member_id was from space A but the proposal was in space B.
|
||||
-- 2. incidents.incidents_insert let the reporter set reporter_id to any
|
||||
-- space_members row they own, including one in a different space.
|
||||
-- 3. incident_updates.incident_updates_insert did not validate author_id.
|
||||
--
|
||||
-- Also adds a partial unique index that prevents two `active` policy versions
|
||||
-- for the same slug — fixing a race when two admins concurrently activate
|
||||
-- different drafts.
|
||||
--
|
||||
-- Idempotent: drops policies / indexes first.
|
||||
-- =============================================================================
|
||||
|
||||
-- 1. proposal_votes: require member's space match the proposal's space.
|
||||
DROP POLICY IF EXISTS votes_insert ON public.proposal_votes;
|
||||
CREATE POLICY votes_insert ON public.proposal_votes FOR INSERT
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM public.proposals p
|
||||
JOIN public.space_members m ON m.id = proposal_votes.member_id
|
||||
WHERE p.id = proposal_votes.proposal_id
|
||||
AND m.user_id = auth.uid()
|
||||
AND m.space_id = p.space_id
|
||||
AND p.status = 'open'
|
||||
AND p.voting_opens_at <= now()
|
||||
AND p.voting_closes_at > now()
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS votes_update ON public.proposal_votes;
|
||||
CREATE POLICY votes_update ON public.proposal_votes FOR UPDATE
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM public.proposals p
|
||||
JOIN public.space_members m ON m.id = proposal_votes.member_id
|
||||
WHERE p.id = proposal_votes.proposal_id
|
||||
AND m.user_id = auth.uid()
|
||||
AND m.space_id = p.space_id
|
||||
AND p.status = 'open'
|
||||
AND p.voting_closes_at > now()
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
-- 2. incidents: reporter_id, when set, must be a space_members row owned by
|
||||
-- the caller AND in the same space as the incident.
|
||||
DROP POLICY IF EXISTS incidents_insert ON public.incidents;
|
||||
CREATE POLICY incidents_insert ON public.incidents FOR INSERT
|
||||
WITH CHECK (
|
||||
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
|
||||
AND (
|
||||
reporter_id IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.space_members m
|
||||
WHERE m.id = incidents.reporter_id
|
||||
AND m.user_id = auth.uid()
|
||||
AND m.space_id = incidents.space_id
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
-- 3. incident_updates: author_id, when set, must be a space_members row
|
||||
-- owned by the caller AND in the same space as the incident.
|
||||
DROP POLICY IF EXISTS incident_updates_insert ON public.incident_updates;
|
||||
CREATE POLICY incident_updates_insert ON public.incident_updates FOR INSERT
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.incidents i
|
||||
WHERE i.id = incident_updates.incident_id
|
||||
AND (
|
||||
public.user_has_role_in_space(auth.uid(), i.space_id, ARRAY['admin','board'])
|
||||
OR i.reporter_id IN (
|
||||
SELECT m.id FROM public.space_members m
|
||||
WHERE m.user_id = auth.uid() AND m.space_id = i.space_id
|
||||
)
|
||||
)
|
||||
)
|
||||
AND (
|
||||
author_id IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.space_members m, public.incidents i
|
||||
WHERE i.id = incident_updates.incident_id
|
||||
AND m.id = incident_updates.author_id
|
||||
AND m.user_id = auth.uid()
|
||||
AND m.space_id = i.space_id
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
-- 4. Exactly one active policy version per (space, slug).
|
||||
DROP INDEX IF EXISTS public.policies_one_active_per_slug;
|
||||
CREATE UNIQUE INDEX policies_one_active_per_slug
|
||||
ON public.policies (space_id, slug)
|
||||
WHERE status = 'active';
|
||||
@@ -0,0 +1,127 @@
|
||||
-- =============================================================================
|
||||
-- 018: Member-state extensions, visibility settings, KB meeting flag
|
||||
-- =============================================================================
|
||||
-- Tier 2 and Tier 3 of docs/GOVERNANCE_FEATURES.md (the smaller column-set
|
||||
-- extensions; UI for the rest follows in app code).
|
||||
--
|
||||
-- Adds:
|
||||
-- - Self-editable member profile fields: skills, interests, willing_to.
|
||||
-- - Conflict-of-interest disclosure fields: affiliations,
|
||||
-- coi_last_disclosed_at.
|
||||
-- - A BEFORE-UPDATE/INSERT trigger on space_members that auto-creates a
|
||||
-- 180-day card-access review task whenever has_card_access flips on.
|
||||
-- - Per-space settings for financial-data visibility and member-directory
|
||||
-- visibility (so spaces can change "who sees the cardholder count" without
|
||||
-- code changes).
|
||||
-- - knowledge_base flags for board meeting minutes: is_meeting_minutes and
|
||||
-- meeting_date.
|
||||
-- - inactive_members view: members with no activity_log entry in 180 days.
|
||||
--
|
||||
-- Idempotent.
|
||||
-- =============================================================================
|
||||
|
||||
|
||||
-- 1. ENUMS ----------------------------------------------------------------------
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE public.financial_visibility AS ENUM ('treasurer_only','board_visible','all_members_visible');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE public.directory_visibility AS ENUM ('board_only','member_count_visible','members_visible','public_members_visible');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
|
||||
-- 2. SPACES — visibility settings ---------------------------------------------
|
||||
ALTER TABLE public.spaces
|
||||
ADD COLUMN IF NOT EXISTS financial_visibility public.financial_visibility NOT NULL DEFAULT 'board_visible';
|
||||
ALTER TABLE public.spaces
|
||||
ADD COLUMN IF NOT EXISTS member_directory_visibility public.directory_visibility NOT NULL DEFAULT 'members_visible';
|
||||
|
||||
|
||||
-- 3. SPACE_MEMBERS — opt-in profile and COI ----------------------------------
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS skills text[] NOT NULL DEFAULT '{}'::text[];
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS interests text[] NOT NULL DEFAULT '{}'::text[];
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS willing_to text[] NOT NULL DEFAULT '{}'::text[];
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS affiliations text[] NOT NULL DEFAULT '{}'::text[];
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS coi_last_disclosed_at timestamptz;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_space_members_willing_to ON public.space_members USING GIN (willing_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_space_members_skills ON public.space_members USING GIN (skills);
|
||||
|
||||
|
||||
-- 4. KNOWLEDGE_BASE — meeting minutes flag -----------------------------------
|
||||
ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS is_meeting_minutes boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS meeting_date timestamptz;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_base_meeting_date
|
||||
ON public.knowledge_base (meeting_date DESC)
|
||||
WHERE is_meeting_minutes = true;
|
||||
|
||||
|
||||
-- 5. Card-access mid-tenure review trigger -----------------------------------
|
||||
-- Fires when has_card_access flips from false/null to true. Inserts a 180-day
|
||||
-- task into `tasks` so admin / board are reminded to re-evaluate access.
|
||||
CREATE OR REPLACE FUNCTION public.schedule_card_review()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_review_date timestamptz;
|
||||
v_old_card boolean;
|
||||
BEGIN
|
||||
v_old_card := COALESCE(OLD.has_card_access, false);
|
||||
IF NEW.has_card_access = true AND (TG_OP = 'INSERT' OR v_old_card = false) THEN
|
||||
v_review_date := now() + interval '180 days';
|
||||
INSERT INTO public.tasks (
|
||||
space_id,
|
||||
title,
|
||||
description,
|
||||
task_type,
|
||||
status,
|
||||
area,
|
||||
due_date,
|
||||
requested_by_name
|
||||
) VALUES (
|
||||
NEW.space_id,
|
||||
'Card-access review: ' || COALESCE(NULLIF(NEW.display_name, ''), 'member'),
|
||||
'Auto-generated 6-month review. Confirm card-access should continue. Any concerns from area leads, station champions, or hosts?',
|
||||
'task',
|
||||
'open',
|
||||
'Admin',
|
||||
v_review_date,
|
||||
'System'
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_schedule_card_review ON public.space_members;
|
||||
CREATE TRIGGER trg_schedule_card_review
|
||||
AFTER INSERT OR UPDATE OF has_card_access ON public.space_members
|
||||
FOR EACH ROW EXECUTE FUNCTION public.schedule_card_review();
|
||||
|
||||
|
||||
-- 6. inactive_members view ---------------------------------------------------
|
||||
-- Members in good standing who have no activity_log entry in the last 180
|
||||
-- days. View, not materialized — recomputed on read. Cheap because activity_log
|
||||
-- already has an index on (space_id, created_at DESC).
|
||||
CREATE OR REPLACE VIEW public.inactive_members AS
|
||||
SELECT m.*,
|
||||
a.last_activity_at
|
||||
FROM public.space_members m
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT max(created_at) AS last_activity_at
|
||||
FROM public.activity_log al
|
||||
WHERE al.user_id = m.user_id AND al.space_id = m.space_id
|
||||
) a ON true
|
||||
WHERE m.status IN ('current', 'late')
|
||||
AND m.approved = true
|
||||
AND (a.last_activity_at IS NULL OR a.last_activity_at < now() - interval '180 days');
|
||||
|
||||
-- Grant access via RLS-equivalent function. Views inherit RLS from underlying
|
||||
-- tables in Postgres only if the view owner is not a superuser. The view here
|
||||
-- is owned by `postgres`, which bypasses RLS. We therefore filter at the
|
||||
-- application layer (`/members` page already scopes by space_id when reading
|
||||
-- this view).
|
||||
@@ -0,0 +1,83 @@
|
||||
-- =============================================================================
|
||||
-- 019: Auto-expire open proposals whose voting window has closed
|
||||
-- =============================================================================
|
||||
-- Pass 5's open item: when `voting_closes_at < now()` on an `open` proposal,
|
||||
-- nothing flips the status. Admins have to click "Mark decided" manually.
|
||||
--
|
||||
-- This migration adds two pieces:
|
||||
--
|
||||
-- 1. A SECURITY DEFINER function `public.expire_proposals()` that finds
|
||||
-- every open proposal with a passed deadline and updates it:
|
||||
-- - If quorum is met, status becomes 'decided' and decided_at = now()
|
||||
-- (the trigger has already computed `passed`, so the result is final).
|
||||
-- - If quorum is NOT met, status becomes 'expired'.
|
||||
-- Returns the number of proposals it touched, so callers can monitor.
|
||||
--
|
||||
-- 2. An "on-read" optimisation: nothing here yet, deliberately. Polling
|
||||
-- `SELECT public.expire_proposals()` on a schedule is the recommended
|
||||
-- pattern. From a Next.js cron job, from pg_cron on Supabase, or from
|
||||
-- a /api/cron route guarded by a CRON_SECRET header.
|
||||
--
|
||||
-- Safe to re-run: CREATE OR REPLACE FUNCTION.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.expire_proposals()
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_decided integer;
|
||||
v_expired integer;
|
||||
BEGIN
|
||||
-- Quorum met -> decide.
|
||||
WITH updated AS (
|
||||
UPDATE public.proposals
|
||||
SET status = 'decided',
|
||||
decided_at = now()
|
||||
WHERE status = 'open'
|
||||
AND voting_closes_at IS NOT NULL
|
||||
AND voting_closes_at < now()
|
||||
AND quorum_met = true
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT count(*) INTO v_decided FROM updated;
|
||||
|
||||
-- Quorum not met -> expire.
|
||||
WITH updated AS (
|
||||
UPDATE public.proposals
|
||||
SET status = 'expired'
|
||||
WHERE status = 'open'
|
||||
AND voting_closes_at IS NOT NULL
|
||||
AND voting_closes_at < now()
|
||||
AND COALESCE(quorum_met, false) = false
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT count(*) INTO v_expired FROM updated;
|
||||
|
||||
RETURN v_decided + v_expired;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Optional: schedule with pg_cron if available. Self-hosted Supabase enables
|
||||
-- the extension by default; managed Supabase requires the user to enable it
|
||||
-- from Database → Extensions. The DO block below is best-effort and fails
|
||||
-- silently when pg_cron is not present.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron') THEN
|
||||
-- Unschedule any prior job with the same name (idempotent).
|
||||
PERFORM cron.unschedule(jobid)
|
||||
FROM cron.job
|
||||
WHERE jobname = 'expire-proposals-hourly';
|
||||
PERFORM cron.schedule(
|
||||
'expire-proposals-hourly',
|
||||
'0 * * * *',
|
||||
$cron$SELECT public.expire_proposals();$cron$
|
||||
);
|
||||
END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
-- pg_cron not installed or insufficient privileges. The function still
|
||||
-- exists; the operator can run it on whatever schedule they want.
|
||||
NULL;
|
||||
END $$;
|
||||
@@ -0,0 +1,109 @@
|
||||
-- =============================================================================
|
||||
-- 020: Per-space areas table with seeded defaults
|
||||
-- =============================================================================
|
||||
-- Currently `tasks.area`, `projects.area`, `knowledge_base.area`, etc are free
|
||||
-- text. The UI hard-codes a list of nine area names in each client component.
|
||||
-- This migration:
|
||||
--
|
||||
-- 1. Creates `space_areas`: rows of (space_id, code, name, icon, sort_order,
|
||||
-- is_archived). Each space has its own list.
|
||||
-- 2. Adds a trigger that seeds ten sensible defaults whenever a `spaces`
|
||||
-- row is inserted.
|
||||
-- 3. Backfills every existing space with the defaults (idempotent —
|
||||
-- skipped where the space already has at least one area row).
|
||||
--
|
||||
-- Existing `tasks.area` / `projects.area` text columns are unchanged; values
|
||||
-- still match by name. Renaming an area does NOT cascade; admins re-tag
|
||||
-- existing rows if they care. This is acceptable for a small CRUD app.
|
||||
--
|
||||
-- Idempotent.
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.space_areas (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
code text NOT NULL,
|
||||
name text NOT NULL,
|
||||
icon text,
|
||||
sort_order integer NOT NULL DEFAULT 100,
|
||||
is_archived boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (space_id, code),
|
||||
UNIQUE (space_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_space_areas_space_sort
|
||||
ON public.space_areas (space_id, sort_order, name);
|
||||
|
||||
ALTER TABLE public.space_areas ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS space_areas_select ON public.space_areas;
|
||||
DROP POLICY IF EXISTS space_areas_insert ON public.space_areas;
|
||||
DROP POLICY IF EXISTS space_areas_update ON public.space_areas;
|
||||
DROP POLICY IF EXISTS space_areas_delete ON public.space_areas;
|
||||
|
||||
CREATE POLICY space_areas_select ON public.space_areas FOR SELECT
|
||||
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY space_areas_insert ON public.space_areas FOR INSERT
|
||||
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
CREATE POLICY space_areas_update ON public.space_areas FOR UPDATE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
CREATE POLICY space_areas_delete ON public.space_areas FOR DELETE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_space_areas_touch ON public.space_areas;
|
||||
CREATE TRIGGER trg_space_areas_touch
|
||||
BEFORE UPDATE ON public.space_areas
|
||||
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
|
||||
|
||||
-- Seed defaults when a new space is created.
|
||||
CREATE OR REPLACE FUNCTION public.seed_default_areas()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.space_areas (space_id, code, name, sort_order) VALUES
|
||||
(NEW.id, '3d-printing', '3D Printing', 10),
|
||||
(NEW.id, 'electronics', 'Electronics', 20),
|
||||
(NEW.id, 'woodshop', 'Woodshop', 30),
|
||||
(NEW.id, 'laser', 'Laser', 40),
|
||||
(NEW.id, 'metal-shop', 'Metal Shop', 50),
|
||||
(NEW.id, 'facilities', 'Facilities', 60),
|
||||
(NEW.id, 'software', 'Software', 70),
|
||||
(NEW.id, 'kitchen', 'Kitchen', 80),
|
||||
(NEW.id, 'admin', 'Admin', 90),
|
||||
(NEW.id, 'general', 'General', 100)
|
||||
ON CONFLICT (space_id, code) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_seed_default_areas ON public.spaces;
|
||||
CREATE TRIGGER trg_seed_default_areas
|
||||
AFTER INSERT ON public.spaces
|
||||
FOR EACH ROW EXECUTE FUNCTION public.seed_default_areas();
|
||||
|
||||
|
||||
-- Backfill every existing space that has no areas yet.
|
||||
INSERT INTO public.space_areas (space_id, code, name, sort_order)
|
||||
SELECT s.id, d.code, d.name, d.sort_order
|
||||
FROM public.spaces s
|
||||
CROSS JOIN (VALUES
|
||||
('3d-printing', '3D Printing', 10),
|
||||
('electronics', 'Electronics', 20),
|
||||
('woodshop', 'Woodshop', 30),
|
||||
('laser', 'Laser', 40),
|
||||
('metal-shop', 'Metal Shop', 50),
|
||||
('facilities', 'Facilities', 60),
|
||||
('software', 'Software', 70),
|
||||
('kitchen', 'Kitchen', 80),
|
||||
('admin', 'Admin', 90),
|
||||
('general', 'General', 100)
|
||||
) AS d(code, name, sort_order)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM public.space_areas a WHERE a.space_id = s.id
|
||||
)
|
||||
ON CONFLICT (space_id, code) DO NOTHING;
|
||||
@@ -0,0 +1,104 @@
|
||||
# Database scripts
|
||||
|
||||
This directory holds the SQL that defines the application's database. The application is multi-tenant per `spaces` row; every other table is keyed by `space_id`. Row-level security is mandatory.
|
||||
|
||||
## Two kinds of file
|
||||
|
||||
### `schema.sql`
|
||||
|
||||
The canonical, full-schema file. Run this **once** in a fresh Supabase project's SQL editor. It is idempotent: extensions, enums, tables, indexes, policies, triggers, and realtime publications are all wrapped in guards so re-running is safe.
|
||||
|
||||
Sections in order:
|
||||
|
||||
1. Extensions
|
||||
2. Enums
|
||||
3. Tables (in FK-dependency order)
|
||||
4. Indexes
|
||||
5. Helper functions (`get_user_space_ids`, `user_has_role_in_space`)
|
||||
6. Row Level Security (drops before recreating)
|
||||
7. Triggers and auth hook (signup hook, default channels, privilege-escalation guard)
|
||||
8. Realtime publication membership
|
||||
|
||||
Anyone deploying for the first time needs only this file plus the environment variables documented in `.env.example`.
|
||||
|
||||
### `NNN_short_description.sql`
|
||||
|
||||
Incremental migrations for **existing** databases. Each file:
|
||||
|
||||
- Is named `NNN_short_description.sql` where `NNN` is the next zero-padded sequence number after the highest existing file in the directory.
|
||||
- Does one focused thing.
|
||||
- Is idempotent (`ALTER COLUMN ... DROP NOT NULL`, `CREATE OR REPLACE FUNCTION`, `DROP TRIGGER IF EXISTS` then `CREATE TRIGGER`).
|
||||
- Carries a short comment block at the top: background, what it does, safety notes.
|
||||
|
||||
When you add an incremental migration, you must also update `schema.sql` so that a fresh deploy ends up in the same final state.
|
||||
|
||||
## Current incremental migrations
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `014_member_user_id_nullable.sql` | Drops NOT NULL on `space_members.user_id` so offline members can be inserted by `addMember` / `importMembers`. |
|
||||
| `015_prevent_member_self_role_change.sql` | BEFORE UPDATE trigger that rejects self-modification of role, tier, status, approved, has_card_access, space_id by non-privileged members. Closes a privilege-escalation hole. |
|
||||
| `016_governance_kernel.sql` | Tier 1 of `docs/GOVERNANCE_FEATURES.md`: proposals + proposal_votes, incidents + incident_updates, policies. Plus six new `spaces` columns for governance defaults. Adds quorum-computing and tally-refreshing triggers. |
|
||||
| `017_governance_rls_hardening.sql` | Closes four cross-tenant RLS holes in the governance kernel (votes_insert/update, incidents_insert, incident_updates_insert), plus a partial unique index so only one policy version per slug can be `active` at a time. |
|
||||
| `018_member_state_and_visibility.sql` | Tier 2 + Tier 3: skills / interests / willing_to / affiliations / coi_last_disclosed_at columns on `space_members`; `financial_visibility` + `member_directory_visibility` settings on `spaces`; `is_meeting_minutes` + `meeting_date` on `knowledge_base`; auto-scheduled 180-day card-access review trigger; `inactive_members` view. |
|
||||
| `019_proposal_expiry.sql` | `public.expire_proposals()` function that closes proposals whose `voting_closes_at` has passed: flips to `decided` if quorum was met, `expired` otherwise. Schedules itself hourly via `pg_cron` if the extension is enabled. |
|
||||
| `020_areas.sql` | Per-space `space_areas` table (code, name, icon, sort_order, is_archived). Trigger seeds 10 sensible defaults on space creation; backfill populates existing spaces. RLS: members read, admin/board write, admin delete. |
|
||||
|
||||
### `seed.sql`
|
||||
|
||||
Demo data loader for local development. Run after `schema.sql` against an empty database to populate a "Demo Hackerspace" with five offline members (one per role), a policy, three tasks, a project, a KB entry, three proposals (open / draft / decided) with seven votes already cast, an open incident, and matching activity-log rows. Idempotent: re-running drops and reseeds the demo space without touching anything else.
|
||||
|
||||
Older migrations (001 to 013) lived in the original Supabase project and were collapsed into `schema.sql`. They are not in this directory.
|
||||
|
||||
## Workflow
|
||||
|
||||
When making a schema change:
|
||||
|
||||
1. Write the SQL as an incremental migration: `scripts/NNN_what_you_changed.sql`.
|
||||
2. Apply the same change to `schema.sql` so fresh deploys land in the new state.
|
||||
3. Re-run `schema.sql` against a scratch Supabase project to confirm it still completes without error.
|
||||
4. Regenerate types: `supabase gen types typescript --project-id <ref> > types/database.ts`. If you cannot run the CLI, hand-edit `types/database.ts` to match and note it in the PR.
|
||||
5. Update `DB_SCHEMA_MAP.md` if column names, defaults, or enum values changed.
|
||||
|
||||
## Naming
|
||||
|
||||
- Enums: singular, snake_case (`task_status`, not `task_statuses`).
|
||||
- Tables: plural, snake_case (`tasks`, `space_members`).
|
||||
- Foreign keys: `<other_table_singular>_id` (`space_id`, `member_id`).
|
||||
- Timestamps: `created_at`, `updated_at`, `<action>_at` (`claimed_at`, `completed_at`).
|
||||
- Policies: `<table>_<verb>` where verb is `select | insert | update | delete` (e.g. `members_insert`).
|
||||
|
||||
## Idempotency rules
|
||||
|
||||
- `CREATE TABLE IF NOT EXISTS` for every table.
|
||||
- `CREATE INDEX IF NOT EXISTS` for every index.
|
||||
- `DO $$ BEGIN ... EXCEPTION WHEN duplicate_object THEN NULL; END $$` for every enum.
|
||||
- `DROP POLICY IF EXISTS ...` immediately before `CREATE POLICY ...`.
|
||||
- `CREATE OR REPLACE FUNCTION` for every function.
|
||||
- `DROP TRIGGER IF EXISTS ...` before `CREATE TRIGGER ...`.
|
||||
- `DO $$ BEGIN ALTER PUBLICATION ... ADD TABLE ...; EXCEPTION WHEN duplicate_object THEN NULL; END $$` for realtime membership.
|
||||
|
||||
If you find yourself reaching for a non-idempotent statement, wrap it.
|
||||
|
||||
## Security baseline
|
||||
|
||||
Every new table must, before merging:
|
||||
|
||||
- Have `ENABLE ROW LEVEL SECURITY`.
|
||||
- Have at least a SELECT policy and INSERT policy.
|
||||
- Scope every policy by `space_id IN (SELECT public.get_user_space_ids(auth.uid()))` or a role check via `public.user_has_role_in_space(...)`.
|
||||
- Be covered in `docs/DATABASE_SCHEMA.md` and `DB_SCHEMA_MAP.md`.
|
||||
|
||||
## Verifying a fresh deploy
|
||||
|
||||
```sql
|
||||
-- After running schema.sql, sanity-check:
|
||||
SELECT count(*) FROM pg_tables WHERE schemaname = 'public';
|
||||
SELECT count(*) FROM pg_policies WHERE schemaname = 'public';
|
||||
SELECT count(*) FROM pg_trigger WHERE tgrelid IN (
|
||||
SELECT oid FROM pg_class WHERE relnamespace = 'public'::regnamespace
|
||||
);
|
||||
SELECT tablename FROM pg_publication_tables WHERE pubname = 'supabase_realtime';
|
||||
```
|
||||
|
||||
You should see roughly: 13 tables, around 45 policies, multiple triggers, and the two `comms_*` tables in `supabase_realtime`.
|
||||
@@ -1,23 +0,0 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const filePath = '/vercel/share/v0-project/lib/actions.ts'
|
||||
console.log('Patching:', filePath)
|
||||
let content = fs.readFileSync(filePath, 'utf8')
|
||||
|
||||
// Replace ALL .eq('user_id', user.id).eq('status', 'current').single() patterns
|
||||
content = content.replaceAll(
|
||||
`.eq('user_id', user.id).eq('status', 'current').single()`,
|
||||
`.eq('user_id', user.id).in('status', ['current', 'unverified', 'late']).single()`
|
||||
)
|
||||
|
||||
// Also the multi-line variant
|
||||
content = content.replaceAll(
|
||||
`.eq('user_id', user.id)\n .eq('status', 'current')\n .single()`,
|
||||
`.eq('user_id', user.id)\n .in('status', ['current', 'unverified', 'late'])\n .single()`
|
||||
)
|
||||
|
||||
fs.writeFileSync(filePath, content, 'utf8')
|
||||
console.log('Done. File length:', content.length)
|
||||
const remaining = (content.match(/eq\('status', 'current'\)/g) || []).length
|
||||
console.log('Remaining eq(status,current) occurrences:', remaining)
|
||||
@@ -1,258 +0,0 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// Find the actual project root by looking for package.json
|
||||
const candidates = [
|
||||
'/vercel/share/v0-project',
|
||||
process.cwd(),
|
||||
]
|
||||
|
||||
let root = null
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(path.join(c, 'package.json'))) {
|
||||
root = c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!root) {
|
||||
// Walk up from cwd
|
||||
let dir = process.cwd()
|
||||
while (dir !== '/') {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) { root = dir; break }
|
||||
dir = path.dirname(dir)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Project root:', root)
|
||||
const target = path.join(root, 'components', 'app-sidebar.tsx')
|
||||
console.log('Target file:', target)
|
||||
console.log('Exists:', fs.existsSync(target))
|
||||
|
||||
if (!fs.existsSync(target)) {
|
||||
// Try to find it
|
||||
function findFile(dir, name) {
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
for (const e of entries) {
|
||||
if (e.name === 'node_modules' || e.name === '.git') continue
|
||||
const full = path.join(dir, e.name)
|
||||
if (e.isDirectory()) {
|
||||
const found = findFile(full, name)
|
||||
if (found) return found
|
||||
} else if (e.name === name) return full
|
||||
}
|
||||
} catch {}
|
||||
return null
|
||||
}
|
||||
const found = findFile(root || '/', 'app-sidebar.tsx')
|
||||
console.log('Found at:', found)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let src = fs.readFileSync(target, 'utf8')
|
||||
|
||||
// Fix 1: add useEffect to import
|
||||
src = src.replace(
|
||||
`import { useState } from 'react'`,
|
||||
`import { useState, useEffect } from 'react'`
|
||||
)
|
||||
|
||||
// Fix 2: add useEffect after useState call
|
||||
src = src.replace(
|
||||
`const [drawerOpen, setDrawerOpen] = useState(false)`,
|
||||
`const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
// Auto-close drawer on route change — no onClick needed on nav links
|
||||
useEffect(() => { setDrawerOpen(false) }, [pathname])`
|
||||
)
|
||||
|
||||
// Fix 3: remove onClick from NavLink interface
|
||||
src = src.replace(` onClick?: () => void\n`, '')
|
||||
|
||||
// Fix 4: remove onClick from NavLink function params
|
||||
src = src.replace(
|
||||
`function NavLink({ href, label, icon: Icon, badge, active, onClick }: NavLinkProps) {`,
|
||||
`function NavLink({ href, label, icon: Icon, badge, active }: NavLinkProps) {`
|
||||
)
|
||||
|
||||
// Fix 5: remove onClick={onClick} from <Link>
|
||||
src = src.replace(` onClick={onClick}\n`, '')
|
||||
|
||||
// Fix 6: remove onClick from all NavLink usages
|
||||
src = src.replace(/ onClick=\{onNav\}/g, '')
|
||||
|
||||
fs.writeFileSync(target, src, 'utf8')
|
||||
console.log('Done! Wrote', src.length, 'bytes to', target)
|
||||
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { signOut } from '@/lib/auth-actions'
|
||||
import type { SpaceMember, Space } from '@/lib/types'
|
||||
import {
|
||||
Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2,
|
||||
MessageSquare, Users, CreditCard, BookUser, Download, LogOut,
|
||||
} from 'lucide-react'
|
||||
|
||||
function NavLink({
|
||||
href, label, icon: Icon, badge, active,
|
||||
}: {
|
||||
href: string
|
||||
label: string
|
||||
icon: React.ElementType
|
||||
badge?: number
|
||||
active: boolean
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'flex items-center justify-between mx-2 px-3 py-2 rounded-md text-sm transition-colors',
|
||||
active
|
||||
? 'bg-[var(--sidebar-primary)]/15 text-[var(--sidebar-primary)]'
|
||||
: 'text-[var(--sidebar-foreground)]/70 hover:bg-[var(--sidebar-accent)] hover:text-[var(--sidebar-foreground)]',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<Icon className={cn('w-4 h-4', active ? 'text-[var(--sidebar-primary)]' : 'text-[var(--sidebar-foreground)]/50')} />
|
||||
{label}
|
||||
</span>
|
||||
{badge && badge > 0 ? (
|
||||
<span className="text-[10px] bg-red-500 text-white rounded-full w-4 h-4 flex items-center justify-center font-medium">
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
interface AppSidebarProps {
|
||||
member: SpaceMember & { spaces: Space }
|
||||
taskBadge?: number
|
||||
commsBadge?: number
|
||||
paymentBadge?: number
|
||||
}
|
||||
|
||||
export function AppSidebar({ member, taskBadge = 0, commsBadge = 0, paymentBadge = 0 }: AppSidebarProps) {
|
||||
const pathname = usePathname()
|
||||
const space = member.spaces
|
||||
const initials = member.display_name?.split(' ').map((n) => n[0]).join('').toUpperCase().slice(0, 2) || '??'
|
||||
const isAdmin = member.role === 'admin' || member.role === 'board'
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
// Auto-close the drawer whenever the route changes
|
||||
useEffect(() => {
|
||||
setDrawerOpen(false)
|
||||
}, [pathname])
|
||||
|
||||
function isActive(href) {
|
||||
return pathname === href || pathname.startsWith(href + '/')
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
await signOut()
|
||||
}
|
||||
|
||||
const navJsx = (
|
||||
<>
|
||||
<div className="px-5 py-5 border-b border-[var(--sidebar-border)]">
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded bg-[var(--sidebar-primary)] flex items-center justify-center">
|
||||
<span className="text-white font-mono text-xs font-bold">{'{'}hs{'}'}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<p className="text-[var(--sidebar-foreground)]/60 text-xs mt-1.5 font-mono truncate">{space?.name || 'My Space'}</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-4 overflow-y-auto">
|
||||
<div className="px-3 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Workspace</p>
|
||||
</div>
|
||||
<NavLink href="/dashboard" label="Dashboard" icon={LayoutDashboard} active={isActive('/dashboard')} />
|
||||
<NavLink href="/tasks" label="Tasks and Chores" icon={ListChecks} active={isActive('/tasks')} badge={taskBadge} />
|
||||
<NavLink href="/projects" label="Projects" icon={FolderKanban} active={isActive('/projects')} />
|
||||
<NavLink href="/ops" label="Ops and Facilities" icon={Settings2} active={isActive('/ops')} />
|
||||
<NavLink href="/comms" label="Comms" icon={MessageSquare} active={isActive('/comms')} badge={commsBadge} />
|
||||
|
||||
<div className="px-3 mt-4 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">People</p>
|
||||
</div>
|
||||
<NavLink href="/members" label="Members" icon={Users} active={isActive('/members')} />
|
||||
<NavLink href="/payments" label="Payments" icon={CreditCard} active={isActive('/payments')} badge={paymentBadge} />
|
||||
<NavLink href="/contacts" label="Contacts" icon={BookUser} active={isActive('/contacts')} />
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="px-3 mt-4 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Admin</p>
|
||||
</div>
|
||||
<NavLink href="/import" label="Import and Sync" icon={Download} active={isActive('/import')} />
|
||||
<NavLink href="/settings" label="Settings" icon={Settings2} active={isActive('/settings')} />
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-[var(--sidebar-border)] p-3">
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="w-full flex items-center gap-3 px-2 py-2 rounded-md hover:bg-[var(--sidebar-accent)] transition-colors group"
|
||||
>
|
||||
<div className="w-7 h-7 rounded bg-[var(--sidebar-primary)]/80 flex items-center justify-center text-white text-xs font-bold shrink-0">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<p className="text-[var(--sidebar-foreground)] text-xs font-medium truncate">{member.display_name}</p>
|
||||
<p className="text-[var(--sidebar-foreground)]/40 text-[10px] font-mono">{member.role} and {member.tier}</p>
|
||||
</div>
|
||||
<LogOut className="w-3.5 h-3.5 text-[var(--sidebar-foreground)]/30" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="hidden md:flex w-[230px] shrink-0 bg-[var(--sidebar)] flex-col h-screen sticky top-0 border-r border-[var(--sidebar-border)]">
|
||||
{navJsx}
|
||||
</aside>
|
||||
|
||||
<div className="md:hidden fixed top-0 left-0 right-0 z-40 h-[52px] bg-[var(--sidebar)] border-b border-[var(--sidebar-border)] flex items-center justify-between px-4">
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded bg-[var(--sidebar-primary)] flex items-center justify-center">
|
||||
<span className="text-white font-mono text-[10px] font-bold">hs</span>
|
||||
</div>
|
||||
<span className="text-[var(--sidebar-foreground)]/60 text-xs font-mono truncate max-w-[150px]">{space?.name}</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="text-[var(--sidebar-foreground)]/70 hover:text-[var(--sidebar-foreground)] p-1"
|
||||
aria-label="Open navigation menu"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{drawerOpen && (
|
||||
<div className="md:hidden fixed inset-0 z-50 flex">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => setDrawerOpen(false)} />
|
||||
<div className="relative w-[280px] bg-[var(--sidebar)] flex flex-col h-full shadow-xl overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--sidebar-border)]">
|
||||
<span className="text-[var(--sidebar-foreground)] text-sm font-mono font-bold">{space?.name}</span>
|
||||
<button onClick={() => setDrawerOpen(false)} className="text-[var(--sidebar-foreground)]/50 hover:text-[var(--sidebar-foreground)]">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{navJsx}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
fs.writeFileSync('/vercel/share/v0-project/components/app-sidebar.tsx', content, 'utf8')
|
||||
console.log('Done. Wrote', content.length, 'bytes')
|
||||
@@ -1,180 +0,0 @@
|
||||
import fs from 'fs'
|
||||
|
||||
const content = `'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { signOut } from '@/lib/auth-actions'
|
||||
import type { SpaceMember, Space } from '@/lib/types'
|
||||
import {
|
||||
Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2,
|
||||
MessageSquare, Users, CreditCard, BookUser, Download, LogOut,
|
||||
} from 'lucide-react'
|
||||
|
||||
function NavLink({
|
||||
href, label, icon: Icon, badge, active,
|
||||
}: {
|
||||
href: string
|
||||
label: string
|
||||
icon: React.ElementType
|
||||
badge?: number
|
||||
active: boolean
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'flex items-center justify-between mx-2 px-3 py-2 rounded-md text-sm transition-colors',
|
||||
active
|
||||
? 'bg-[var(--sidebar-primary)]/15 text-[var(--sidebar-primary)]'
|
||||
: 'text-[var(--sidebar-foreground)]/70 hover:bg-[var(--sidebar-accent)] hover:text-[var(--sidebar-foreground)]',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<Icon className={cn('w-4 h-4', active ? 'text-[var(--sidebar-primary)]' : 'text-[var(--sidebar-foreground)]/50')} />
|
||||
{label}
|
||||
</span>
|
||||
{badge && badge > 0 ? (
|
||||
<span className="text-[10px] bg-red-500 text-white rounded-full w-4 h-4 flex items-center justify-center font-medium">
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
interface AppSidebarProps {
|
||||
member: SpaceMember & { spaces: Space }
|
||||
taskBadge?: number
|
||||
commsBadge?: number
|
||||
paymentBadge?: number
|
||||
}
|
||||
|
||||
export function AppSidebar({ member, taskBadge = 0, commsBadge = 0, paymentBadge = 0 }: AppSidebarProps) {
|
||||
const pathname = usePathname()
|
||||
const space = member.spaces
|
||||
const initials = member.display_name?.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || '??'
|
||||
const isAdmin = member.role === 'admin' || member.role === 'board'
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
// Auto-close the drawer whenever the route changes
|
||||
useEffect(() => {
|
||||
setDrawerOpen(false)
|
||||
}, [pathname])
|
||||
|
||||
function isActive(href: string) {
|
||||
return pathname === href || pathname.startsWith(href + '/')
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
await signOut()
|
||||
}
|
||||
|
||||
// navJsx is JSX stored in a variable — NOT a nested component.
|
||||
// This avoids React remounting it on every parent render, which was
|
||||
// causing the drawer to unmount before Next.js <Link> could navigate.
|
||||
const navJsx = (
|
||||
<>
|
||||
<div className="px-5 py-5 border-b border-[var(--sidebar-border)]">
|
||||
<Link href="/dashboard" className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded bg-[var(--sidebar-primary)] flex items-center justify-center">
|
||||
<span className="text-white font-mono text-xs font-bold">{'{'}<span>hs</span>{'}'}</span>
|
||||
</div>
|
||||
</Link>
|
||||
<p className="text-[var(--sidebar-foreground)]/60 text-xs mt-1.5 font-mono truncate">{space?.name || 'My Space'}</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 py-4 overflow-y-auto">
|
||||
<div className="px-3 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Workspace</p>
|
||||
</div>
|
||||
<NavLink href="/dashboard" label="Dashboard" icon={LayoutDashboard} active={isActive('/dashboard')} />
|
||||
<NavLink href="/tasks" label="Tasks & Chores" icon={ListChecks} active={isActive('/tasks')} badge={taskBadge} />
|
||||
<NavLink href="/projects" label="Projects" icon={FolderKanban} active={isActive('/projects')} />
|
||||
<NavLink href="/ops" label="Ops & Facilities" icon={Settings2} active={isActive('/ops')} />
|
||||
<NavLink href="/comms" label="Comms" icon={MessageSquare} active={isActive('/comms')} badge={commsBadge} />
|
||||
|
||||
<div className="px-3 mt-4 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">People</p>
|
||||
</div>
|
||||
<NavLink href="/members" label="Members" icon={Users} active={isActive('/members')} />
|
||||
<NavLink href="/payments" label="Payments" icon={CreditCard} active={isActive('/payments')} badge={paymentBadge} />
|
||||
<NavLink href="/contacts" label="Contacts" icon={BookUser} active={isActive('/contacts')} />
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="px-3 mt-4 mb-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Admin</p>
|
||||
</div>
|
||||
<NavLink href="/import" label="Import / Sync" icon={Download} active={isActive('/import')} />
|
||||
<NavLink href="/settings" label="Settings" icon={Settings2} active={isActive('/settings')} />
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-[var(--sidebar-border)] p-3">
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="w-full flex items-center gap-3 px-2 py-2 rounded-md hover:bg-[var(--sidebar-accent)] transition-colors group"
|
||||
>
|
||||
<div className="w-7 h-7 rounded bg-[var(--sidebar-primary)]/80 flex items-center justify-center text-white text-xs font-bold shrink-0">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<p className="text-[var(--sidebar-foreground)] text-xs font-medium truncate">{member.display_name}</p>
|
||||
<p className="text-[var(--sidebar-foreground)]/40 text-[10px] font-mono">{member.role} · {member.tier}</p>
|
||||
</div>
|
||||
<LogOut className="w-3.5 h-3.5 text-[var(--sidebar-foreground)]/30" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden md:flex w-[230px] shrink-0 bg-[var(--sidebar)] flex-col h-screen sticky top-0 border-r border-[var(--sidebar-border)]">
|
||||
{navJsx}
|
||||
</aside>
|
||||
|
||||
{/* Mobile: fixed top bar */}
|
||||
<div className="md:hidden fixed top-0 left-0 right-0 z-40 h-[52px] bg-[var(--sidebar)] border-b border-[var(--sidebar-border)] flex items-center justify-between px-4">
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded bg-[var(--sidebar-primary)] flex items-center justify-center">
|
||||
<span className="text-white font-mono text-[10px] font-bold">hs</span>
|
||||
</div>
|
||||
<span className="text-[var(--sidebar-foreground)]/60 text-xs font-mono truncate max-w-[150px]">{space?.name}</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="text-[var(--sidebar-foreground)]/70 hover:text-[var(--sidebar-foreground)] p-1"
|
||||
aria-label="Open navigation menu"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile: slide-out drawer */}
|
||||
{drawerOpen && (
|
||||
<div className="md:hidden fixed inset-0 z-50 flex">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={() => setDrawerOpen(false)} aria-hidden="true" />
|
||||
<div className="relative w-[280px] bg-[var(--sidebar)] flex flex-col h-full shadow-xl overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--sidebar-border)]">
|
||||
<span className="text-[var(--sidebar-foreground)] text-sm font-mono font-bold">{space?.name}</span>
|
||||
<button onClick={() => setDrawerOpen(false)} className="text-[var(--sidebar-foreground)]/50 hover:text-[var(--sidebar-foreground)]" aria-label="Close menu">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{navJsx}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
fs.writeFileSync('/vercel/share/v0-project/components/app-sidebar.tsx', content, 'utf8')
|
||||
console.log('Wrote app-sidebar.tsx successfully, length:', content.length)
|
||||
@@ -1,35 +0,0 @@
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
|
||||
const path = '/vercel/share/v0-project/app/(app)/dashboard/page.tsx'
|
||||
let content = readFileSync(path, 'utf8')
|
||||
|
||||
// 1. Fix the payments query
|
||||
content = content.replace(
|
||||
`.is('linked_member_id', null)`,
|
||||
`.eq('link_status', 'unlinked')`
|
||||
)
|
||||
|
||||
// 2. Add ClaimButton import after the redirect import
|
||||
if (!content.includes('ClaimButton')) {
|
||||
content = content.replace(
|
||||
"import { redirect } from 'next/navigation'",
|
||||
"import { redirect } from 'next/navigation'\nimport { ClaimButton } from '@/components/task-claim-button'"
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Replace the static CLAIM link with ClaimButton
|
||||
content = content.replace(
|
||||
/<Link href="\/tasks" className="font-mono text-\[10px\] border border-border px-2 py-0\.5 rounded hover:border-primary hover:text-primary transition">CLAIM<\/Link>/,
|
||||
'{task.status === \'open\' && <ClaimButton taskId={task.id} />}'
|
||||
)
|
||||
|
||||
// 4. Fix project.name -> project.title
|
||||
content = content.replace(/project\.name\b/g, 'project.title')
|
||||
|
||||
writeFileSync(path, content, 'utf8')
|
||||
console.log('[v0] Dashboard patched successfully')
|
||||
console.log('[v0] Content preview (lines 1-10):')
|
||||
content.split('\n').slice(0, 10).forEach((line, i) => console.log(`${i+1}: ${line}`))
|
||||
console.log('[v0] Check linked_member_id still present:', content.includes('linked_member_id'))
|
||||
console.log('[v0] Check link_status present:', content.includes('link_status'))
|
||||
console.log('[v0] Check ClaimButton import:', content.includes('ClaimButton'))
|
||||
+717
-3
@@ -64,10 +64,13 @@ CREATE TABLE IF NOT EXISTS public.spaces (
|
||||
);
|
||||
|
||||
-- space_members ---------------------------------------------------------------
|
||||
-- user_id is NULLABLE on purpose. The application supports "offline" members
|
||||
-- (added by an admin without an auth account). addMember() and importMembers()
|
||||
-- both insert rows with user_id = NULL.
|
||||
CREATE TABLE IF NOT EXISTS public.space_members (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
role public.member_role NOT NULL DEFAULT 'member',
|
||||
tier public.member_tier NOT NULL DEFAULT 'basic',
|
||||
status public.member_status NOT NULL DEFAULT 'unverified',
|
||||
@@ -384,7 +387,58 @@ $$;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 6. ROW LEVEL SECURITY
|
||||
-- Drops first so re-runs do not fail with "policy already exists".
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP POLICY IF EXISTS spaces_select ON public.spaces;
|
||||
DROP POLICY IF EXISTS spaces_insert ON public.spaces;
|
||||
DROP POLICY IF EXISTS spaces_update ON public.spaces;
|
||||
DROP POLICY IF EXISTS members_select ON public.space_members;
|
||||
DROP POLICY IF EXISTS members_insert ON public.space_members;
|
||||
DROP POLICY IF EXISTS members_update ON public.space_members;
|
||||
DROP POLICY IF EXISTS members_delete ON public.space_members;
|
||||
DROP POLICY IF EXISTS tasks_select ON public.tasks;
|
||||
DROP POLICY IF EXISTS tasks_insert ON public.tasks;
|
||||
DROP POLICY IF EXISTS tasks_update ON public.tasks;
|
||||
DROP POLICY IF EXISTS tasks_delete ON public.tasks;
|
||||
DROP POLICY IF EXISTS projects_select ON public.projects;
|
||||
DROP POLICY IF EXISTS projects_insert ON public.projects;
|
||||
DROP POLICY IF EXISTS projects_update ON public.projects;
|
||||
DROP POLICY IF EXISTS projects_delete ON public.projects;
|
||||
DROP POLICY IF EXISTS payments_select ON public.payments;
|
||||
DROP POLICY IF EXISTS payments_insert ON public.payments;
|
||||
DROP POLICY IF EXISTS payments_update ON public.payments;
|
||||
DROP POLICY IF EXISTS payments_delete ON public.payments;
|
||||
DROP POLICY IF EXISTS contacts_select ON public.contacts;
|
||||
DROP POLICY IF EXISTS contacts_insert ON public.contacts;
|
||||
DROP POLICY IF EXISTS contacts_update ON public.contacts;
|
||||
DROP POLICY IF EXISTS contacts_delete ON public.contacts;
|
||||
DROP POLICY IF EXISTS kb_select ON public.knowledge_base;
|
||||
DROP POLICY IF EXISTS kb_insert ON public.knowledge_base;
|
||||
DROP POLICY IF EXISTS kb_update ON public.knowledge_base;
|
||||
DROP POLICY IF EXISTS kb_delete ON public.knowledge_base;
|
||||
DROP POLICY IF EXISTS secrets_select ON public.secrets;
|
||||
DROP POLICY IF EXISTS secrets_insert ON public.secrets;
|
||||
DROP POLICY IF EXISTS secrets_update ON public.secrets;
|
||||
DROP POLICY IF EXISTS secrets_delete ON public.secrets;
|
||||
DROP POLICY IF EXISTS area_leads_select ON public.area_leads;
|
||||
DROP POLICY IF EXISTS area_leads_insert ON public.area_leads;
|
||||
DROP POLICY IF EXISTS area_leads_update ON public.area_leads;
|
||||
DROP POLICY IF EXISTS area_leads_delete ON public.area_leads;
|
||||
DROP POLICY IF EXISTS integrations_select ON public.integrations;
|
||||
DROP POLICY IF EXISTS integrations_insert ON public.integrations;
|
||||
DROP POLICY IF EXISTS integrations_update ON public.integrations;
|
||||
DROP POLICY IF EXISTS integrations_delete ON public.integrations;
|
||||
DROP POLICY IF EXISTS channels_select ON public.comms_channels;
|
||||
DROP POLICY IF EXISTS channels_insert ON public.comms_channels;
|
||||
DROP POLICY IF EXISTS channels_update ON public.comms_channels;
|
||||
DROP POLICY IF EXISTS channels_delete ON public.comms_channels;
|
||||
DROP POLICY IF EXISTS messages_select ON public.comms_messages;
|
||||
DROP POLICY IF EXISTS messages_insert ON public.comms_messages;
|
||||
DROP POLICY IF EXISTS messages_update ON public.comms_messages;
|
||||
DROP POLICY IF EXISTS messages_delete ON public.comms_messages;
|
||||
DROP POLICY IF EXISTS activity_select ON public.activity_log;
|
||||
DROP POLICY IF EXISTS activity_insert ON public.activity_log;
|
||||
|
||||
ALTER TABLE public.spaces ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.space_members ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.tasks ENABLE ROW LEVEL SECURITY;
|
||||
@@ -620,9 +674,669 @@ CREATE TRIGGER on_auth_user_created
|
||||
AFTER INSERT ON auth.users
|
||||
FOR EACH ROW EXECUTE FUNCTION public.handle_space_signup();
|
||||
|
||||
-- Privilege-escalation guard.
|
||||
-- RLS lets a member update their own space_members row (so they can fix their
|
||||
-- own display_name, handle, phone, etc.). Without this trigger, a member
|
||||
-- could PATCH role = 'admin' on themselves via a direct PostgREST call.
|
||||
-- The trigger fires on every UPDATE: if the user updating their own row is
|
||||
-- not already an admin/board/treasurer, any change to role/tier/status/
|
||||
-- approved is rejected. SECURITY DEFINER so it can read space_members
|
||||
-- regardless of RLS during the check.
|
||||
CREATE OR REPLACE FUNCTION public.prevent_member_self_role_change()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_is_privileged boolean;
|
||||
BEGIN
|
||||
-- Service role (no auth.uid) and inserts handled elsewhere: skip.
|
||||
IF auth.uid() IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- If the row being updated does not belong to the current user, the
|
||||
-- existing RLS already requires admin/board/treasurer. Nothing to add.
|
||||
IF NEW.user_id IS DISTINCT FROM auth.uid() THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Self-update. If the user already holds a privileged role IN THIS SPACE
|
||||
-- they may update anything (an admin editing their own row is fine).
|
||||
SELECT public.user_has_role_in_space(auth.uid(), NEW.space_id, ARRAY['admin','board','treasurer'])
|
||||
INTO v_is_privileged;
|
||||
|
||||
IF v_is_privileged THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Non-privileged self-update: protected columns must not change.
|
||||
IF NEW.role IS DISTINCT FROM OLD.role
|
||||
OR NEW.tier IS DISTINCT FROM OLD.tier
|
||||
OR NEW.status IS DISTINCT FROM OLD.status
|
||||
OR NEW.approved IS DISTINCT FROM OLD.approved
|
||||
OR NEW.has_card_access IS DISTINCT FROM OLD.has_card_access
|
||||
OR NEW.space_id IS DISTINCT FROM OLD.space_id THEN
|
||||
RAISE EXCEPTION 'Members cannot change their own role, tier, status, approval, card access, or space.'
|
||||
USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_prevent_member_self_role_change ON public.space_members;
|
||||
CREATE TRIGGER trg_prevent_member_self_role_change
|
||||
BEFORE UPDATE ON public.space_members
|
||||
FOR EACH ROW EXECUTE FUNCTION public.prevent_member_self_role_change();
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 8. REALTIME
|
||||
-- Wrapped in DO blocks so re-running this file is safe: ADD TABLE errors when
|
||||
-- the table is already a member of the publication.
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_messages;
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_channels;
|
||||
DO $$ BEGIN
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_messages;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_channels;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 9. GOVERNANCE KERNEL
|
||||
-- Source of truth: scripts/016_governance_kernel.sql.
|
||||
-- Tier 1 from docs/GOVERNANCE_FEATURES.md.
|
||||
-- Adds proposals + proposal_votes, incidents + incident_updates, policies.
|
||||
-- =============================================================================
|
||||
|
||||
-- 9.1 Enums ------------------------------------------------------------------
|
||||
DO $$ BEGIN CREATE TYPE public.proposal_type AS ENUM ('bylaw_change','board_action','membership_vote','advisory_poll','recall','budget'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.proposal_status AS ENUM ('draft','open','decided','withdrawn','expired'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.threshold_rule AS ENUM ('simple_majority','two_thirds','three_fourths','unanimous'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.vote_position AS ENUM ('yes','no','abstain','recused'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.incident_status AS ENUM ('received','under_review','decided','appealed','closed'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.incident_severity AS ENUM ('low','medium','high','critical'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.incident_update_visibility AS ENUM ('reporter_only','all_parties','board_only'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN CREATE TYPE public.policy_status AS ENUM ('draft','active','deprecated','superseded'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- 9.2 Spaces extensions ------------------------------------------------------
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_quorum_percent integer NOT NULL DEFAULT 10;
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_quorum_floor integer NOT NULL DEFAULT 1;
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_voting_window_hours integer NOT NULL DEFAULT 216;
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_threshold public.threshold_rule NOT NULL DEFAULT 'simple_majority';
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS incident_sla_hours integer NOT NULL DEFAULT 72;
|
||||
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS mission_statement text;
|
||||
|
||||
-- 9.3 Tables -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS public.proposals (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
proposer_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
|
||||
proposer_name text,
|
||||
title text NOT NULL,
|
||||
body text NOT NULL DEFAULT '',
|
||||
proposal_type public.proposal_type NOT NULL DEFAULT 'advisory_poll',
|
||||
status public.proposal_status NOT NULL DEFAULT 'draft',
|
||||
quorum_required integer NOT NULL DEFAULT 0,
|
||||
quorum_percent integer NOT NULL DEFAULT 10,
|
||||
quorum_floor integer NOT NULL DEFAULT 1,
|
||||
threshold public.threshold_rule NOT NULL DEFAULT 'simple_majority',
|
||||
voting_opens_at timestamptz,
|
||||
voting_closes_at timestamptz,
|
||||
policy_ref_id uuid,
|
||||
parent_incident_id uuid,
|
||||
outcome_yes integer NOT NULL DEFAULT 0,
|
||||
outcome_no integer NOT NULL DEFAULT 0,
|
||||
outcome_abstain integer NOT NULL DEFAULT 0,
|
||||
outcome_recused integer NOT NULL DEFAULT 0,
|
||||
total_voters integer NOT NULL DEFAULT 0,
|
||||
quorum_met boolean,
|
||||
passed boolean,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
decided_at timestamptz
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.proposal_votes (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
proposal_id uuid NOT NULL REFERENCES public.proposals(id) ON DELETE CASCADE,
|
||||
member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE,
|
||||
position public.vote_position NOT NULL,
|
||||
recusal_reason text,
|
||||
comment text,
|
||||
voted_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (proposal_id, member_id),
|
||||
CONSTRAINT recused_requires_reason
|
||||
CHECK (position <> 'recused' OR (recusal_reason IS NOT NULL AND length(trim(recusal_reason)) > 0))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.incidents (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
reporter_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
|
||||
reporter_token text UNIQUE,
|
||||
is_anonymous boolean NOT NULL DEFAULT false,
|
||||
subjects uuid[] NOT NULL DEFAULT '{}'::uuid[],
|
||||
category text NOT NULL DEFAULT 'general',
|
||||
severity public.incident_severity NOT NULL DEFAULT 'medium',
|
||||
title text NOT NULL,
|
||||
body text NOT NULL,
|
||||
status public.incident_status NOT NULL DEFAULT 'received',
|
||||
disposition text,
|
||||
decision_maker_ids uuid[] NOT NULL DEFAULT '{}'::uuid[],
|
||||
appeal_proposal_id uuid,
|
||||
sla_response_by timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
acknowledged_at timestamptz,
|
||||
decided_at timestamptz,
|
||||
closed_at timestamptz
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.incident_updates (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
incident_id uuid NOT NULL REFERENCES public.incidents(id) ON DELETE CASCADE,
|
||||
author_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
|
||||
author_name text,
|
||||
body text NOT NULL,
|
||||
visibility public.incident_update_visibility NOT NULL DEFAULT 'all_parties',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.policies (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
slug text NOT NULL,
|
||||
section_ref text,
|
||||
parent_policy_id uuid REFERENCES public.policies(id) ON DELETE SET NULL,
|
||||
title text NOT NULL,
|
||||
body_formal text NOT NULL DEFAULT '',
|
||||
body_plain text,
|
||||
version integer NOT NULL DEFAULT 1,
|
||||
prior_version_id uuid REFERENCES public.policies(id) ON DELETE SET NULL,
|
||||
status public.policy_status NOT NULL DEFAULT 'draft',
|
||||
effective_at timestamptz,
|
||||
adopted_by_proposal_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (space_id, slug, version)
|
||||
);
|
||||
|
||||
-- 9.4 Circular FKs -----------------------------------------------------------
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE public.proposals ADD CONSTRAINT proposals_policy_ref_fk
|
||||
FOREIGN KEY (policy_ref_id) REFERENCES public.policies(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE public.proposals ADD CONSTRAINT proposals_parent_incident_fk
|
||||
FOREIGN KEY (parent_incident_id) REFERENCES public.incidents(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE public.incidents ADD CONSTRAINT incidents_appeal_proposal_fk
|
||||
FOREIGN KEY (appeal_proposal_id) REFERENCES public.proposals(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE public.policies ADD CONSTRAINT policies_adopted_by_proposal_fk
|
||||
FOREIGN KEY (adopted_by_proposal_id) REFERENCES public.proposals(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- 9.5 Indexes ----------------------------------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS idx_proposals_space ON public.proposals (space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposals_status ON public.proposals (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposals_proposer ON public.proposals (proposer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposals_closes ON public.proposals (voting_closes_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposal_votes_proposal ON public.proposal_votes (proposal_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_proposal_votes_member ON public.proposal_votes (member_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_space ON public.incidents (space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_reporter ON public.incidents (reporter_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_status ON public.incidents (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_subjects ON public.incidents USING GIN (subjects);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_incidents_token_unique
|
||||
ON public.incidents (reporter_token) WHERE reporter_token IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_incident_updates_incident ON public.incident_updates (incident_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_policies_space_slug ON public.policies (space_id, slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_policies_status ON public.policies (status);
|
||||
|
||||
-- 9.6 Triggers ---------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION public.compute_proposal_quorum()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||
DECLARE
|
||||
v_member_count integer;
|
||||
v_quorum_percent integer;
|
||||
v_quorum_floor integer;
|
||||
v_voting_window_hours integer;
|
||||
BEGIN
|
||||
IF NEW.status = 'open' AND (TG_OP = 'INSERT' OR OLD.status = 'draft') THEN
|
||||
SELECT default_quorum_percent, default_quorum_floor, default_voting_window_hours
|
||||
INTO v_quorum_percent, v_quorum_floor, v_voting_window_hours
|
||||
FROM public.spaces WHERE id = NEW.space_id;
|
||||
SELECT count(*) INTO v_member_count
|
||||
FROM public.space_members
|
||||
WHERE space_id = NEW.space_id AND status IN ('current','late') AND approved = true;
|
||||
NEW.quorum_percent := COALESCE(v_quorum_percent, 10);
|
||||
NEW.quorum_floor := COALESCE(v_quorum_floor, 1);
|
||||
NEW.quorum_required := GREATEST(NEW.quorum_floor, CEIL(v_member_count * NEW.quorum_percent / 100.0)::integer);
|
||||
IF NEW.voting_opens_at IS NULL THEN NEW.voting_opens_at := now(); END IF;
|
||||
IF NEW.voting_closes_at IS NULL THEN
|
||||
NEW.voting_closes_at := NEW.voting_opens_at + (v_voting_window_hours || ' hours')::interval;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_compute_proposal_quorum ON public.proposals;
|
||||
CREATE TRIGGER trg_compute_proposal_quorum
|
||||
BEFORE INSERT OR UPDATE ON public.proposals
|
||||
FOR EACH ROW EXECUTE FUNCTION public.compute_proposal_quorum();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.refresh_proposal_tally()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||
DECLARE
|
||||
v_proposal_id uuid; v_yes integer; v_no integer; v_abstain integer; v_recused integer; v_total integer;
|
||||
v_quorum_required integer; v_threshold public.threshold_rule; v_status public.proposal_status;
|
||||
v_quorum_met boolean; v_passed boolean;
|
||||
BEGIN
|
||||
v_proposal_id := COALESCE(NEW.proposal_id, OLD.proposal_id);
|
||||
SELECT count(*) FILTER (WHERE position='yes'), count(*) FILTER (WHERE position='no'),
|
||||
count(*) FILTER (WHERE position='abstain'), count(*) FILTER (WHERE position='recused'), count(*)
|
||||
INTO v_yes, v_no, v_abstain, v_recused, v_total
|
||||
FROM public.proposal_votes WHERE proposal_id = v_proposal_id;
|
||||
SELECT quorum_required, threshold, status INTO v_quorum_required, v_threshold, v_status
|
||||
FROM public.proposals WHERE id = v_proposal_id;
|
||||
v_quorum_met := (v_yes + v_no + v_abstain) >= COALESCE(v_quorum_required, 0);
|
||||
v_passed := CASE
|
||||
WHEN NOT v_quorum_met THEN false
|
||||
WHEN v_yes + v_no = 0 THEN false
|
||||
WHEN v_threshold = 'simple_majority' THEN v_yes > v_no
|
||||
WHEN v_threshold = 'two_thirds' THEN v_yes * 3 >= (v_yes + v_no) * 2
|
||||
WHEN v_threshold = 'three_fourths' THEN v_yes * 4 >= (v_yes + v_no) * 3
|
||||
WHEN v_threshold = 'unanimous' THEN v_no = 0 AND v_yes > 0
|
||||
ELSE false END;
|
||||
UPDATE public.proposals
|
||||
SET outcome_yes=v_yes, outcome_no=v_no, outcome_abstain=v_abstain, outcome_recused=v_recused,
|
||||
total_voters=v_total, quorum_met=v_quorum_met, passed=v_passed, updated_at=now()
|
||||
WHERE id = v_proposal_id;
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_refresh_proposal_tally ON public.proposal_votes;
|
||||
CREATE TRIGGER trg_refresh_proposal_tally
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.proposal_votes
|
||||
FOR EACH ROW EXECUTE FUNCTION public.refresh_proposal_tally();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.compute_incident_sla()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||
DECLARE v_hours integer;
|
||||
BEGIN
|
||||
IF NEW.sla_response_by IS NULL THEN
|
||||
SELECT incident_sla_hours INTO v_hours FROM public.spaces WHERE id = NEW.space_id;
|
||||
NEW.sla_response_by := COALESCE(NEW.created_at, now()) + (COALESCE(v_hours, 72) || ' hours')::interval;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_compute_incident_sla ON public.incidents;
|
||||
CREATE TRIGGER trg_compute_incident_sla
|
||||
BEFORE INSERT ON public.incidents
|
||||
FOR EACH ROW EXECUTE FUNCTION public.compute_incident_sla();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.touch_updated_at()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN NEW.updated_at := now(); RETURN NEW; END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_proposals_touch ON public.proposals;
|
||||
CREATE TRIGGER trg_proposals_touch BEFORE UPDATE ON public.proposals FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
DROP TRIGGER IF EXISTS trg_incidents_touch ON public.incidents;
|
||||
CREATE TRIGGER trg_incidents_touch BEFORE UPDATE ON public.incidents FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
DROP TRIGGER IF EXISTS trg_policies_touch ON public.policies;
|
||||
CREATE TRIGGER trg_policies_touch BEFORE UPDATE ON public.policies FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
|
||||
-- 9.7 RLS --------------------------------------------------------------------
|
||||
ALTER TABLE public.proposals ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.proposal_votes ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.incidents ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.incident_updates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE public.policies ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS proposals_select ON public.proposals;
|
||||
DROP POLICY IF EXISTS proposals_insert ON public.proposals;
|
||||
DROP POLICY IF EXISTS proposals_update ON public.proposals;
|
||||
DROP POLICY IF EXISTS proposals_delete ON public.proposals;
|
||||
DROP POLICY IF EXISTS votes_select ON public.proposal_votes;
|
||||
DROP POLICY IF EXISTS votes_insert ON public.proposal_votes;
|
||||
DROP POLICY IF EXISTS votes_update ON public.proposal_votes;
|
||||
DROP POLICY IF EXISTS incidents_select ON public.incidents;
|
||||
DROP POLICY IF EXISTS incidents_insert ON public.incidents;
|
||||
DROP POLICY IF EXISTS incidents_update ON public.incidents;
|
||||
DROP POLICY IF EXISTS incident_updates_select ON public.incident_updates;
|
||||
DROP POLICY IF EXISTS incident_updates_insert ON public.incident_updates;
|
||||
DROP POLICY IF EXISTS policies_select ON public.policies;
|
||||
DROP POLICY IF EXISTS policies_insert ON public.policies;
|
||||
DROP POLICY IF EXISTS policies_update ON public.policies;
|
||||
|
||||
CREATE POLICY proposals_select ON public.proposals FOR SELECT
|
||||
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY proposals_insert ON public.proposals FOR INSERT
|
||||
WITH CHECK (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY proposals_update ON public.proposals FOR UPDATE
|
||||
USING (
|
||||
public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
|
||||
OR (status = 'draft' AND proposer_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid()))
|
||||
);
|
||||
CREATE POLICY proposals_delete ON public.proposals FOR DELETE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
|
||||
CREATE POLICY votes_select ON public.proposal_votes FOR SELECT
|
||||
USING (EXISTS (SELECT 1 FROM public.proposals p WHERE p.id = proposal_votes.proposal_id
|
||||
AND p.space_id IN (SELECT public.get_user_space_ids(auth.uid()))));
|
||||
CREATE POLICY votes_insert ON public.proposal_votes FOR INSERT
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM public.proposals p
|
||||
JOIN public.space_members m ON m.id = proposal_votes.member_id
|
||||
WHERE p.id = proposal_votes.proposal_id
|
||||
AND m.user_id = auth.uid()
|
||||
AND m.space_id = p.space_id
|
||||
AND p.status = 'open'
|
||||
AND p.voting_opens_at <= now()
|
||||
AND p.voting_closes_at > now()
|
||||
)
|
||||
);
|
||||
CREATE POLICY votes_update ON public.proposal_votes FOR UPDATE
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM public.proposals p
|
||||
JOIN public.space_members m ON m.id = proposal_votes.member_id
|
||||
WHERE p.id = proposal_votes.proposal_id
|
||||
AND m.user_id = auth.uid()
|
||||
AND m.space_id = p.space_id
|
||||
AND p.status = 'open'
|
||||
AND p.voting_closes_at > now()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY incidents_select ON public.incidents FOR SELECT
|
||||
USING (
|
||||
public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
|
||||
OR reporter_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid())
|
||||
);
|
||||
CREATE POLICY incidents_insert ON public.incidents FOR INSERT
|
||||
WITH CHECK (
|
||||
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
|
||||
AND (
|
||||
reporter_id IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.space_members m
|
||||
WHERE m.id = incidents.reporter_id
|
||||
AND m.user_id = auth.uid()
|
||||
AND m.space_id = incidents.space_id
|
||||
)
|
||||
)
|
||||
);
|
||||
CREATE POLICY incidents_update ON public.incidents FOR UPDATE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
|
||||
CREATE POLICY incident_updates_select ON public.incident_updates FOR SELECT
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM public.incidents i WHERE i.id = incident_updates.incident_id
|
||||
AND (public.user_has_role_in_space(auth.uid(), i.space_id, ARRAY['admin','board'])
|
||||
OR (incident_updates.visibility <> 'board_only'
|
||||
AND i.reporter_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid()))))
|
||||
);
|
||||
CREATE POLICY incident_updates_insert ON public.incident_updates FOR INSERT
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.incidents i
|
||||
WHERE i.id = incident_updates.incident_id
|
||||
AND (
|
||||
public.user_has_role_in_space(auth.uid(), i.space_id, ARRAY['admin','board'])
|
||||
OR i.reporter_id IN (
|
||||
SELECT m.id FROM public.space_members m
|
||||
WHERE m.user_id = auth.uid() AND m.space_id = i.space_id
|
||||
)
|
||||
)
|
||||
)
|
||||
AND (
|
||||
author_id IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.space_members m, public.incidents i
|
||||
WHERE i.id = incident_updates.incident_id
|
||||
AND m.id = incident_updates.author_id
|
||||
AND m.user_id = auth.uid()
|
||||
AND m.space_id = i.space_id
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY policies_select ON public.policies FOR SELECT
|
||||
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY policies_insert ON public.policies FOR INSERT
|
||||
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
CREATE POLICY policies_update ON public.policies FOR UPDATE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
|
||||
-- 9.8 Active-policy uniqueness ------------------------------------------------
|
||||
-- A race between two admins concurrently activating two different drafts of
|
||||
-- the same slug could leave two `active` rows. Partial unique index forbids
|
||||
-- it at the storage layer.
|
||||
DROP INDEX IF EXISTS public.policies_one_active_per_slug;
|
||||
CREATE UNIQUE INDEX policies_one_active_per_slug
|
||||
ON public.policies (space_id, slug)
|
||||
WHERE status = 'active';
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 10. MEMBER STATE + VISIBILITY (Tier 2 + Tier 3 of GOVERNANCE_FEATURES.md)
|
||||
-- Source of truth: scripts/018_member_state_and_visibility.sql.
|
||||
-- =============================================================================
|
||||
|
||||
-- 10.1 Enums
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE public.financial_visibility AS ENUM ('treasurer_only','board_visible','all_members_visible');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE public.directory_visibility AS ENUM ('board_only','member_count_visible','members_visible','public_members_visible');
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
-- 10.2 Spaces visibility settings
|
||||
ALTER TABLE public.spaces
|
||||
ADD COLUMN IF NOT EXISTS financial_visibility public.financial_visibility NOT NULL DEFAULT 'board_visible';
|
||||
ALTER TABLE public.spaces
|
||||
ADD COLUMN IF NOT EXISTS member_directory_visibility public.directory_visibility NOT NULL DEFAULT 'members_visible';
|
||||
|
||||
-- 10.3 Space-members opt-in profile and COI
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS skills text[] NOT NULL DEFAULT '{}'::text[];
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS interests text[] NOT NULL DEFAULT '{}'::text[];
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS willing_to text[] NOT NULL DEFAULT '{}'::text[];
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS affiliations text[] NOT NULL DEFAULT '{}'::text[];
|
||||
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS coi_last_disclosed_at timestamptz;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_space_members_willing_to ON public.space_members USING GIN (willing_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_space_members_skills ON public.space_members USING GIN (skills);
|
||||
|
||||
-- 10.4 Knowledge-base meeting minutes flag
|
||||
ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS is_meeting_minutes boolean NOT NULL DEFAULT false;
|
||||
ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS meeting_date timestamptz;
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_base_meeting_date
|
||||
ON public.knowledge_base (meeting_date DESC)
|
||||
WHERE is_meeting_minutes = true;
|
||||
|
||||
-- 10.5 Card-access mid-tenure review trigger
|
||||
CREATE OR REPLACE FUNCTION public.schedule_card_review()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_review_date timestamptz;
|
||||
v_old_card boolean;
|
||||
BEGIN
|
||||
v_old_card := COALESCE(OLD.has_card_access, false);
|
||||
IF NEW.has_card_access = true AND (TG_OP = 'INSERT' OR v_old_card = false) THEN
|
||||
v_review_date := now() + interval '180 days';
|
||||
INSERT INTO public.tasks (
|
||||
space_id, title, description, task_type, status, area, due_date, requested_by_name
|
||||
) VALUES (
|
||||
NEW.space_id,
|
||||
'Card-access review: ' || COALESCE(NULLIF(NEW.display_name, ''), 'member'),
|
||||
'Auto-generated 6-month review. Confirm card-access should continue. Any concerns from area leads, station champions, or hosts?',
|
||||
'task',
|
||||
'open',
|
||||
'Admin',
|
||||
v_review_date,
|
||||
'System'
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_schedule_card_review ON public.space_members;
|
||||
CREATE TRIGGER trg_schedule_card_review
|
||||
AFTER INSERT OR UPDATE OF has_card_access ON public.space_members
|
||||
FOR EACH ROW EXECUTE FUNCTION public.schedule_card_review();
|
||||
|
||||
-- 10.6 Inactive members view
|
||||
CREATE OR REPLACE VIEW public.inactive_members AS
|
||||
SELECT m.*,
|
||||
a.last_activity_at
|
||||
FROM public.space_members m
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT max(created_at) AS last_activity_at
|
||||
FROM public.activity_log al
|
||||
WHERE al.user_id = m.user_id AND al.space_id = m.space_id
|
||||
) a ON true
|
||||
WHERE m.status IN ('current', 'late')
|
||||
AND m.approved = true
|
||||
AND (a.last_activity_at IS NULL OR a.last_activity_at < now() - interval '180 days');
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 11. PROPOSAL EXPIRY
|
||||
-- Source: scripts/019_proposal_expiry.sql.
|
||||
-- Call public.expire_proposals() on a schedule to auto-close open proposals
|
||||
-- whose voting_closes_at has passed.
|
||||
-- =============================================================================
|
||||
CREATE OR REPLACE FUNCTION public.expire_proposals()
|
||||
RETURNS integer
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_decided integer;
|
||||
v_expired integer;
|
||||
BEGIN
|
||||
WITH updated AS (
|
||||
UPDATE public.proposals
|
||||
SET status = 'decided', decided_at = now()
|
||||
WHERE status = 'open'
|
||||
AND voting_closes_at IS NOT NULL
|
||||
AND voting_closes_at < now()
|
||||
AND quorum_met = true
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT count(*) INTO v_decided FROM updated;
|
||||
|
||||
WITH updated AS (
|
||||
UPDATE public.proposals
|
||||
SET status = 'expired'
|
||||
WHERE status = 'open'
|
||||
AND voting_closes_at IS NOT NULL
|
||||
AND voting_closes_at < now()
|
||||
AND COALESCE(quorum_met, false) = false
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT count(*) INTO v_expired FROM updated;
|
||||
|
||||
RETURN v_decided + v_expired;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron') THEN
|
||||
PERFORM cron.unschedule(jobid) FROM cron.job WHERE jobname = 'expire-proposals-hourly';
|
||||
PERFORM cron.schedule('expire-proposals-hourly', '0 * * * *', $cron$SELECT public.expire_proposals();$cron$);
|
||||
END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
NULL;
|
||||
END $$;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 12. SPACE AREAS
|
||||
-- Source: scripts/020_areas.sql.
|
||||
-- Each space gets its own list of physical areas / shops / categories.
|
||||
-- A trigger seeds ten sensible defaults when a space is created. Admin and
|
||||
-- board members manage the list afterwards.
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS public.space_areas (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
||||
code text NOT NULL,
|
||||
name text NOT NULL,
|
||||
icon text,
|
||||
sort_order integer NOT NULL DEFAULT 100,
|
||||
is_archived boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (space_id, code),
|
||||
UNIQUE (space_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_space_areas_space_sort
|
||||
ON public.space_areas (space_id, sort_order, name);
|
||||
|
||||
ALTER TABLE public.space_areas ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS space_areas_select ON public.space_areas;
|
||||
DROP POLICY IF EXISTS space_areas_insert ON public.space_areas;
|
||||
DROP POLICY IF EXISTS space_areas_update ON public.space_areas;
|
||||
DROP POLICY IF EXISTS space_areas_delete ON public.space_areas;
|
||||
|
||||
CREATE POLICY space_areas_select ON public.space_areas FOR SELECT
|
||||
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
||||
CREATE POLICY space_areas_insert ON public.space_areas FOR INSERT
|
||||
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
CREATE POLICY space_areas_update ON public.space_areas FOR UPDATE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
||||
CREATE POLICY space_areas_delete ON public.space_areas FOR DELETE
|
||||
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_space_areas_touch ON public.space_areas;
|
||||
CREATE TRIGGER trg_space_areas_touch
|
||||
BEFORE UPDATE ON public.space_areas
|
||||
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.seed_default_areas()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.space_areas (space_id, code, name, sort_order) VALUES
|
||||
(NEW.id, '3d-printing', '3D Printing', 10),
|
||||
(NEW.id, 'electronics', 'Electronics', 20),
|
||||
(NEW.id, 'woodshop', 'Woodshop', 30),
|
||||
(NEW.id, 'laser', 'Laser', 40),
|
||||
(NEW.id, 'metal-shop', 'Metal Shop', 50),
|
||||
(NEW.id, 'facilities', 'Facilities', 60),
|
||||
(NEW.id, 'software', 'Software', 70),
|
||||
(NEW.id, 'kitchen', 'Kitchen', 80),
|
||||
(NEW.id, 'admin', 'Admin', 90),
|
||||
(NEW.id, 'general', 'General', 100)
|
||||
ON CONFLICT (space_id, code) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_seed_default_areas ON public.spaces;
|
||||
CREATE TRIGGER trg_seed_default_areas
|
||||
AFTER INSERT ON public.spaces
|
||||
FOR EACH ROW EXECUTE FUNCTION public.seed_default_areas();
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
-- =============================================================================
|
||||
-- Seed data for local development
|
||||
-- =============================================================================
|
||||
-- Loads a demo space populated with offline members (no auth.users rows),
|
||||
-- tasks, a project, a knowledge-base entry, a policy, three proposals in
|
||||
-- different states, and an incident. Activity log entries reflect the work.
|
||||
--
|
||||
-- Run AFTER schema.sql + migrations:
|
||||
-- psql "$DATABASE_URL" -f scripts/seed.sql
|
||||
-- Or via Supabase CLI:
|
||||
-- supabase db reset && psql "$DATABASE_URL" -f scripts/seed.sql
|
||||
--
|
||||
-- Idempotent: ON CONFLICT DO NOTHING on the seed UUIDs.
|
||||
-- Wipes itself first by deleting rows scoped to the seed space_id.
|
||||
-- =============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_space_id uuid := '11111111-1111-1111-1111-111111111111';
|
||||
v_alice_id uuid := '22222222-2222-2222-2222-222222222221'; -- admin
|
||||
v_bob_id uuid := '22222222-2222-2222-2222-222222222222'; -- board
|
||||
v_carla_id uuid := '22222222-2222-2222-2222-222222222223'; -- treasurer
|
||||
v_dan_id uuid := '22222222-2222-2222-2222-222222222224'; -- member
|
||||
v_eve_id uuid := '22222222-2222-2222-2222-222222222225'; -- associate
|
||||
v_policy_id uuid := '33333333-3333-3333-3333-333333333331';
|
||||
v_proposal_open uuid := '44444444-4444-4444-4444-444444444441';
|
||||
v_proposal_draft uuid := '44444444-4444-4444-4444-444444444442';
|
||||
v_proposal_done uuid := '44444444-4444-4444-4444-444444444443';
|
||||
v_incident_id uuid := '55555555-5555-5555-5555-555555555551';
|
||||
v_task_open uuid := '66666666-6666-6666-6666-666666666661';
|
||||
v_task_chore uuid := '66666666-6666-6666-6666-666666666662';
|
||||
v_task_done uuid := '66666666-6666-6666-6666-666666666663';
|
||||
v_project_id uuid := '77777777-7777-7777-7777-777777777771';
|
||||
BEGIN
|
||||
-- Wipe prior seed (idempotent).
|
||||
DELETE FROM public.proposal_votes WHERE proposal_id IN (v_proposal_open, v_proposal_draft, v_proposal_done);
|
||||
DELETE FROM public.incident_updates WHERE incident_id = v_incident_id;
|
||||
DELETE FROM public.proposals WHERE space_id = v_space_id;
|
||||
DELETE FROM public.incidents WHERE space_id = v_space_id;
|
||||
DELETE FROM public.policies WHERE space_id = v_space_id;
|
||||
DELETE FROM public.tasks WHERE space_id = v_space_id;
|
||||
DELETE FROM public.projects WHERE space_id = v_space_id;
|
||||
DELETE FROM public.knowledge_base WHERE space_id = v_space_id;
|
||||
DELETE FROM public.activity_log WHERE space_id = v_space_id;
|
||||
DELETE FROM public.comms_channels WHERE space_id = v_space_id;
|
||||
DELETE FROM public.space_members WHERE space_id = v_space_id;
|
||||
DELETE FROM public.spaces WHERE id = v_space_id;
|
||||
|
||||
-- Space
|
||||
INSERT INTO public.spaces (
|
||||
id, name, slug, city, invite_code, require_approval, mission_statement,
|
||||
financial_visibility, member_directory_visibility,
|
||||
default_quorum_percent, default_quorum_floor, default_voting_window_hours,
|
||||
default_threshold, incident_sla_hours
|
||||
) VALUES (
|
||||
v_space_id, 'Demo Hackerspace', 'demo', 'Mesa, AZ', 'DEMO-2026-TEST', true,
|
||||
'A community-driven hackerspace committed to radical inclusivity, open tools, and shared stewardship.',
|
||||
'board_visible', 'members_visible',
|
||||
10, 2, 216, 'simple_majority', 72
|
||||
);
|
||||
|
||||
-- Members (all offline — no user_id; real signups via invite code attach later).
|
||||
INSERT INTO public.space_members (
|
||||
id, space_id, user_id, role, tier, status, approved,
|
||||
display_name, handle, email, phone, has_card_access,
|
||||
skills, interests, willing_to, affiliations, coi_last_disclosed_at
|
||||
) VALUES
|
||||
(v_alice_id, v_space_id, NULL, 'admin', 'plus', 'current', true,
|
||||
'Alice Admin', 'alice', 'alice@demo.local', '+1 555 0101', true,
|
||||
ARRAY['electronics','laser','3d printing'], ARRAY['cnc','robotics'],
|
||||
ARRAY['board_candidate','docs_steward'],
|
||||
ARRAY['ACME Robotics, contractor'], now() - interval '60 days'),
|
||||
(v_bob_id, v_space_id, NULL, 'board', 'plus', 'current', true,
|
||||
'Bob Board', 'bob', 'bob@demo.local', '+1 555 0102', true,
|
||||
ARRAY['welding','metal shop'], ARRAY['blacksmithing'],
|
||||
ARRAY['safety_committee','event_organizer'],
|
||||
ARRAY[]::text[], NULL),
|
||||
(v_carla_id, v_space_id, NULL, 'treasurer', 'plus', 'current', true,
|
||||
'Carla Treasurer', 'carla', 'carla@demo.local', '+1 555 0103', false,
|
||||
ARRAY['accounting','grant writing'], ARRAY['nonprofit law'],
|
||||
ARRAY['treasurer_candidate'],
|
||||
ARRAY['Local Credit Union, board'], now() - interval '15 days'),
|
||||
(v_dan_id, v_space_id, NULL, 'member', 'basic', 'current', true,
|
||||
'Dan Maker', 'dan', 'dan@demo.local', '+1 555 0104', true,
|
||||
ARRAY['woodshop','laser','arduino'], ARRAY['guitars','furniture'],
|
||||
ARRAY['host_volunteer','area_lead_candidate'],
|
||||
ARRAY[]::text[], NULL),
|
||||
(v_eve_id, v_space_id, NULL, 'associate', 'associate', 'current', true,
|
||||
'Eve Visitor', 'eve', 'eve@demo.local', NULL, false,
|
||||
ARRAY['photography'], ARRAY[]::text[],
|
||||
ARRAY[]::text[],
|
||||
ARRAY[]::text[], NULL);
|
||||
|
||||
-- The spaces trigger already created default comms channels (general,
|
||||
-- announcements, ops). No additional channel inserts needed.
|
||||
|
||||
-- Policies (one active v1, with a clear plain-language summary).
|
||||
INSERT INTO public.policies (
|
||||
id, space_id, slug, section_ref, title,
|
||||
body_plain, body_formal,
|
||||
version, status, effective_at
|
||||
) VALUES (
|
||||
v_policy_id, v_space_id, 'code-of-conduct', '1', 'Code of Conduct',
|
||||
'Be excellent to each other. No harassment, no theft, no unsafe behavior. Disagreements are resolved through proposals or through a board incident report. If you see a problem, file an incident.',
|
||||
E'## Article 1. Purpose\n\nThe Demo Hackerspace is a community workshop for makers, hackers, artists, and learners of all backgrounds.\n\n## Article 2. Conduct\n\n1. **Respect.** Treat every member, guest, and visitor with respect.\n2. **Safety.** Follow station-specific safety guidance. Do not operate equipment you have not been signed off on.\n3. **Stewardship.** Clean up after yourself. Report broken or unsafe equipment.\n\n## Article 3. Enforcement\n\nViolations are reported via the **Incidents** workflow. The board acknowledges within 72 hours and decides within 14 days. Members may appeal a dismissal to a membership vote per Article 4.\n\n## Article 4. Appeal\n\nAny member whose incident report is dismissed by the board may petition the membership for a vote. The petition opens an **Appeal** proposal; passage requires simple majority of cast votes with quorum of 10% or 2 members, whichever is greater.',
|
||||
1, 'active', now() - interval '90 days'
|
||||
);
|
||||
|
||||
-- Tasks (a mix).
|
||||
INSERT INTO public.tasks (
|
||||
id, space_id, title, description, task_type, status, area, recurrence,
|
||||
requested_by_name, claimed_by_name, due_date, created_at
|
||||
) VALUES
|
||||
(v_task_open, v_space_id, 'Wipe down laser cutter bed',
|
||||
'Acetone + lint-free cloth. Check the honeycomb for stuck bits.',
|
||||
'chore', 'open', 'Laser', 'weekly',
|
||||
'Alice Admin', NULL, now() + interval '2 days', now() - interval '1 day'),
|
||||
(v_task_chore, v_space_id, 'Empty trash bins',
|
||||
'All three bins by the door. Recycling goes in the blue bin.',
|
||||
'chore', 'claimed', 'Facilities', 'weekly',
|
||||
'Bob Board', 'Dan Maker', now() + interval '12 hours', now() - interval '3 days'),
|
||||
(v_task_done, v_space_id, 'Reorder filament',
|
||||
'PETG, black. Hatchbox 1kg. Reimburse via Carla.',
|
||||
'task', 'completed', '3D Printing', 'none',
|
||||
'Dan Maker', NULL, NULL, now() - interval '7 days');
|
||||
|
||||
-- A project.
|
||||
INSERT INTO public.projects (
|
||||
id, space_id, title, description, area, status, tags, assignee_names
|
||||
) VALUES (
|
||||
v_project_id, v_space_id, 'CNC mount for the woodshop bench',
|
||||
'Build a removable plate so the small CNC can share the workbench with hand work.',
|
||||
'Woodshop', 'in_progress', ARRAY['fab','urgent'],
|
||||
ARRAY['Dan Maker','Bob Board']
|
||||
);
|
||||
|
||||
-- A knowledge-base entry.
|
||||
INSERT INTO public.knowledge_base (
|
||||
space_id, title, content, area, visibility, is_pinned,
|
||||
updated_by_name, tags
|
||||
) VALUES (
|
||||
v_space_id, 'Door system: the incantations',
|
||||
E'The door fob system runs on `mqtt://door.local`.\n\n1. Add a new fob: scan in the admin app, paste the serial into Members > Edit.\n2. Disable a fob: flip `has_card_access` to false on the member row.\n3. If the door is stuck, power-cycle the relay module behind the bookcase.\n\nIf the entire system is down, the manual override key is in the lockbox by the back door. Code is on the wiki under "Door". This is not a secret; it is the failover.',
|
||||
'Facilities', 'all_members', true,
|
||||
'Alice Admin', ARRAY['door','access','infra']
|
||||
);
|
||||
|
||||
-- An OPEN proposal (members can vote on it).
|
||||
INSERT INTO public.proposals (
|
||||
id, space_id, proposer_id, proposer_name, title, body,
|
||||
proposal_type, status, threshold,
|
||||
voting_opens_at, voting_closes_at
|
||||
) VALUES (
|
||||
v_proposal_open, v_space_id, v_dan_id, 'Dan Maker',
|
||||
'Adopt a quarterly equipment-budget review',
|
||||
E'## Why\n\nWe spend on equipment without a regular review. Last year we duplicated a $400 saw and let a $1200 laser tube go uncovered for two months.\n\n## What I propose\n\n- Every quarter, the treasurer publishes a one-page equipment-budget report to /financials.\n- The board votes to allocate the next quarter''s budget.\n- Members can submit equipment requests via a Tasks-style queue.\n\n## Cost\n\nZero. Procedural change only.',
|
||||
'board_action', 'open', 'simple_majority',
|
||||
now() - interval '3 days', now() + interval '6 days'
|
||||
);
|
||||
|
||||
-- A DRAFT proposal (not yet open for voting).
|
||||
INSERT INTO public.proposals (
|
||||
id, space_id, proposer_id, proposer_name, title, body,
|
||||
proposal_type, status, threshold,
|
||||
voting_opens_at, voting_closes_at
|
||||
) VALUES (
|
||||
v_proposal_draft, v_space_id, v_alice_id, 'Alice Admin',
|
||||
'Bylaw change: raise quorum floor to 5',
|
||||
'Currently quorum floor is 2. Proposing 5 to better reflect membership growth. This is a draft — please leave comments before I open voting.',
|
||||
'bylaw_change', 'draft', 'two_thirds',
|
||||
NULL, NULL
|
||||
);
|
||||
|
||||
-- A DECIDED proposal (showcasing the archive).
|
||||
INSERT INTO public.proposals (
|
||||
id, space_id, proposer_id, proposer_name, title, body,
|
||||
proposal_type, status, threshold,
|
||||
voting_opens_at, voting_closes_at, decided_at
|
||||
) VALUES (
|
||||
v_proposal_done, v_space_id, v_bob_id, 'Bob Board',
|
||||
'Approve June facility deep-clean budget of $400',
|
||||
E'## Scope\n\nProfessional clean of the woodshop, electronics area, and bathroom. Bid from "ACME Cleaning" is $400 flat.\n\n## Why\n\nWe have not had a professional clean in 11 months. Member-volunteer cleans cover surface only.',
|
||||
'budget', 'decided', 'simple_majority',
|
||||
now() - interval '20 days', now() - interval '11 days', now() - interval '10 days'
|
||||
);
|
||||
|
||||
-- Votes on the open and decided proposals.
|
||||
INSERT INTO public.proposal_votes (proposal_id, member_id, position, comment) VALUES
|
||||
(v_proposal_open, v_alice_id, 'yes', 'Strongly support.'),
|
||||
(v_proposal_open, v_bob_id, 'yes', 'Yes.'),
|
||||
(v_proposal_open, v_carla_id, 'abstain', 'Will share data once a quarter regardless.'),
|
||||
(v_proposal_done, v_alice_id, 'yes', NULL),
|
||||
(v_proposal_done, v_bob_id, 'yes', NULL),
|
||||
(v_proposal_done, v_carla_id, 'yes', 'Money is set aside.'),
|
||||
(v_proposal_done, v_dan_id, 'no', 'Prefer member volunteer day instead.');
|
||||
|
||||
-- An open incident.
|
||||
INSERT INTO public.incidents (
|
||||
id, space_id, reporter_id, is_anonymous,
|
||||
category, severity, title, body, status, subjects
|
||||
) VALUES (
|
||||
v_incident_id, v_space_id, v_dan_id, false,
|
||||
'safety', 'high', 'Saw left running unattended in the woodshop on Friday',
|
||||
E'On Friday around 10pm I came in to use the woodshop. The 14" bandsaw was running with nobody nearby. I shut it off and left a note on the whiteboard.\n\nNo damage done, but this is a safety policy violation and I want it on record.',
|
||||
'received', ARRAY[]::uuid[]
|
||||
);
|
||||
|
||||
-- Seed activity log so the dashboard has recent activity to show.
|
||||
INSERT INTO public.activity_log (space_id, user_id, display_name, action, entity_type, entity_id, details, created_at) VALUES
|
||||
(v_space_id, NULL, 'Alice Admin', 'created', 'policy', v_policy_id, 'Code of Conduct v1', now() - interval '90 days'),
|
||||
(v_space_id, NULL, 'Bob Board', 'logged', 'payment', NULL, '$120 cash dues', now() - interval '12 days'),
|
||||
(v_space_id, NULL, 'Dan Maker', 'created', 'task', v_task_open, 'Wipe down laser cutter', now() - interval '1 day'),
|
||||
(v_space_id, NULL, 'Dan Maker', 'claimed', 'task', v_task_chore, NULL, now() - interval '2 days'),
|
||||
(v_space_id, NULL, 'Dan Maker', 'completed','task', v_task_done, NULL, now() - interval '6 days'),
|
||||
(v_space_id, NULL, 'Bob Board', 'opened', 'proposal', v_proposal_open, 'Quarterly budget review',now() - interval '3 days'),
|
||||
(v_space_id, NULL, 'Dan Maker', 'voted', 'proposal', v_proposal_open, 'yes', now() - interval '2 days'),
|
||||
(v_space_id, NULL, 'Dan Maker', 'filed', 'incident', v_incident_id, 'Saw left running', now() - interval '6 hours');
|
||||
|
||||
RAISE NOTICE 'Seed loaded. Space: % | invite code: DEMO-2026-TEST', v_space_id;
|
||||
END $$;
|
||||
@@ -0,0 +1,8 @@
|
||||
# Supabase
|
||||
.branches
|
||||
.temp
|
||||
|
||||
# dotenvx
|
||||
.env.keys
|
||||
.env.local
|
||||
.env.*.local
|
||||
@@ -0,0 +1,408 @@
|
||||
# For detailed configuration reference documentation, visit:
|
||||
# https://supabase.com/docs/guides/local-development/cli/config
|
||||
# A string used to distinguish different Supabase projects on the same host. Defaults to the
|
||||
# working directory name when running `supabase init`.
|
||||
project_id = "hackerspace-management"
|
||||
|
||||
[api]
|
||||
enabled = true
|
||||
# Port to use for the API URL.
|
||||
port = 54321
|
||||
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
|
||||
# endpoints. `public` and `graphql_public` schemas are included by default.
|
||||
schemas = ["public", "graphql_public"]
|
||||
# Extra schemas to add to the search_path of every request.
|
||||
extra_search_path = ["public", "extensions"]
|
||||
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
|
||||
# for accidental or malicious requests.
|
||||
max_rows = 1000
|
||||
|
||||
[api.tls]
|
||||
# Enable HTTPS endpoints locally using a self-signed certificate.
|
||||
enabled = false
|
||||
# Paths to self-signed certificate pair.
|
||||
# cert_path = "../certs/my-cert.pem"
|
||||
# key_path = "../certs/my-key.pem"
|
||||
|
||||
[db]
|
||||
# Port to use for the local database URL.
|
||||
port = 54322
|
||||
# Port used by db diff command to initialize the shadow database.
|
||||
shadow_port = 54320
|
||||
# Maximum amount of time to wait for health check when starting the local database.
|
||||
health_timeout = "2m"
|
||||
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
|
||||
# server_version;` on the remote database to check.
|
||||
major_version = 17
|
||||
|
||||
[db.pooler]
|
||||
enabled = false
|
||||
# Port to use for the local connection pooler.
|
||||
port = 54329
|
||||
# Specifies when a server connection can be reused by other clients.
|
||||
# Configure one of the supported pooler modes: `transaction`, `session`.
|
||||
pool_mode = "transaction"
|
||||
# How many server connections to allow per user/database pair.
|
||||
default_pool_size = 20
|
||||
# Maximum number of client connections allowed.
|
||||
max_client_conn = 100
|
||||
|
||||
# [db.vault]
|
||||
# secret_key = "env(SECRET_VALUE)"
|
||||
|
||||
[db.migrations]
|
||||
# If disabled, migrations will be skipped during a db push or reset.
|
||||
enabled = true
|
||||
# Specifies an ordered list of schema files that describe your database.
|
||||
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
|
||||
schema_paths = []
|
||||
|
||||
[db.seed]
|
||||
# If enabled, seeds the database after migrations during a db reset.
|
||||
enabled = true
|
||||
# Specifies an ordered list of seed files to load during db reset.
|
||||
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
|
||||
sql_paths = ["./seed.sql"]
|
||||
|
||||
[db.network_restrictions]
|
||||
# Enable management of network restrictions.
|
||||
enabled = false
|
||||
# List of IPv4 CIDR blocks allowed to connect to the database.
|
||||
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
|
||||
allowed_cidrs = ["0.0.0.0/0"]
|
||||
# List of IPv6 CIDR blocks allowed to connect to the database.
|
||||
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
|
||||
allowed_cidrs_v6 = ["::/0"]
|
||||
|
||||
# Uncomment to reject non-secure connections to the database.
|
||||
# [db.ssl_enforcement]
|
||||
# enabled = true
|
||||
|
||||
[realtime]
|
||||
enabled = true
|
||||
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
|
||||
# ip_version = "IPv6"
|
||||
# The maximum length in bytes of HTTP request headers. (default: 4096)
|
||||
# max_header_length = 4096
|
||||
|
||||
[studio]
|
||||
enabled = true
|
||||
# Port to use for Supabase Studio.
|
||||
port = 54323
|
||||
# External URL of the API server that frontend connects to.
|
||||
api_url = "http://127.0.0.1"
|
||||
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
|
||||
openai_api_key = "env(OPENAI_API_KEY)"
|
||||
|
||||
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
|
||||
# are monitored, and you can view the emails that would have been sent from the web interface.
|
||||
[inbucket]
|
||||
enabled = true
|
||||
# Port to use for the email testing server web interface.
|
||||
port = 54324
|
||||
# Uncomment to expose additional ports for testing user applications that send emails.
|
||||
# smtp_port = 54325
|
||||
# pop3_port = 54326
|
||||
# admin_email = "admin@email.com"
|
||||
# sender_name = "Admin"
|
||||
|
||||
[storage]
|
||||
enabled = true
|
||||
# The maximum file size allowed (e.g. "5MB", "500KB").
|
||||
file_size_limit = "50MiB"
|
||||
|
||||
# Uncomment to configure local storage buckets
|
||||
# [storage.buckets.images]
|
||||
# public = false
|
||||
# file_size_limit = "50MiB"
|
||||
# allowed_mime_types = ["image/png", "image/jpeg"]
|
||||
# objects_path = "./images"
|
||||
|
||||
# Allow connections via S3 compatible clients
|
||||
[storage.s3_protocol]
|
||||
enabled = true
|
||||
|
||||
# Image transformation API is available to Supabase Pro plan.
|
||||
# [storage.image_transformation]
|
||||
# enabled = true
|
||||
|
||||
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
|
||||
# This feature is only available on the hosted platform.
|
||||
[storage.analytics]
|
||||
enabled = false
|
||||
max_namespaces = 5
|
||||
max_tables = 10
|
||||
max_catalogs = 2
|
||||
|
||||
# Analytics Buckets is available to Supabase Pro plan.
|
||||
# [storage.analytics.buckets.my-warehouse]
|
||||
|
||||
# Store vector embeddings in S3 for large and durable datasets
|
||||
# This feature is only available on the hosted platform.
|
||||
[storage.vector]
|
||||
enabled = false
|
||||
max_buckets = 10
|
||||
max_indexes = 5
|
||||
|
||||
# Vector Buckets is available to Supabase Pro plan.
|
||||
# [storage.vector.buckets.documents-openai]
|
||||
|
||||
[auth]
|
||||
enabled = true
|
||||
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
|
||||
# in emails.
|
||||
site_url = "http://127.0.0.1:3000"
|
||||
# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
|
||||
# external_url = ""
|
||||
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
||||
additional_redirect_urls = ["https://127.0.0.1:3000"]
|
||||
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
||||
jwt_expiry = 3600
|
||||
# JWT issuer URL. If not set, defaults to auth.external_url.
|
||||
# jwt_issuer = ""
|
||||
# Path to JWT signing key. DO NOT commit your signing keys file to git.
|
||||
# signing_keys_path = "./signing_keys.json"
|
||||
# If disabled, the refresh token will never expire.
|
||||
enable_refresh_token_rotation = true
|
||||
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
|
||||
# Requires enable_refresh_token_rotation = true.
|
||||
refresh_token_reuse_interval = 10
|
||||
# Allow/disallow new user signups to your project.
|
||||
enable_signup = true
|
||||
# Allow/disallow anonymous sign-ins to your project.
|
||||
enable_anonymous_sign_ins = false
|
||||
# Allow/disallow testing manual linking of accounts
|
||||
enable_manual_linking = false
|
||||
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
|
||||
minimum_password_length = 6
|
||||
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
|
||||
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
|
||||
password_requirements = ""
|
||||
|
||||
# Configure passkey sign-ins.
|
||||
# [auth.passkey]
|
||||
# enabled = false
|
||||
|
||||
# Configure WebAuthn relying party settings (required when passkey is enabled).
|
||||
# [auth.webauthn]
|
||||
# rp_display_name = "Supabase"
|
||||
# rp_id = "localhost"
|
||||
# rp_origins = ["http://127.0.0.1:3000"]
|
||||
|
||||
[auth.rate_limit]
|
||||
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
|
||||
email_sent = 2
|
||||
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
|
||||
sms_sent = 30
|
||||
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
|
||||
anonymous_users = 30
|
||||
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
|
||||
token_refresh = 150
|
||||
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
|
||||
sign_in_sign_ups = 30
|
||||
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
|
||||
token_verifications = 30
|
||||
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
|
||||
web3 = 30
|
||||
|
||||
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
|
||||
# [auth.captcha]
|
||||
# enabled = true
|
||||
# provider = "hcaptcha"
|
||||
# secret = ""
|
||||
|
||||
[auth.email]
|
||||
# Allow/disallow new user signups via email to your project.
|
||||
enable_signup = true
|
||||
# If enabled, a user will be required to confirm any email change on both the old, and new email
|
||||
# addresses. If disabled, only the new email is required to confirm.
|
||||
double_confirm_changes = true
|
||||
# If enabled, users need to confirm their email address before signing in.
|
||||
enable_confirmations = false
|
||||
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
|
||||
secure_password_change = false
|
||||
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
|
||||
max_frequency = "1s"
|
||||
# Number of characters used in the email OTP.
|
||||
otp_length = 6
|
||||
# Number of seconds before the email OTP expires (defaults to 1 hour).
|
||||
otp_expiry = 3600
|
||||
|
||||
# Use a production-ready SMTP server
|
||||
# [auth.email.smtp]
|
||||
# enabled = true
|
||||
# host = "smtp.sendgrid.net"
|
||||
# port = 587
|
||||
# user = "apikey"
|
||||
# pass = "env(SENDGRID_API_KEY)"
|
||||
# admin_email = "admin@email.com"
|
||||
# sender_name = "Admin"
|
||||
|
||||
# Uncomment to customize email template
|
||||
# [auth.email.template.invite]
|
||||
# subject = "You have been invited"
|
||||
# content_path = "./supabase/templates/invite.html"
|
||||
|
||||
# Uncomment to customize notification email template
|
||||
# [auth.email.notification.password_changed]
|
||||
# enabled = true
|
||||
# subject = "Your password has been changed"
|
||||
# content_path = "./templates/password_changed_notification.html"
|
||||
|
||||
[auth.sms]
|
||||
# Allow/disallow new user signups via SMS to your project.
|
||||
enable_signup = false
|
||||
# If enabled, users need to confirm their phone number before signing in.
|
||||
enable_confirmations = false
|
||||
# Template for sending OTP to users
|
||||
template = "Your code is {{ .Code }}"
|
||||
# Controls the minimum amount of time that must pass before sending another sms otp.
|
||||
max_frequency = "5s"
|
||||
|
||||
# Use pre-defined map of phone number to OTP for testing.
|
||||
# [auth.sms.test_otp]
|
||||
# 4152127777 = "123456"
|
||||
|
||||
# Configure logged in session timeouts.
|
||||
# [auth.sessions]
|
||||
# Force log out after the specified duration.
|
||||
# timebox = "24h"
|
||||
# Force log out if the user has been inactive longer than the specified duration.
|
||||
# inactivity_timeout = "8h"
|
||||
|
||||
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
|
||||
# [auth.hook.before_user_created]
|
||||
# enabled = true
|
||||
# uri = "pg-functions://postgres/auth/before-user-created-hook"
|
||||
|
||||
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
|
||||
# [auth.hook.custom_access_token]
|
||||
# enabled = true
|
||||
# uri = "pg-functions://<database>/<schema>/<hook_name>"
|
||||
|
||||
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
|
||||
[auth.sms.twilio]
|
||||
enabled = false
|
||||
account_sid = ""
|
||||
message_service_sid = ""
|
||||
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
|
||||
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
|
||||
|
||||
# Multi-factor-authentication is available to Supabase Pro plan.
|
||||
[auth.mfa]
|
||||
# Control how many MFA factors can be enrolled at once per user.
|
||||
max_enrolled_factors = 10
|
||||
|
||||
# Control MFA via App Authenticator (TOTP)
|
||||
[auth.mfa.totp]
|
||||
enroll_enabled = false
|
||||
verify_enabled = false
|
||||
|
||||
# Configure MFA via Phone Messaging
|
||||
[auth.mfa.phone]
|
||||
enroll_enabled = false
|
||||
verify_enabled = false
|
||||
otp_length = 6
|
||||
template = "Your code is {{ .Code }}"
|
||||
max_frequency = "5s"
|
||||
|
||||
# Configure MFA via WebAuthn
|
||||
# [auth.mfa.web_authn]
|
||||
# enroll_enabled = true
|
||||
# verify_enabled = true
|
||||
|
||||
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
|
||||
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
|
||||
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
|
||||
[auth.external.apple]
|
||||
enabled = false
|
||||
client_id = ""
|
||||
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
|
||||
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
|
||||
# Overrides the default auth callback URL derived from auth.external_url.
|
||||
redirect_uri = ""
|
||||
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
|
||||
# or any other third-party OIDC providers.
|
||||
url = ""
|
||||
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
|
||||
skip_nonce_check = false
|
||||
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
|
||||
email_optional = false
|
||||
|
||||
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
|
||||
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
|
||||
[auth.web3.solana]
|
||||
enabled = false
|
||||
|
||||
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.firebase]
|
||||
enabled = false
|
||||
# project_id = "my-firebase-project"
|
||||
|
||||
# Use Auth0 as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.auth0]
|
||||
enabled = false
|
||||
# tenant = "my-auth0-tenant"
|
||||
# tenant_region = "us"
|
||||
|
||||
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.aws_cognito]
|
||||
enabled = false
|
||||
# user_pool_id = "my-user-pool-id"
|
||||
# user_pool_region = "us-east-1"
|
||||
|
||||
# Use Clerk as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.clerk]
|
||||
enabled = false
|
||||
# Obtain from https://clerk.com/setup/supabase
|
||||
# domain = "example.clerk.accounts.dev"
|
||||
|
||||
# OAuth server configuration
|
||||
[auth.oauth_server]
|
||||
# Enable OAuth server functionality
|
||||
enabled = false
|
||||
# Path for OAuth consent flow UI
|
||||
authorization_url_path = "/oauth/consent"
|
||||
# Allow dynamic client registration
|
||||
allow_dynamic_registration = false
|
||||
|
||||
[edge_runtime]
|
||||
enabled = true
|
||||
# Supported request policies: `oneshot`, `per_worker`.
|
||||
# `per_worker` (default) — enables hot reload during local development.
|
||||
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
|
||||
policy = "per_worker"
|
||||
# Port to attach the Chrome inspector for debugging edge functions.
|
||||
inspector_port = 8083
|
||||
# The Deno major version to use.
|
||||
deno_version = 2
|
||||
|
||||
# [edge_runtime.secrets]
|
||||
# secret_key = "env(SECRET_VALUE)"
|
||||
|
||||
[analytics]
|
||||
enabled = true
|
||||
port = 54327
|
||||
# Configure one of the supported backends: `postgres`, `bigquery`.
|
||||
backend = "postgres"
|
||||
|
||||
# Experimental features may be deprecated any time
|
||||
[experimental]
|
||||
# Configures Postgres storage engine to use OrioleDB (S3)
|
||||
orioledb_version = ""
|
||||
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
|
||||
s3_host = "env(S3_HOST)"
|
||||
# Configures S3 bucket region, eg. us-east-1
|
||||
s3_region = "env(S3_REGION)"
|
||||
# Configures AWS_ACCESS_KEY_ID for S3 bucket
|
||||
s3_access_key = "env(S3_ACCESS_KEY)"
|
||||
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
|
||||
s3_secret_key = "env(S3_SECRET_KEY)"
|
||||
|
||||
# [experimental.pgdelta]
|
||||
# When enabled, pg-delta becomes the active engine for supported schema flows.
|
||||
# enabled = false
|
||||
# Directory under `supabase/` where declarative files are written.
|
||||
# declarative_schema_path = "./database"
|
||||
# JSON string passed through to pg-delta SQL formatting.
|
||||
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
|
||||
+21
-4
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"target": "ES6",
|
||||
"skipLibCheck": true,
|
||||
@@ -19,9 +23,22 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "vitest.setup.ts"],
|
||||
"exclude": ["node_modules", "dist", "build"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"vitest.setup.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build"
|
||||
]
|
||||
}
|
||||
|
||||
+711
-8
@@ -7,10 +7,30 @@ export type Json =
|
||||
| Json[]
|
||||
|
||||
export type Database = {
|
||||
// Allows to automatically instantiate createClient with right options
|
||||
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
|
||||
__InternalSupabase: {
|
||||
PostgrestVersion: "14.1"
|
||||
graphql_public: {
|
||||
Tables: {
|
||||
[_ in never]: never
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
}
|
||||
Functions: {
|
||||
graphql: {
|
||||
Args: {
|
||||
extensions?: Json
|
||||
operationName?: string
|
||||
query?: string
|
||||
variables?: Json
|
||||
}
|
||||
Returns: Json
|
||||
}
|
||||
}
|
||||
Enums: {
|
||||
[_ in never]: never
|
||||
}
|
||||
CompositeTypes: {
|
||||
[_ in never]: never
|
||||
}
|
||||
}
|
||||
public: {
|
||||
Tables: {
|
||||
@@ -102,6 +122,13 @@ export type Database = {
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "area_leads_lead_id_fkey"
|
||||
columns: ["lead_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "inactive_members"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "area_leads_lead_id_fkey"
|
||||
columns: ["lead_id"]
|
||||
@@ -309,6 +336,156 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
incident_updates: {
|
||||
Row: {
|
||||
author_id: string | null
|
||||
author_name: string | null
|
||||
body: string
|
||||
created_at: string
|
||||
id: string
|
||||
incident_id: string
|
||||
visibility: Database["public"]["Enums"]["incident_update_visibility"]
|
||||
}
|
||||
Insert: {
|
||||
author_id?: string | null
|
||||
author_name?: string | null
|
||||
body: string
|
||||
created_at?: string
|
||||
id?: string
|
||||
incident_id: string
|
||||
visibility?: Database["public"]["Enums"]["incident_update_visibility"]
|
||||
}
|
||||
Update: {
|
||||
author_id?: string | null
|
||||
author_name?: string | null
|
||||
body?: string
|
||||
created_at?: string
|
||||
id?: string
|
||||
incident_id?: string
|
||||
visibility?: Database["public"]["Enums"]["incident_update_visibility"]
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "incident_updates_author_id_fkey"
|
||||
columns: ["author_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "inactive_members"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "incident_updates_author_id_fkey"
|
||||
columns: ["author_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "space_members"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "incident_updates_incident_id_fkey"
|
||||
columns: ["incident_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "incidents"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
incidents: {
|
||||
Row: {
|
||||
acknowledged_at: string | null
|
||||
appeal_proposal_id: string | null
|
||||
body: string
|
||||
category: string
|
||||
closed_at: string | null
|
||||
created_at: string
|
||||
decided_at: string | null
|
||||
decision_maker_ids: string[]
|
||||
disposition: string | null
|
||||
id: string
|
||||
is_anonymous: boolean
|
||||
reporter_id: string | null
|
||||
reporter_token: string | null
|
||||
severity: Database["public"]["Enums"]["incident_severity"]
|
||||
sla_response_by: string | null
|
||||
space_id: string
|
||||
status: Database["public"]["Enums"]["incident_status"]
|
||||
subjects: string[]
|
||||
title: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
acknowledged_at?: string | null
|
||||
appeal_proposal_id?: string | null
|
||||
body: string
|
||||
category?: string
|
||||
closed_at?: string | null
|
||||
created_at?: string
|
||||
decided_at?: string | null
|
||||
decision_maker_ids?: string[]
|
||||
disposition?: string | null
|
||||
id?: string
|
||||
is_anonymous?: boolean
|
||||
reporter_id?: string | null
|
||||
reporter_token?: string | null
|
||||
severity?: Database["public"]["Enums"]["incident_severity"]
|
||||
sla_response_by?: string | null
|
||||
space_id: string
|
||||
status?: Database["public"]["Enums"]["incident_status"]
|
||||
subjects?: string[]
|
||||
title: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
acknowledged_at?: string | null
|
||||
appeal_proposal_id?: string | null
|
||||
body?: string
|
||||
category?: string
|
||||
closed_at?: string | null
|
||||
created_at?: string
|
||||
decided_at?: string | null
|
||||
decision_maker_ids?: string[]
|
||||
disposition?: string | null
|
||||
id?: string
|
||||
is_anonymous?: boolean
|
||||
reporter_id?: string | null
|
||||
reporter_token?: string | null
|
||||
severity?: Database["public"]["Enums"]["incident_severity"]
|
||||
sla_response_by?: string | null
|
||||
space_id?: string
|
||||
status?: Database["public"]["Enums"]["incident_status"]
|
||||
subjects?: string[]
|
||||
title?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "incidents_appeal_proposal_fk"
|
||||
columns: ["appeal_proposal_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "proposals"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "incidents_reporter_id_fkey"
|
||||
columns: ["reporter_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "inactive_members"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "incidents_reporter_id_fkey"
|
||||
columns: ["reporter_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "space_members"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "incidents_space_id_fkey"
|
||||
columns: ["space_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "spaces"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
integrations: {
|
||||
Row: {
|
||||
config: Json | null
|
||||
@@ -372,7 +549,9 @@ export type Database = {
|
||||
created_by: string | null
|
||||
icon: string | null
|
||||
id: string
|
||||
is_meeting_minutes: boolean
|
||||
is_pinned: boolean
|
||||
meeting_date: string | null
|
||||
pinned: boolean
|
||||
space_id: string
|
||||
tags: string[] | null
|
||||
@@ -392,7 +571,9 @@ export type Database = {
|
||||
created_by?: string | null
|
||||
icon?: string | null
|
||||
id?: string
|
||||
is_meeting_minutes?: boolean
|
||||
is_pinned?: boolean
|
||||
meeting_date?: string | null
|
||||
pinned?: boolean
|
||||
space_id: string
|
||||
tags?: string[] | null
|
||||
@@ -412,7 +593,9 @@ export type Database = {
|
||||
created_by?: string | null
|
||||
icon?: string | null
|
||||
id?: string
|
||||
is_meeting_minutes?: boolean
|
||||
is_pinned?: boolean
|
||||
meeting_date?: string | null
|
||||
pinned?: boolean
|
||||
space_id?: string
|
||||
tags?: string[] | null
|
||||
@@ -431,6 +614,13 @@ export type Database = {
|
||||
referencedRelation: "spaces"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "knowledge_base_updated_by_id_fkey"
|
||||
columns: ["updated_by_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "inactive_members"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "knowledge_base_updated_by_id_fkey"
|
||||
columns: ["updated_by_id"]
|
||||
@@ -508,6 +698,13 @@ export type Database = {
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "payments_member_id_fkey"
|
||||
columns: ["member_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "inactive_members"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "payments_member_id_fkey"
|
||||
columns: ["member_id"]
|
||||
@@ -524,6 +721,89 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
policies: {
|
||||
Row: {
|
||||
adopted_by_proposal_id: string | null
|
||||
body_formal: string
|
||||
body_plain: string | null
|
||||
created_at: string
|
||||
effective_at: string | null
|
||||
id: string
|
||||
parent_policy_id: string | null
|
||||
prior_version_id: string | null
|
||||
section_ref: string | null
|
||||
slug: string
|
||||
space_id: string
|
||||
status: Database["public"]["Enums"]["policy_status"]
|
||||
title: string
|
||||
updated_at: string
|
||||
version: number
|
||||
}
|
||||
Insert: {
|
||||
adopted_by_proposal_id?: string | null
|
||||
body_formal?: string
|
||||
body_plain?: string | null
|
||||
created_at?: string
|
||||
effective_at?: string | null
|
||||
id?: string
|
||||
parent_policy_id?: string | null
|
||||
prior_version_id?: string | null
|
||||
section_ref?: string | null
|
||||
slug: string
|
||||
space_id: string
|
||||
status?: Database["public"]["Enums"]["policy_status"]
|
||||
title: string
|
||||
updated_at?: string
|
||||
version?: number
|
||||
}
|
||||
Update: {
|
||||
adopted_by_proposal_id?: string | null
|
||||
body_formal?: string
|
||||
body_plain?: string | null
|
||||
created_at?: string
|
||||
effective_at?: string | null
|
||||
id?: string
|
||||
parent_policy_id?: string | null
|
||||
prior_version_id?: string | null
|
||||
section_ref?: string | null
|
||||
slug?: string
|
||||
space_id?: string
|
||||
status?: Database["public"]["Enums"]["policy_status"]
|
||||
title?: string
|
||||
updated_at?: string
|
||||
version?: number
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "policies_adopted_by_proposal_fk"
|
||||
columns: ["adopted_by_proposal_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "proposals"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "policies_parent_policy_id_fkey"
|
||||
columns: ["parent_policy_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "policies"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "policies_prior_version_id_fkey"
|
||||
columns: ["prior_version_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "policies"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "policies_space_id_fkey"
|
||||
columns: ["space_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "spaces"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
projects: {
|
||||
Row: {
|
||||
area: string | null
|
||||
@@ -598,6 +878,181 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
proposal_votes: {
|
||||
Row: {
|
||||
comment: string | null
|
||||
id: string
|
||||
member_id: string
|
||||
position: Database["public"]["Enums"]["vote_position"]
|
||||
proposal_id: string
|
||||
recusal_reason: string | null
|
||||
voted_at: string
|
||||
}
|
||||
Insert: {
|
||||
comment?: string | null
|
||||
id?: string
|
||||
member_id: string
|
||||
position: Database["public"]["Enums"]["vote_position"]
|
||||
proposal_id: string
|
||||
recusal_reason?: string | null
|
||||
voted_at?: string
|
||||
}
|
||||
Update: {
|
||||
comment?: string | null
|
||||
id?: string
|
||||
member_id?: string
|
||||
position?: Database["public"]["Enums"]["vote_position"]
|
||||
proposal_id?: string
|
||||
recusal_reason?: string | null
|
||||
voted_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "proposal_votes_member_id_fkey"
|
||||
columns: ["member_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "inactive_members"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "proposal_votes_member_id_fkey"
|
||||
columns: ["member_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "space_members"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "proposal_votes_proposal_id_fkey"
|
||||
columns: ["proposal_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "proposals"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
proposals: {
|
||||
Row: {
|
||||
body: string
|
||||
created_at: string
|
||||
decided_at: string | null
|
||||
id: string
|
||||
outcome_abstain: number
|
||||
outcome_no: number
|
||||
outcome_recused: number
|
||||
outcome_yes: number
|
||||
parent_incident_id: string | null
|
||||
passed: boolean | null
|
||||
policy_ref_id: string | null
|
||||
proposal_type: Database["public"]["Enums"]["proposal_type"]
|
||||
proposer_id: string | null
|
||||
proposer_name: string | null
|
||||
quorum_floor: number
|
||||
quorum_met: boolean | null
|
||||
quorum_percent: number
|
||||
quorum_required: number
|
||||
space_id: string
|
||||
status: Database["public"]["Enums"]["proposal_status"]
|
||||
threshold: Database["public"]["Enums"]["threshold_rule"]
|
||||
title: string
|
||||
total_voters: number
|
||||
updated_at: string
|
||||
voting_closes_at: string | null
|
||||
voting_opens_at: string | null
|
||||
}
|
||||
Insert: {
|
||||
body?: string
|
||||
created_at?: string
|
||||
decided_at?: string | null
|
||||
id?: string
|
||||
outcome_abstain?: number
|
||||
outcome_no?: number
|
||||
outcome_recused?: number
|
||||
outcome_yes?: number
|
||||
parent_incident_id?: string | null
|
||||
passed?: boolean | null
|
||||
policy_ref_id?: string | null
|
||||
proposal_type?: Database["public"]["Enums"]["proposal_type"]
|
||||
proposer_id?: string | null
|
||||
proposer_name?: string | null
|
||||
quorum_floor?: number
|
||||
quorum_met?: boolean | null
|
||||
quorum_percent?: number
|
||||
quorum_required?: number
|
||||
space_id: string
|
||||
status?: Database["public"]["Enums"]["proposal_status"]
|
||||
threshold?: Database["public"]["Enums"]["threshold_rule"]
|
||||
title: string
|
||||
total_voters?: number
|
||||
updated_at?: string
|
||||
voting_closes_at?: string | null
|
||||
voting_opens_at?: string | null
|
||||
}
|
||||
Update: {
|
||||
body?: string
|
||||
created_at?: string
|
||||
decided_at?: string | null
|
||||
id?: string
|
||||
outcome_abstain?: number
|
||||
outcome_no?: number
|
||||
outcome_recused?: number
|
||||
outcome_yes?: number
|
||||
parent_incident_id?: string | null
|
||||
passed?: boolean | null
|
||||
policy_ref_id?: string | null
|
||||
proposal_type?: Database["public"]["Enums"]["proposal_type"]
|
||||
proposer_id?: string | null
|
||||
proposer_name?: string | null
|
||||
quorum_floor?: number
|
||||
quorum_met?: boolean | null
|
||||
quorum_percent?: number
|
||||
quorum_required?: number
|
||||
space_id?: string
|
||||
status?: Database["public"]["Enums"]["proposal_status"]
|
||||
threshold?: Database["public"]["Enums"]["threshold_rule"]
|
||||
title?: string
|
||||
total_voters?: number
|
||||
updated_at?: string
|
||||
voting_closes_at?: string | null
|
||||
voting_opens_at?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "proposals_parent_incident_fk"
|
||||
columns: ["parent_incident_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "incidents"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "proposals_policy_ref_fk"
|
||||
columns: ["policy_ref_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "policies"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "proposals_proposer_id_fkey"
|
||||
columns: ["proposer_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "inactive_members"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "proposals_proposer_id_fkey"
|
||||
columns: ["proposer_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "space_members"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "proposals_space_id_fkey"
|
||||
columns: ["space_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "spaces"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
secrets: {
|
||||
Row: {
|
||||
area: string | null
|
||||
@@ -654,11 +1109,57 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
space_areas: {
|
||||
Row: {
|
||||
code: string
|
||||
created_at: string
|
||||
icon: string | null
|
||||
id: string
|
||||
is_archived: boolean
|
||||
name: string
|
||||
sort_order: number
|
||||
space_id: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
code: string
|
||||
created_at?: string
|
||||
icon?: string | null
|
||||
id?: string
|
||||
is_archived?: boolean
|
||||
name: string
|
||||
sort_order?: number
|
||||
space_id: string
|
||||
updated_at?: string
|
||||
}
|
||||
Update: {
|
||||
code?: string
|
||||
created_at?: string
|
||||
icon?: string | null
|
||||
id?: string
|
||||
is_archived?: boolean
|
||||
name?: string
|
||||
sort_order?: number
|
||||
space_id?: string
|
||||
updated_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "space_areas_space_id_fkey"
|
||||
columns: ["space_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "spaces"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
space_members: {
|
||||
Row: {
|
||||
affiliations: string[]
|
||||
approved: boolean
|
||||
avatar_url: string | null
|
||||
bio: string | null
|
||||
coi_last_disclosed_at: string | null
|
||||
created_at: string
|
||||
display_name: string | null
|
||||
dues_paid_until: string | null
|
||||
@@ -666,6 +1167,7 @@ export type Database = {
|
||||
handle: string | null
|
||||
has_card_access: boolean
|
||||
id: string
|
||||
interests: string[]
|
||||
joined_at: string | null
|
||||
last_paid_at: string | null
|
||||
last_payment_at: string | null
|
||||
@@ -673,17 +1175,21 @@ export type Database = {
|
||||
payment_status: string | null
|
||||
phone: string | null
|
||||
role: Database["public"]["Enums"]["member_role"]
|
||||
skills: string[]
|
||||
space_id: string
|
||||
status: Database["public"]["Enums"]["member_status"]
|
||||
stripe_customer_id: string | null
|
||||
tier: Database["public"]["Enums"]["member_tier"]
|
||||
updated_at: string
|
||||
user_id: string
|
||||
user_id: string | null
|
||||
willing_to: string[]
|
||||
}
|
||||
Insert: {
|
||||
affiliations?: string[]
|
||||
approved?: boolean
|
||||
avatar_url?: string | null
|
||||
bio?: string | null
|
||||
coi_last_disclosed_at?: string | null
|
||||
created_at?: string
|
||||
display_name?: string | null
|
||||
dues_paid_until?: string | null
|
||||
@@ -691,6 +1197,7 @@ export type Database = {
|
||||
handle?: string | null
|
||||
has_card_access?: boolean
|
||||
id?: string
|
||||
interests?: string[]
|
||||
joined_at?: string | null
|
||||
last_paid_at?: string | null
|
||||
last_payment_at?: string | null
|
||||
@@ -698,17 +1205,21 @@ export type Database = {
|
||||
payment_status?: string | null
|
||||
phone?: string | null
|
||||
role?: Database["public"]["Enums"]["member_role"]
|
||||
skills?: string[]
|
||||
space_id: string
|
||||
status?: Database["public"]["Enums"]["member_status"]
|
||||
stripe_customer_id?: string | null
|
||||
tier?: Database["public"]["Enums"]["member_tier"]
|
||||
updated_at?: string
|
||||
user_id: string
|
||||
user_id?: string | null
|
||||
willing_to?: string[]
|
||||
}
|
||||
Update: {
|
||||
affiliations?: string[]
|
||||
approved?: boolean
|
||||
avatar_url?: string | null
|
||||
bio?: string | null
|
||||
coi_last_disclosed_at?: string | null
|
||||
created_at?: string
|
||||
display_name?: string | null
|
||||
dues_paid_until?: string | null
|
||||
@@ -716,6 +1227,7 @@ export type Database = {
|
||||
handle?: string | null
|
||||
has_card_access?: boolean
|
||||
id?: string
|
||||
interests?: string[]
|
||||
joined_at?: string | null
|
||||
last_paid_at?: string | null
|
||||
last_payment_at?: string | null
|
||||
@@ -723,12 +1235,14 @@ export type Database = {
|
||||
payment_status?: string | null
|
||||
phone?: string | null
|
||||
role?: Database["public"]["Enums"]["member_role"]
|
||||
skills?: string[]
|
||||
space_id?: string
|
||||
status?: Database["public"]["Enums"]["member_status"]
|
||||
stripe_customer_id?: string | null
|
||||
tier?: Database["public"]["Enums"]["member_tier"]
|
||||
updated_at?: string
|
||||
user_id?: string
|
||||
user_id?: string | null
|
||||
willing_to?: string[]
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
@@ -745,10 +1259,18 @@ export type Database = {
|
||||
address: string | null
|
||||
city: string | null
|
||||
created_at: string
|
||||
default_quorum_floor: number
|
||||
default_quorum_percent: number
|
||||
default_threshold: Database["public"]["Enums"]["threshold_rule"]
|
||||
default_voting_window_hours: number
|
||||
description: string | null
|
||||
financial_visibility: Database["public"]["Enums"]["financial_visibility"]
|
||||
id: string
|
||||
incident_sla_hours: number
|
||||
invite_code: string | null
|
||||
logo_url: string | null
|
||||
member_directory_visibility: Database["public"]["Enums"]["directory_visibility"]
|
||||
mission_statement: string | null
|
||||
name: string
|
||||
public_member_directory: boolean | null
|
||||
require_approval: boolean | null
|
||||
@@ -762,10 +1284,18 @@ export type Database = {
|
||||
address?: string | null
|
||||
city?: string | null
|
||||
created_at?: string
|
||||
default_quorum_floor?: number
|
||||
default_quorum_percent?: number
|
||||
default_threshold?: Database["public"]["Enums"]["threshold_rule"]
|
||||
default_voting_window_hours?: number
|
||||
description?: string | null
|
||||
financial_visibility?: Database["public"]["Enums"]["financial_visibility"]
|
||||
id?: string
|
||||
incident_sla_hours?: number
|
||||
invite_code?: string | null
|
||||
logo_url?: string | null
|
||||
member_directory_visibility?: Database["public"]["Enums"]["directory_visibility"]
|
||||
mission_statement?: string | null
|
||||
name: string
|
||||
public_member_directory?: boolean | null
|
||||
require_approval?: boolean | null
|
||||
@@ -779,10 +1309,18 @@ export type Database = {
|
||||
address?: string | null
|
||||
city?: string | null
|
||||
created_at?: string
|
||||
default_quorum_floor?: number
|
||||
default_quorum_percent?: number
|
||||
default_threshold?: Database["public"]["Enums"]["threshold_rule"]
|
||||
default_voting_window_hours?: number
|
||||
description?: string | null
|
||||
financial_visibility?: Database["public"]["Enums"]["financial_visibility"]
|
||||
id?: string
|
||||
incident_sla_hours?: number
|
||||
invite_code?: string | null
|
||||
logo_url?: string | null
|
||||
member_directory_visibility?: Database["public"]["Enums"]["directory_visibility"]
|
||||
mission_statement?: string | null
|
||||
name?: string
|
||||
public_member_directory?: boolean | null
|
||||
require_approval?: boolean | null
|
||||
@@ -898,9 +1436,51 @@ export type Database = {
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
inactive_members: {
|
||||
Row: {
|
||||
affiliations: string[] | null
|
||||
approved: boolean | null
|
||||
avatar_url: string | null
|
||||
bio: string | null
|
||||
coi_last_disclosed_at: string | null
|
||||
created_at: string | null
|
||||
display_name: string | null
|
||||
dues_paid_until: string | null
|
||||
email: string | null
|
||||
handle: string | null
|
||||
has_card_access: boolean | null
|
||||
id: string | null
|
||||
interests: string[] | null
|
||||
joined_at: string | null
|
||||
last_activity_at: string | null
|
||||
last_paid_at: string | null
|
||||
last_payment_at: string | null
|
||||
payment_note: string | null
|
||||
payment_status: string | null
|
||||
phone: string | null
|
||||
role: Database["public"]["Enums"]["member_role"] | null
|
||||
skills: string[] | null
|
||||
space_id: string | null
|
||||
status: Database["public"]["Enums"]["member_status"] | null
|
||||
stripe_customer_id: string | null
|
||||
tier: Database["public"]["Enums"]["member_tier"] | null
|
||||
updated_at: string | null
|
||||
user_id: string | null
|
||||
willing_to: string[] | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "space_members_space_id_fkey"
|
||||
columns: ["space_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "spaces"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
Functions: {
|
||||
expire_proposals: { Args: never; Returns: number }
|
||||
get_user_space_ids: { Args: { uid: string }; Returns: string[] }
|
||||
user_has_role_in_space: {
|
||||
Args: { allowed_roles: string[]; sid: string; uid: string }
|
||||
@@ -911,13 +1491,39 @@ export type Database = {
|
||||
area_lead_status: "active" | "vacant" | "handoff"
|
||||
channel_type: "general" | "area" | "ops" | "project"
|
||||
contact_type: "vendor" | "supplier" | "partner" | "landlord" | "city"
|
||||
directory_visibility:
|
||||
| "board_only"
|
||||
| "member_count_visible"
|
||||
| "members_visible"
|
||||
| "public_members_visible"
|
||||
financial_visibility:
|
||||
| "treasurer_only"
|
||||
| "board_visible"
|
||||
| "all_members_visible"
|
||||
incident_severity: "low" | "medium" | "high" | "critical"
|
||||
incident_status:
|
||||
| "received"
|
||||
| "under_review"
|
||||
| "decided"
|
||||
| "appealed"
|
||||
| "closed"
|
||||
incident_update_visibility: "reporter_only" | "all_parties" | "board_only"
|
||||
kb_visibility: "all_members" | "board" | "admin_only"
|
||||
member_role: "admin" | "board" | "treasurer" | "member" | "associate"
|
||||
member_status: "current" | "late" | "inactive" | "unverified"
|
||||
member_tier: "plus" | "basic" | "associate"
|
||||
payment_link_status: "linked" | "unlinked"
|
||||
payment_platform: "paypal" | "zeffy" | "venmo" | "cash"
|
||||
policy_status: "draft" | "active" | "deprecated" | "superseded"
|
||||
project_status: "backlog" | "in_progress" | "review" | "done" | "blocked"
|
||||
proposal_status: "draft" | "open" | "decided" | "withdrawn" | "expired"
|
||||
proposal_type:
|
||||
| "bylaw_change"
|
||||
| "board_action"
|
||||
| "membership_vote"
|
||||
| "advisory_poll"
|
||||
| "recall"
|
||||
| "budget"
|
||||
recurrence_type: "daily" | "weekly" | "biweekly" | "monthly" | "none"
|
||||
task_status:
|
||||
| "open"
|
||||
@@ -929,6 +1535,12 @@ export type Database = {
|
||||
| "done"
|
||||
| "blocked"
|
||||
task_type: "chore" | "task"
|
||||
threshold_rule:
|
||||
| "simple_majority"
|
||||
| "two_thirds"
|
||||
| "three_fourths"
|
||||
| "unanimous"
|
||||
vote_position: "yes" | "no" | "abstain" | "recused"
|
||||
}
|
||||
CompositeTypes: {
|
||||
[_ in never]: never
|
||||
@@ -1035,3 +1647,94 @@ export type Enums<
|
||||
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
|
||||
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
|
||||
: never
|
||||
|
||||
export type CompositeTypes<
|
||||
PublicCompositeTypeNameOrOptions extends
|
||||
| keyof DefaultSchema["CompositeTypes"]
|
||||
| { schema: keyof DatabaseWithoutInternals },
|
||||
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals
|
||||
}
|
||||
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
|
||||
: never = never,
|
||||
> = PublicCompositeTypeNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals
|
||||
}
|
||||
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
|
||||
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
|
||||
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
|
||||
: never
|
||||
|
||||
export const Constants = {
|
||||
graphql_public: {
|
||||
Enums: {},
|
||||
},
|
||||
public: {
|
||||
Enums: {
|
||||
area_lead_status: ["active", "vacant", "handoff"],
|
||||
channel_type: ["general", "area", "ops", "project"],
|
||||
contact_type: ["vendor", "supplier", "partner", "landlord", "city"],
|
||||
directory_visibility: [
|
||||
"board_only",
|
||||
"member_count_visible",
|
||||
"members_visible",
|
||||
"public_members_visible",
|
||||
],
|
||||
financial_visibility: [
|
||||
"treasurer_only",
|
||||
"board_visible",
|
||||
"all_members_visible",
|
||||
],
|
||||
incident_severity: ["low", "medium", "high", "critical"],
|
||||
incident_status: [
|
||||
"received",
|
||||
"under_review",
|
||||
"decided",
|
||||
"appealed",
|
||||
"closed",
|
||||
],
|
||||
incident_update_visibility: [
|
||||
"reporter_only",
|
||||
"all_parties",
|
||||
"board_only",
|
||||
],
|
||||
kb_visibility: ["all_members", "board", "admin_only"],
|
||||
member_role: ["admin", "board", "treasurer", "member", "associate"],
|
||||
member_status: ["current", "late", "inactive", "unverified"],
|
||||
member_tier: ["plus", "basic", "associate"],
|
||||
payment_link_status: ["linked", "unlinked"],
|
||||
payment_platform: ["paypal", "zeffy", "venmo", "cash"],
|
||||
policy_status: ["draft", "active", "deprecated", "superseded"],
|
||||
project_status: ["backlog", "in_progress", "review", "done", "blocked"],
|
||||
proposal_status: ["draft", "open", "decided", "withdrawn", "expired"],
|
||||
proposal_type: [
|
||||
"bylaw_change",
|
||||
"board_action",
|
||||
"membership_vote",
|
||||
"advisory_poll",
|
||||
"recall",
|
||||
"budget",
|
||||
],
|
||||
recurrence_type: ["daily", "weekly", "biweekly", "monthly", "none"],
|
||||
task_status: [
|
||||
"open",
|
||||
"claimed",
|
||||
"in_progress",
|
||||
"overdue",
|
||||
"due_today",
|
||||
"completed",
|
||||
"done",
|
||||
"blocked",
|
||||
],
|
||||
task_type: ["chore", "task"],
|
||||
threshold_rule: [
|
||||
"simple_majority",
|
||||
"two_thirds",
|
||||
"three_fourths",
|
||||
"unanimous",
|
||||
],
|
||||
vote_position: ["yes", "no", "abstain", "recused"],
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
|
||||
@@ -8,11 +8,20 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
// Vitest must not pick up Playwright e2e specs.
|
||||
include: ['__tests__/**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: [
|
||||
'node_modules/**',
|
||||
'e2e/**',
|
||||
'.next/**',
|
||||
'dist/**',
|
||||
],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'e2e/',
|
||||
'vitest.setup.ts',
|
||||
'**/*.d.ts',
|
||||
'**/types/**',
|
||||
|
||||
+3
-4
@@ -20,11 +20,10 @@ vi.mock('next/navigation', () => ({
|
||||
}))
|
||||
|
||||
// Mock next/image
|
||||
// Using React.createElement avoids JSX in a .ts file (esbuild would refuse).
|
||||
import React from 'react'
|
||||
vi.mock('next/image', () => ({
|
||||
default: (props: any) => {
|
||||
// eslint-disable-next-line jsx-a11y/alt-text
|
||||
return <img {...props} />
|
||||
},
|
||||
default: (props: any) => React.createElement('img', props),
|
||||
}))
|
||||
|
||||
// Suppress console errors in tests (optional)
|
||||
|
||||
Reference in New Issue
Block a user