diff --git a/.do/app.yaml b/.do/app.yaml new file mode 100644 index 0000000..2cac606 --- /dev/null +++ b/.do/app.yaml @@ -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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..651a18f --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fa9d078 --- /dev/null +++ b/.env.example @@ -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. +# ───────────────────────────────────────────────────────────────────────────── diff --git a/.gitignore b/.gitignore index 1cf3cf3..52013c4 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,10 @@ playwright-report/ # Vitest .vitest/ + +# TypeScript incremental build cache +*.tsbuildinfo + +# Supabase CLI local state +supabase/.branches/ +supabase/.temp/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3e9e3e4 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6431c37 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index aec4406..6bb4f0b 100644 --- a/README.md +++ b/README.md @@ -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 +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 - +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.). diff --git a/__tests__/auth-helpers.test.ts b/__tests__/auth-helpers.test.ts new file mode 100644 index 0000000..b0f8004 --- /dev/null +++ b/__tests__/auth-helpers.test.ts @@ -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() + } + }) +}) diff --git a/__tests__/governance.test.ts b/__tests__/governance.test.ts new file mode 100644 index 0000000..7c6b972 --- /dev/null +++ b/__tests__/governance.test.ts @@ -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']) + }) +}) diff --git a/__tests__/permissions.test.ts b/__tests__/permissions.test.ts new file mode 100644 index 0000000..8b8eb29 --- /dev/null +++ b/__tests__/permissions.test.ts @@ -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) + }) +}) diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts new file mode 100644 index 0000000..27f6c42 --- /dev/null +++ b/__tests__/security.test.ts @@ -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('')).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('

hello world

')).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) + }) +}) diff --git a/__tests__/validations.test.ts b/__tests__/validations.test.ts new file mode 100644 index 0000000..572491e --- /dev/null +++ b/__tests__/validations.test.ts @@ -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 ', () => { + 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) + }) +}) diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 765600c..2e9a716 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -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() { ))} + {(pendingProposals.length > 0 || pendingIncidents.length > 0) && ( +
+ {pendingProposals.length > 0 && ( +
+
+

Open proposals awaiting your vote

+ All +
+
+ {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 ( + +

{p.title}

+

+ {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' })}` : ''} + {' · '} + + quorum {quorumPct}% + +

+ + ) + })} +
+
+ )} + + {isAdminOrBoard && pendingIncidents.length > 0 && ( +
+
+

Incidents needing response

+ All +
+
+ {pendingIncidents.map(i => { + const overdueSla = i.sla_response_by && new Date(i.sla_response_by) < new Date() + return ( + +

{i.title}

+

+ {i.severity} · {i.status.replace(/_/g, ' ')} + {i.sla_response_by && ( + <> + {' · '} + + respond by {new Date(i.sla_response_by).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} + + + )} +

+ + ) + })} +
+
+ )} +
+ )} +
diff --git a/app/(app)/financials/page.tsx b/app/(app)/financials/page.tsx new file mode 100644 index 0000000..93725f9 --- /dev/null +++ b/app/(app)/financials/page.tsx @@ -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 & { financial_visibility: FinancialVisibility } | null + + if (!space) { + return

Space not found.

+ } + + if (!isVisible(space.financial_visibility, member.role)) { + return ( + +
+

Financial data is not visible to your role.

+

+ Visibility is currently set to {space.financial_visibility.replace(/_/g, ' ')}. + Ask an admin to change it from Settings if your space wants broader transparency. +

+
+
+ ) + } + + 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 = {} + const byMonth: Record = {} + + 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 ( + +

+ Visibility: {space.financial_visibility.replace(/_/g, ' ')} + {member.role === 'admin' && ( + <> + {' · '} + + change in Settings + + + )} +

+ +
+ + + + 0} + /> +
+ +
+

+ By platform +

+ {platformRows.length === 0 ? ( +

No payments recorded yet.

+ ) : ( +
    + {platformRows.map(([platform, total]) => { + const pct = Math.round((total / maxPlatform) * 100) + return ( +
  • +
    + {platform} + {DOLLAR.format(total)} +
    +
    +
    +
    +
  • + ) + })} +
+ )} +
+ +
+

+ Last 12 months +

+ {monthlyRows.length === 0 ? ( +

No monthly breakdown yet.

+ ) : ( +
+ {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 ( +
+ {label} + {DOLLAR.format(total)} +
+ ) + })} +
+ )} +
+
+ ) +} + +function PageShell({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+
+
{children}
+
+ ) +} + +function StatCard({ label, value, sub, warn }: { label: string; value: string; sub: string; warn?: boolean }) { + return ( +
+

{label}

+

{value}

+

{sub}

+
+ ) +} diff --git a/app/(app)/incidents/[id]/incident-actions.tsx b/app/(app)/incidents/[id]/incident-actions.tsx new file mode 100644 index 0000000..47af991 --- /dev/null +++ b/app/(app)/incidents/[id]/incident-actions.tsx @@ -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('all_parties') + + // Status transition form (admin/board) + const [newStatus, setNewStatus] = useState(status) + const [disposition, setDisposition] = useState('') + + // Appeal form (reporter) + const [appealTitle, setAppealTitle] = useState('') + const [appealBody, setAppealBody] = useState('') + + function run(fn: () => Promise, 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 ( +
+ {isAdminOrBoard && ( +
+

+ Update status +

+
+ + {(newStatus === 'decided' || newStatus === 'closed') && ( +