chore: clear audit backlog (types sync, email norm, docs, members shortcut)
Finishes all remaining tracked work from the full-app audit. - types/database.ts synced: added the 8 missing tables (forum_threads, comments, space_tiers, space_invites, space_onboarding_steps, space_role_labels, space_custom_roles, space_member_custom_roles), the comment_entity_type enum (type + Constants), and the missing columns (space_members.tier_id/onboarding_completed_at/ onboarding_progress, secrets.encrypted_value/encryption_version, knowledge_base.render_markdown). - Email canonicalization: new emailField() helper (trim+lowercase) applied to signIn/signUp/addMember/updateMember/createContact and the import path; prevents case-variant duplicates and sign-in mismatches. - Members directory: per-row "make area lead" picker (admin/board) assigns a member to any area-lead role; mirrors Customize. - Docs refreshed and re-dated: DATABASE_SCHEMA.md (migrations 014-024 summary), DB_SCHEMA_MAP.md (new-table quick map), API_REFERENCE.md (all server actions from migrations 016-024). schema.sql named as source of truth. Backlog is now empty. 271/271 tests pass, build clean. No migration.
This commit is contained in:
@@ -11,6 +11,11 @@ All notable changes to this project are documented here. Format follows [Keep a
|
|||||||
- Customize -> Area leads: create area-lead roles, see Vacant until assigned, assign/unassign a member from a picker. The assigned member effectively holds that role, so any Ops item whose access list includes it opens to them.
|
- Customize -> Area leads: create area-lead roles, see Vacant until assigned, assign/unassign a member from a picker. The assigned member effectively holds that role, so any Ops item whose access list includes it opens to them.
|
||||||
- SECURITY DEFINER helpers `user_effective_roles` and `user_has_permission`.
|
- SECURITY DEFINER helpers `user_effective_roles` and `user_has_permission`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- All emails are canonicalized to trimmed lowercase (sign-in, sign-up, add/update member, contacts, imports) so case/whitespace variants no longer create duplicate members or fail sign-in.
|
||||||
|
- `types/database.ts` brought in sync with the schema (8 tables, the `comment_entity_type` enum, and the new columns); docs (`DATABASE_SCHEMA.md`, `DB_SCHEMA_MAP.md`, `API_REFERENCE.md`) refreshed for migrations 016-024.
|
||||||
|
- The member directory has a per-row "make area lead" assignment for admin/board (mirrors Customize -> Area leads).
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- Closed two member self-escalation paths: a non-privileged member can no longer change their own `tier_id` or `onboarding_completed_at` on the `space_members` row (migration 024 extends the self-change trigger). Onboarding completion now goes through the service client after server-side validation.
|
- Closed two member self-escalation paths: a non-privileged member can no longer change their own `tier_id` or `onboarding_completed_at` on the `space_members` row (migration 024 extends the self-change trigger). Onboarding completion now goes through the service client after server-side validation.
|
||||||
- Added in-code space scoping to forum/comment/channel mutations (defence-in-depth on top of RLS).
|
- Added in-code space scoping to forum/comment/channel mutations (defence-in-depth on top of RLS).
|
||||||
|
|||||||
+26
-2
@@ -1,8 +1,8 @@
|
|||||||
# DB Schema Map — Quick Reference
|
# DB Schema Map — Quick Reference
|
||||||
|
|
||||||
> **Last Updated**: 2026-03-10
|
> **Last Updated**: 2026-05-15
|
||||||
> **Full Documentation**: See [docs/DATABASE_SCHEMA.md](./docs/DATABASE_SCHEMA.md) for complete reference
|
> **Full Documentation**: See [docs/DATABASE_SCHEMA.md](./docs/DATABASE_SCHEMA.md) for complete reference
|
||||||
> **Source of Truth**: Live database schema
|
> **Source of Truth**: `scripts/schema.sql` (canonical, idempotent)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -390,3 +390,27 @@ Default channels created by trigger on space INSERT: `general`, `announcements`,
|
|||||||
| 011 | Renamed tasks `type` column to `task_type` (canonical enum column name) |
|
| 011 | Renamed tasks `type` column to `task_type` (canonical enum column name) |
|
||||||
| 012 | Canonical sync — task_type enum, cleaned duplicate columns |
|
| 012 | Canonical sync — task_type enum, cleaned duplicate columns |
|
||||||
| 013 | Added `space_members.last_paid_at` timestamptz — required by importMembers and linkPaymentToMember |
|
| 013 | Added `space_members.last_paid_at` timestamptz — required by importMembers and linkPaymentToMember |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tables added by migrations 016-024 (quick map)
|
||||||
|
|
||||||
|
| Table | Key columns | Purpose |
|
||||||
|
|-------|-------------|---------|
|
||||||
|
| `proposals` / `proposal_votes` | space_id, status, quorum_*, voting_*; member_id, position | Async governance voting |
|
||||||
|
| `incidents` / `incident_updates` | space_id, status, sla_*; visibility | Incident reports + log |
|
||||||
|
| `policies` | space_id, slug, version, status, body_* | Versioned policy library |
|
||||||
|
| `space_areas` | space_id, code, name, sort_order, is_archived | Per-space configurable areas |
|
||||||
|
| `forum_threads` | space_id, author_id, title, category, pinned, locked, comment_count | Forum |
|
||||||
|
| `comments` | space_id, entity_type, entity_id, author_id, parent_id, body | Polymorphic comments (forum/proposal/incident/policy) |
|
||||||
|
| `space_tiers` | space_id, slug, name, monthly_price_cents, billing_cadence, is_system | Custom membership tiers (`space_members.tier_id` FK) |
|
||||||
|
| `space_role_labels` | space_id, role, display_name, color | Rename/recolor built-in roles |
|
||||||
|
| `space_custom_roles` / `space_member_custom_roles` | space_id, slug, name, color / member_id, custom_role_id | Custom non-privileged roles + assignment |
|
||||||
|
| `space_invites` | space_id, code, label, expires_at, max_uses, uses_count, is_enabled | Multi-code invites |
|
||||||
|
| `space_onboarding_steps` | space_id, step_key, step_type, title, body, config, is_enabled, is_required | Configurable member onboarding |
|
||||||
|
| `space_role_permissions` | space_id, subject, permission | Role/custom-role permission grants |
|
||||||
|
| `ops_acl` | space_id, entity_type, entity_id, role | Per-item Ops access list (secret/kb/process/area_lead) |
|
||||||
|
|
||||||
|
Column additions: `space_members.tier_id`, `onboarding_completed_at`, `onboarding_progress`; `secrets.encrypted_value`, `encryption_version`; `knowledge_base.render_markdown`, `is_meeting_minutes`, `meeting_date`. New enum `comment_entity_type`.
|
||||||
|
|
||||||
|
Helper functions: `user_effective_roles(uid,sid)`, `user_has_permission(uid,sid,perm)` (both SECURITY DEFINER, search_path=public), plus governance quorum/tally/SLA functions.
|
||||||
|
|||||||
@@ -2,14 +2,18 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Plus, X, ChevronDown } from 'lucide-react'
|
import { Plus, X, ChevronDown } from 'lucide-react'
|
||||||
import { addMember, updateMember, approveMember, removeMember } from '@/lib/actions'
|
import { addMember, updateMember, approveMember, removeMember, assignAreaLead } from '@/lib/actions'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import type { Tables } from '@/types/database'
|
import type { Tables } from '@/types/database'
|
||||||
|
|
||||||
type Member = Tables<'space_members'>
|
type Member = Tables<'space_members'>
|
||||||
|
type AreaLeadRole = { id: string; area_name: string; lead_id: string | null }
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
members: Member[]
|
members: Member[]
|
||||||
currentRole: string
|
currentRole: string
|
||||||
|
areaLeadRoles?: AreaLeadRole[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const TIER_COLORS: Record<string, string> = {
|
const TIER_COLORS: Record<string, string> = {
|
||||||
@@ -21,8 +25,17 @@ const TIER_COLORS: Record<string, string> = {
|
|||||||
|
|
||||||
const isAdmin = (role: string) => role === 'admin' || role === 'board'
|
const isAdmin = (role: string) => role === 'admin' || role === 'board'
|
||||||
|
|
||||||
export function MembersClient({ members: initialMembers, currentRole }: Props) {
|
export function MembersClient({ members: initialMembers, currentRole, areaLeadRoles = [] }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
const [members, setMembers] = useState<Member[]>(initialMembers)
|
const [members, setMembers] = useState<Member[]>(initialMembers)
|
||||||
|
|
||||||
|
async function makeAreaLead(memberId: string, areaLeadRoleId: string) {
|
||||||
|
if (!areaLeadRoleId) return
|
||||||
|
const result = await assignAreaLead({ area_lead_role_id: areaLeadRoleId, member_id: memberId })
|
||||||
|
if (result.error) { toast.error(result.error); return }
|
||||||
|
toast.success('Assigned as area lead')
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
const [activeTab, setActiveTab] = useState<'all' | 'payment_issues' | 'unverified' | 'inactive'>('all')
|
const [activeTab, setActiveTab] = useState<'all' | 'payment_issues' | 'unverified' | 'inactive'>('all')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [tierFilter, setTierFilter] = useState('')
|
const [tierFilter, setTierFilter] = useState('')
|
||||||
@@ -326,6 +339,21 @@ export function MembersClient({ members: initialMembers, currentRole }: Props) {
|
|||||||
>
|
>
|
||||||
REMOVE
|
REMOVE
|
||||||
</button>
|
</button>
|
||||||
|
{areaLeadRoles.length > 0 && (
|
||||||
|
<select
|
||||||
|
defaultValue=""
|
||||||
|
onChange={e => { makeAreaLead(m.id, e.target.value); e.currentTarget.value = '' }}
|
||||||
|
className="font-mono text-[10px] border border-border px-1.5 py-0.5 rounded bg-background text-muted-foreground hover:border-primary hover:text-primary transition focus:outline-none"
|
||||||
|
title="Assign this member as an area lead"
|
||||||
|
>
|
||||||
|
<option value="">+ area lead</option>
|
||||||
|
{areaLeadRoles.map(r => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.area_name}{r.lead_id ? ' (reassign)' : ' (vacant)'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -57,10 +57,22 @@ export default async function MembersPage() {
|
|||||||
.eq('space_id', self.space_id)
|
.eq('space_id', self.space_id)
|
||||||
.order('joined_at')
|
.order('joined_at')
|
||||||
|
|
||||||
|
// Area-lead roles, so admin/board can assign a lead straight from a member
|
||||||
|
// row (the same capability also lives in Customize -> Area leads).
|
||||||
|
const canManageLeads = self.role === 'admin' || self.role === 'board'
|
||||||
|
const { data: areaLeadRoles } = canManageLeads
|
||||||
|
? await supabase
|
||||||
|
.from('area_leads')
|
||||||
|
.select('id, area_name, lead_id')
|
||||||
|
.eq('space_id', self.space_id)
|
||||||
|
.order('area_name')
|
||||||
|
: { data: [] }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MembersClient
|
<MembersClient
|
||||||
members={members ?? []}
|
members={members ?? []}
|
||||||
currentRole={self.role}
|
currentRole={self.role}
|
||||||
|
areaLeadRoles={(areaLeadRoles ?? []) as Array<{ id: string; area_name: string; lead_id: string | null }>}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-1
@@ -1,7 +1,8 @@
|
|||||||
# Hackerspace.sh - API Reference
|
# Hackerspace.sh - API Reference
|
||||||
|
|
||||||
> **Last Updated**: 2026-03-10
|
> **Last Updated**: 2026-05-15
|
||||||
> Complete reference for all server actions, database operations, and API patterns.
|
> Complete reference for all server actions, database operations, and API patterns.
|
||||||
|
> Actions added since the original baseline are listed in "Server actions added (migrations 016-024)" at the end.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -851,3 +852,38 @@ async function handleSubmit() {
|
|||||||
| "Treasurer access required" | Payment role check | Show permission error |
|
| "Treasurer access required" | Payment role check | Show permission error |
|
||||||
| "Invalid invite code" | Code not found | Ask user to verify |
|
| "Invalid invite code" | Code not found | Ask user to verify |
|
||||||
| "A space with this URL slug already exists" | Duplicate slug | Suggest different slug |
|
| "A space with this URL slug already exists" | Duplicate slug | Suggest different slug |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server actions added (migrations 016-024)
|
||||||
|
|
||||||
|
All are `'use server'` in `lib/actions/*`, re-exported from `lib/actions/index.ts`,
|
||||||
|
validate input with Zod via `parseInput`, authorize via `requireMember` /
|
||||||
|
`requireMemberWithRole`, scope writes by `member.space_id`, and `revalidatePath`.
|
||||||
|
|
||||||
|
### Governance — `proposals.ts`, `incidents.ts`, `policies.ts`
|
||||||
|
`createProposal`, `openProposal`, `castVote`, `decideProposal`, `withdrawProposal`, `deleteProposal`; `fileIncident`, `updateIncidentStatus`, `addIncidentUpdate`, `appealIncident`; `createPolicy`, `supersedePolicy`, `updatePolicyStatus`.
|
||||||
|
|
||||||
|
### Areas — `areas.ts`
|
||||||
|
`createArea`, `updateArea`, `deleteArea` (admin/board; delete admin).
|
||||||
|
|
||||||
|
### Forum + comments — `forum.ts`
|
||||||
|
`createForumThread`, `updateForumThread` (pin/lock = admin/board), `deleteForumThread`, `addComment`, `editComment`, `deleteComment`. All space-scoped in code.
|
||||||
|
|
||||||
|
### Chat channels — `comms.ts`
|
||||||
|
`createChannel` (any member), `renameChannel`, `deleteChannel` (creator or admin/board; default channels protected).
|
||||||
|
|
||||||
|
### Tiers / roles / invites — `tiers.ts`, `roles.ts`, `invites.ts`
|
||||||
|
`createTier`/`updateTier`/`deleteTier`; `upsertRoleLabel`, `createCustomRole`/`updateCustomRole`/`deleteCustomRole`, `assignCustomRole`/`unassignCustomRole`; `createInvite`/`updateInvite`/`deleteInvite`.
|
||||||
|
|
||||||
|
### Onboarding — `onboarding.ts`
|
||||||
|
`createOnboardingStep`/`updateOnboardingStep`/`deleteOnboardingStep` (admin/board); `markOnboardingStepDone`, `finishOnboarding`, `skipOnboarding` (member; completion writes via the service client after the server-side required-steps check, so the self-change trigger cannot be bypassed).
|
||||||
|
|
||||||
|
### Permissions / Ops ACLs / area-lead — `permissions.ts`
|
||||||
|
`setRolePermissions(subject, permissions[])`, `setOpsAcl(entity_type, entity_id, roles[])` (admin/board), `createAreaLeadRole`, `assignAreaLead`, `unassignAreaLead`, `deleteAreaLeadRole`. ACLs are additive: empty list falls back to the item's default visibility.
|
||||||
|
|
||||||
|
### Secrets — `secrets.ts`
|
||||||
|
`createSecret`, `updateSecret`, `revealSecret` (server-only plaintext path, audit-logged, errors on corrupt ciphertext), `deleteSecret`. The list query never selects `value`/`encrypted_value`.
|
||||||
|
|
||||||
|
### Bulk import — `imports.ts`, `payments.ts`
|
||||||
|
`importMembers`, `importPaymentsCsv`: per-row Zod validation, lowercased emails, `flexibleDateTime` date normalization, enum-checked platform/tier, positive finite amounts; invalid rows skipped and returned as a `skipped` count.
|
||||||
|
|||||||
+36
-4
@@ -1,9 +1,12 @@
|
|||||||
# Hackerspace.sh - Database Schema Reference
|
# Hackerspace.sh - Database Schema Reference
|
||||||
|
|
||||||
> **Last Updated**: 2026-03-10
|
> **Last Updated**: 2026-05-15
|
||||||
> **Database**: PostgreSQL via Supabase
|
> **Database**: PostgreSQL via self-hosted Supabase
|
||||||
> **Tables**: 13
|
> **Tables**: ~30
|
||||||
> **Source of Truth**: Live database schema
|
> **Source of Truth**: `scripts/schema.sql` (canonical, idempotent); numbered
|
||||||
|
> migrations `scripts/0NN_*.sql` upgrade existing deployments. The 13-table
|
||||||
|
> reference below is the original baseline; tables added by migrations
|
||||||
|
> 014-024 are summarized in the "Migrations 014-024" section at the end.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -680,3 +683,32 @@ CREATE INDEX idx_activity_space ON activity_log(space_id, created_at);
|
|||||||
| `009_fix_member_insert_rls.sql` | 1.8 | Fixed member INSERT RLS for admin/board |
|
| `009_fix_member_insert_rls.sql` | 1.8 | Fixed member INSERT RLS for admin/board |
|
||||||
| `010_fix_channel_trigger.sql` | 1.9 | Removed non-existent description column from trigger |
|
| `010_fix_channel_trigger.sql` | 1.9 | Removed non-existent description column from trigger |
|
||||||
| `011_fix_member_status_enum.sql` | 2.0 | Fixed enum values (active→current, pending→unverified) |
|
| `011_fix_member_status_enum.sql` | 2.0 | Fixed enum values (active→current, pending→unverified) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migrations 014-024 (additions since the 13-table baseline)
|
||||||
|
|
||||||
|
`scripts/schema.sql` is the canonical idempotent schema; each numbered
|
||||||
|
migration is mirrored as a section in it. Tables/columns added:
|
||||||
|
|
||||||
|
| Mig | Adds |
|
||||||
|
|-----|------|
|
||||||
|
| 014 | `space_members.user_id` made nullable (offline/imported members) |
|
||||||
|
| 015 | `prevent_member_self_role_change()` trigger (self-escalation guard) |
|
||||||
|
| 016 | Governance kernel: `proposals`, `proposal_votes`, `incidents`, `incident_updates`, `policies` + quorum/tally/SLA functions |
|
||||||
|
| 017 | Governance RLS hardening (cross-tenant edge cases) |
|
||||||
|
| 018 | `space_members.skills/interests/willing_to/affiliations/coi_last_disclosed_at`; `knowledge_base.is_meeting_minutes/meeting_date`; financial/member-directory visibility settings |
|
||||||
|
| 019 | Auto-expire open proposals past their voting window |
|
||||||
|
| 020 | `space_areas` (per-space configurable areas) + seeded defaults |
|
||||||
|
| 021 | `forum_threads`, `comments` (polymorphic: forum_thread/proposal/incident/policy), `space_tiers` + `space_members.tier_id`, `space_role_labels`, `space_custom_roles`, `space_member_custom_roles`, `space_invites`; `secrets.encrypted_value/encryption_version`; `knowledge_base.render_markdown`; comms_channels member-creatable |
|
||||||
|
| 022 | `space_onboarding_steps`; `space_members.onboarding_completed_at/onboarding_progress`; seed default steps |
|
||||||
|
| 023 | `space_role_permissions`, `ops_acl`; `user_effective_roles()`, `user_has_permission()` (SECURITY DEFINER); `secrets`/`knowledge_base` SELECT rewritten as `existing-rule OR ops_acl-match` (additive) |
|
||||||
|
| 024 | `prevent_member_self_role_change()` extended to also block self-change of `tier_id` and `onboarding_completed_at` |
|
||||||
|
|
||||||
|
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
|
||||||
|
|
||||||
|
Every new table has RLS enabled with SELECT/INSERT/UPDATE/DELETE policies
|
||||||
|
(writes admin/board, deletes admin, reads space members) and, where it has
|
||||||
|
`updated_at`, a `touch_updated_at` trigger. The ACL model is additive:
|
||||||
|
with zero `ops_acl`/`space_role_permissions` rows the app behaves exactly
|
||||||
|
as the role-based baseline.
|
||||||
|
|||||||
+7
-5
@@ -28,12 +28,14 @@ Five parallel read-only audits (security/RLS, server actions/validation, routes/
|
|||||||
- **KB/process per-item ACL wired.** `OpsAclEditor` renders in the KB/process viewer modal (admin/board); entity type derived from the `process` tag. Per-item Ops ACLs now cover secrets + KB + processes.
|
- **KB/process per-item ACL wired.** `OpsAclEditor` renders in the KB/process viewer modal (admin/board); entity type derived from the `process` tag. Per-item Ops ACLs now cover secrets + KB + processes.
|
||||||
- `/ops` and `/import` denial paths left as-is: unreachable behind the `(app)` layout gate, or already a clear denial with no data leak.
|
- `/ops` and `/import` denial paths left as-is: unreachable behind the `(app)` layout gate, or already a clear denial with no data leak.
|
||||||
|
|
||||||
### Tracked backlog (not security-critical, zero runtime impact)
|
### Pass 16c — backlog cleared (same day)
|
||||||
|
|
||||||
- `types/database.ts` still missing 8 prior-pass tables + `comment_entity_type` enum + 3 `space_members` columns (loose casts; build green via `ignoreBuildErrors`).
|
- **`types/database.ts` synced.** Added the 8 missing tables (forum_threads, comments, space_tiers, space_invites, space_onboarding_steps, space_role_labels, space_custom_roles, space_member_custom_roles), the `comment_entity_type` enum (both type union and Constants array), and the missing columns: `space_members.tier_id/onboarding_completed_at/onboarding_progress`, `secrets.encrypted_value/encryption_version`, `knowledge_base.render_markdown`.
|
||||||
- `docs/DATABASE_SCHEMA.md`, `DB_SCHEMA_MAP.md`, `docs/API_REFERENCE.md` stale since 2026-03-10 (missing migrations 016-024).
|
- **Email normalized everywhere.** New `emailField()` helper (trim + lowercase) applied to signIn/signUp/addMember/updateMember/createContact and the import path. Prevents case-variant duplicate members/contacts.
|
||||||
- Email `.toLowerCase()` still missing on the non-import auth/member schemas (import path now normalizes).
|
- **Members-row "Make area lead".** A per-row picker in the members directory (admin/board) assigns a member to any area-lead role via `assignAreaLead`; same capability also in Customize -> Area leads.
|
||||||
- Members-row "Make area lead" shortcut (Customize -> Area leads fully covers assignment).
|
- **Docs refreshed.** `DATABASE_SCHEMA.md` (header + migrations 014-024 summary), `DB_SCHEMA_MAP.md` (table quick-map + helpers), `docs/API_REFERENCE.md` (all server actions added in migrations 016-024). All re-dated 2026-05-15 with `scripts/schema.sql` named as source of truth.
|
||||||
|
|
||||||
|
Backlog is now empty. Remaining known non-issues: `/ops` + `/import` `return null` are unreachable behind the `(app)` layout gate; `next.config.mjs` keeps `ignoreBuildErrors` (pre-existing legacy app/ TS noise) — the new code is type-clean.
|
||||||
|
|
||||||
### Verification
|
### Verification
|
||||||
|
|
||||||
|
|||||||
+11
-6
@@ -33,13 +33,18 @@ export const flexibleDateTime = () =>
|
|||||||
|
|
||||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Canonicalize every email to trimmed lowercase so case/whitespace variants
|
||||||
|
// cannot create duplicate members/contacts or fail sign-in.
|
||||||
|
export const emailField = (msg = 'Invalid email address') =>
|
||||||
|
z.string().max(200).email(msg).transform(s => s.trim().toLowerCase())
|
||||||
|
|
||||||
export const signInSchema = z.object({
|
export const signInSchema = z.object({
|
||||||
email: z.string().email('Invalid email address'),
|
email: emailField(),
|
||||||
password: z.string().min(1, 'Password is required'),
|
password: z.string().min(1, 'Password is required'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const signUpSchema = z.object({
|
export const signUpSchema = z.object({
|
||||||
email: z.string().email('Invalid email address'),
|
email: emailField(),
|
||||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||||
fullName: z.string().min(1, 'Full name is required').max(100, 'Name is too long'),
|
fullName: z.string().min(1, 'Full name is required').max(100, 'Name is too long'),
|
||||||
action: z.enum(['create', 'join']),
|
action: z.enum(['create', 'join']),
|
||||||
@@ -92,7 +97,7 @@ export const updateProjectStatusSchema = z.object({
|
|||||||
// ─── Members ─────────────────────────────────────────────────────────────────
|
// ─── Members ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const addMemberSchema = z.object({
|
export const addMemberSchema = z.object({
|
||||||
email: z.string().email('Invalid email address'),
|
email: emailField(),
|
||||||
display_name: z.string().min(1, 'Display name is required').max(100),
|
display_name: z.string().min(1, 'Display name is required').max(100),
|
||||||
phone: z.string().max(20).optional().nullable(),
|
phone: z.string().max(20).optional().nullable(),
|
||||||
handle: z.string().max(50).optional().nullable(),
|
handle: z.string().max(50).optional().nullable(),
|
||||||
@@ -105,7 +110,7 @@ export const addMemberSchema = z.object({
|
|||||||
export const updateMemberSchema = z.object({
|
export const updateMemberSchema = z.object({
|
||||||
memberId: z.string().uuid('Invalid member ID'),
|
memberId: z.string().uuid('Invalid member ID'),
|
||||||
display_name: z.string().min(1).max(100).optional(),
|
display_name: z.string().min(1).max(100).optional(),
|
||||||
email: z.string().email().optional(),
|
email: emailField().optional(),
|
||||||
phone: z.string().max(20).optional().nullable(),
|
phone: z.string().max(20).optional().nullable(),
|
||||||
handle: z.string().max(50).optional().nullable(),
|
handle: z.string().max(50).optional().nullable(),
|
||||||
role: z.enum(['admin', 'board', 'treasurer', 'member', 'associate']).optional(),
|
role: z.enum(['admin', 'board', 'treasurer', 'member', 'associate']).optional(),
|
||||||
@@ -121,7 +126,7 @@ export const updateMemberSchema = z.object({
|
|||||||
export const createContactSchema = z.object({
|
export const createContactSchema = z.object({
|
||||||
name: z.string().min(1, 'Name is required').max(200),
|
name: z.string().min(1, 'Name is required').max(200),
|
||||||
contact_type: z.enum(['vendor', 'supplier', 'partner', 'landlord', 'city']),
|
contact_type: z.enum(['vendor', 'supplier', 'partner', 'landlord', 'city']),
|
||||||
email: z.string().email().optional().nullable(),
|
email: emailField().optional().nullable(),
|
||||||
phone: z.string().max(20).optional().nullable(),
|
phone: z.string().max(20).optional().nullable(),
|
||||||
details: z.string().max(500).optional().nullable(),
|
details: z.string().max(500).optional().nullable(),
|
||||||
note: z.string().max(2000).optional().nullable(),
|
note: z.string().max(2000).optional().nullable(),
|
||||||
@@ -524,7 +529,7 @@ export const assignAreaLeadSchema = z.object({
|
|||||||
|
|
||||||
// ─── Bulk import ─────────────────────────────────────────────────────────────
|
// ─── Bulk import ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const importEmail = z.string().email('Invalid email').max(200).transform(s => s.trim().toLowerCase())
|
const importEmail = emailField('Invalid email')
|
||||||
|
|
||||||
export const importMembersSchema = z.array(
|
export const importMembersSchema = z.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -46,6 +46,54 @@ export type Database = {
|
|||||||
Update: { id?: string; space_id?: string; entity_type?: string; entity_id?: string; role?: string; created_at?: string }
|
Update: { id?: string; space_id?: string; entity_type?: string; entity_id?: string; role?: string; created_at?: string }
|
||||||
Relationships: []
|
Relationships: []
|
||||||
}
|
}
|
||||||
|
forum_threads: {
|
||||||
|
Row: { id: string; space_id: string; author_id: string | null; title: string; body: string | null; category: string; pinned: boolean; locked: boolean; comment_count: number; last_comment_at: string | null; created_at: string; updated_at: string }
|
||||||
|
Insert: { id?: string; space_id: string; author_id?: string | null; title: string; body?: string | null; category?: string; pinned?: boolean; locked?: boolean; comment_count?: number; last_comment_at?: string | null; created_at?: string; updated_at?: string }
|
||||||
|
Update: { id?: string; space_id?: string; author_id?: string | null; title?: string; body?: string | null; category?: string; pinned?: boolean; locked?: boolean; comment_count?: number; last_comment_at?: string | null; created_at?: string; updated_at?: string }
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
comments: {
|
||||||
|
Row: { id: string; space_id: string; entity_type: Database['public']['Enums']['comment_entity_type']; entity_id: string; author_id: string | null; parent_id: string | null; body: string; edited_at: string | null; created_at: string }
|
||||||
|
Insert: { id?: string; space_id: string; entity_type: Database['public']['Enums']['comment_entity_type']; entity_id: string; author_id?: string | null; parent_id?: string | null; body: string; edited_at?: string | null; created_at?: string }
|
||||||
|
Update: { id?: string; space_id?: string; entity_type?: Database['public']['Enums']['comment_entity_type']; entity_id?: string; author_id?: string | null; parent_id?: string | null; body?: string; edited_at?: string | null; created_at?: string }
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
space_tiers: {
|
||||||
|
Row: { id: string; space_id: string; slug: string; name: string; description: string | null; monthly_price_cents: number; billing_cadence: string; is_system: boolean; is_archived: boolean; sort_order: number; created_at: string; updated_at: string }
|
||||||
|
Insert: { id?: string; space_id: string; slug: string; name: string; description?: string | null; monthly_price_cents?: number; billing_cadence?: string; is_system?: boolean; is_archived?: boolean; sort_order?: number; created_at?: string; updated_at?: string }
|
||||||
|
Update: { id?: string; space_id?: string; slug?: string; name?: string; description?: string | null; monthly_price_cents?: number; billing_cadence?: string; is_system?: boolean; is_archived?: boolean; sort_order?: number; created_at?: string; updated_at?: string }
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
space_invites: {
|
||||||
|
Row: { id: string; space_id: string; code: string; label: string | null; expires_at: string | null; max_uses: number | null; uses_count: number; is_enabled: boolean; created_by: string | null; created_at: string; updated_at: string }
|
||||||
|
Insert: { id?: string; space_id: string; code: string; label?: string | null; expires_at?: string | null; max_uses?: number | null; uses_count?: number; is_enabled?: boolean; created_by?: string | null; created_at?: string; updated_at?: string }
|
||||||
|
Update: { id?: string; space_id?: string; code?: string; label?: string | null; expires_at?: string | null; max_uses?: number | null; uses_count?: number; is_enabled?: boolean; created_by?: string | null; created_at?: string; updated_at?: string }
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
space_onboarding_steps: {
|
||||||
|
Row: { id: string; space_id: string; step_key: string; step_type: string; title: string; body: string | null; config: Json; is_enabled: boolean; is_required: boolean; is_system: boolean; sort_order: number; created_at: string; updated_at: string }
|
||||||
|
Insert: { id?: string; space_id: string; step_key: string; step_type: string; title: string; body?: string | null; config?: Json; is_enabled?: boolean; is_required?: boolean; is_system?: boolean; sort_order?: number; created_at?: string; updated_at?: string }
|
||||||
|
Update: { id?: string; space_id?: string; step_key?: string; step_type?: string; title?: string; body?: string | null; config?: Json; is_enabled?: boolean; is_required?: boolean; is_system?: boolean; sort_order?: number; created_at?: string; updated_at?: string }
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
space_role_labels: {
|
||||||
|
Row: { id: string; space_id: string; role: string; display_name: string | null; description: string | null; color: string | null; sort_order: number; created_at: string; updated_at: string }
|
||||||
|
Insert: { id?: string; space_id: string; role: string; display_name?: string | null; description?: string | null; color?: string | null; sort_order?: number; created_at?: string; updated_at?: string }
|
||||||
|
Update: { id?: string; space_id?: string; role?: string; display_name?: string | null; description?: string | null; color?: string | null; sort_order?: number; created_at?: string; updated_at?: string }
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
space_custom_roles: {
|
||||||
|
Row: { id: string; space_id: string; slug: string; name: string; description: string | null; color: string | null; sort_order: number; created_at: string; updated_at: string }
|
||||||
|
Insert: { id?: string; space_id: string; slug: string; name: string; description?: string | null; color?: string | null; sort_order?: number; created_at?: string; updated_at?: string }
|
||||||
|
Update: { id?: string; space_id?: string; slug?: string; name?: string; description?: string | null; color?: string | null; sort_order?: number; created_at?: string; updated_at?: string }
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
space_member_custom_roles: {
|
||||||
|
Row: { member_id: string; custom_role_id: string; assigned_at: string }
|
||||||
|
Insert: { member_id: string; custom_role_id: string; assigned_at?: string }
|
||||||
|
Update: { member_id?: string; custom_role_id?: string; assigned_at?: string }
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
activity_log: {
|
activity_log: {
|
||||||
Row: {
|
Row: {
|
||||||
action: string
|
action: string
|
||||||
@@ -563,6 +611,7 @@ export type Database = {
|
|||||||
id: string
|
id: string
|
||||||
is_meeting_minutes: boolean
|
is_meeting_minutes: boolean
|
||||||
is_pinned: boolean
|
is_pinned: boolean
|
||||||
|
render_markdown: boolean
|
||||||
meeting_date: string | null
|
meeting_date: string | null
|
||||||
pinned: boolean
|
pinned: boolean
|
||||||
space_id: string
|
space_id: string
|
||||||
@@ -585,6 +634,7 @@ export type Database = {
|
|||||||
id?: string
|
id?: string
|
||||||
is_meeting_minutes?: boolean
|
is_meeting_minutes?: boolean
|
||||||
is_pinned?: boolean
|
is_pinned?: boolean
|
||||||
|
render_markdown?: boolean
|
||||||
meeting_date?: string | null
|
meeting_date?: string | null
|
||||||
pinned?: boolean
|
pinned?: boolean
|
||||||
space_id: string
|
space_id: string
|
||||||
@@ -607,6 +657,7 @@ export type Database = {
|
|||||||
id?: string
|
id?: string
|
||||||
is_meeting_minutes?: boolean
|
is_meeting_minutes?: boolean
|
||||||
is_pinned?: boolean
|
is_pinned?: boolean
|
||||||
|
render_markdown?: boolean
|
||||||
meeting_date?: string | null
|
meeting_date?: string | null
|
||||||
pinned?: boolean
|
pinned?: boolean
|
||||||
space_id?: string
|
space_id?: string
|
||||||
@@ -1078,6 +1129,8 @@ export type Database = {
|
|||||||
notes: string | null
|
notes: string | null
|
||||||
space_id: string
|
space_id: string
|
||||||
title: string | null
|
title: string | null
|
||||||
|
encrypted_value: string | null
|
||||||
|
encryption_version: number
|
||||||
updated_at: string
|
updated_at: string
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
@@ -1093,6 +1146,8 @@ export type Database = {
|
|||||||
notes?: string | null
|
notes?: string | null
|
||||||
space_id: string
|
space_id: string
|
||||||
title?: string | null
|
title?: string | null
|
||||||
|
encrypted_value?: string | null
|
||||||
|
encryption_version?: number
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
@@ -1108,6 +1163,8 @@ export type Database = {
|
|||||||
notes?: string | null
|
notes?: string | null
|
||||||
space_id?: string
|
space_id?: string
|
||||||
title?: string | null
|
title?: string | null
|
||||||
|
encrypted_value?: string | null
|
||||||
|
encryption_version?: number
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
value?: string
|
value?: string
|
||||||
}
|
}
|
||||||
@@ -1192,6 +1249,9 @@ export type Database = {
|
|||||||
status: Database["public"]["Enums"]["member_status"]
|
status: Database["public"]["Enums"]["member_status"]
|
||||||
stripe_customer_id: string | null
|
stripe_customer_id: string | null
|
||||||
tier: Database["public"]["Enums"]["member_tier"]
|
tier: Database["public"]["Enums"]["member_tier"]
|
||||||
|
tier_id: string | null
|
||||||
|
onboarding_completed_at: string | null
|
||||||
|
onboarding_progress: Json
|
||||||
updated_at: string
|
updated_at: string
|
||||||
user_id: string | null
|
user_id: string | null
|
||||||
willing_to: string[]
|
willing_to: string[]
|
||||||
@@ -1222,6 +1282,9 @@ export type Database = {
|
|||||||
status?: Database["public"]["Enums"]["member_status"]
|
status?: Database["public"]["Enums"]["member_status"]
|
||||||
stripe_customer_id?: string | null
|
stripe_customer_id?: string | null
|
||||||
tier?: Database["public"]["Enums"]["member_tier"]
|
tier?: Database["public"]["Enums"]["member_tier"]
|
||||||
|
tier_id?: string | null
|
||||||
|
onboarding_completed_at?: string | null
|
||||||
|
onboarding_progress?: Json
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
user_id?: string | null
|
user_id?: string | null
|
||||||
willing_to?: string[]
|
willing_to?: string[]
|
||||||
@@ -1252,6 +1315,9 @@ export type Database = {
|
|||||||
status?: Database["public"]["Enums"]["member_status"]
|
status?: Database["public"]["Enums"]["member_status"]
|
||||||
stripe_customer_id?: string | null
|
stripe_customer_id?: string | null
|
||||||
tier?: Database["public"]["Enums"]["member_tier"]
|
tier?: Database["public"]["Enums"]["member_tier"]
|
||||||
|
tier_id?: string | null
|
||||||
|
onboarding_completed_at?: string | null
|
||||||
|
onboarding_progress?: Json
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
user_id?: string | null
|
user_id?: string | null
|
||||||
willing_to?: string[]
|
willing_to?: string[]
|
||||||
@@ -1502,6 +1568,7 @@ export type Database = {
|
|||||||
Enums: {
|
Enums: {
|
||||||
area_lead_status: "active" | "vacant" | "handoff"
|
area_lead_status: "active" | "vacant" | "handoff"
|
||||||
channel_type: "general" | "area" | "ops" | "project"
|
channel_type: "general" | "area" | "ops" | "project"
|
||||||
|
comment_entity_type: "forum_thread" | "proposal" | "incident" | "policy"
|
||||||
contact_type: "vendor" | "supplier" | "partner" | "landlord" | "city"
|
contact_type: "vendor" | "supplier" | "partner" | "landlord" | "city"
|
||||||
directory_visibility:
|
directory_visibility:
|
||||||
| "board_only"
|
| "board_only"
|
||||||
@@ -1685,6 +1752,7 @@ export const Constants = {
|
|||||||
Enums: {
|
Enums: {
|
||||||
area_lead_status: ["active", "vacant", "handoff"],
|
area_lead_status: ["active", "vacant", "handoff"],
|
||||||
channel_type: ["general", "area", "ops", "project"],
|
channel_type: ["general", "area", "ops", "project"],
|
||||||
|
comment_entity_type: ["forum_thread", "proposal", "incident", "policy"],
|
||||||
contact_type: ["vendor", "supplier", "partner", "landlord", "city"],
|
contact_type: ["vendor", "supplier", "partner", "landlord", "city"],
|
||||||
directory_visibility: [
|
directory_visibility: [
|
||||||
"board_only",
|
"board_only",
|
||||||
|
|||||||
Reference in New Issue
Block a user