feat: forum, polymorphic comments, custom tiers, multi-invite, secrets encryption

Big feature batch driven by the super-audit findings and the user's
follow-up requests.

Schema migration 021 (folded into schema.sql Section 13):
- forum_threads + polymorphic comments table keyed on (entity_type,
  entity_id). Entity types: forum_thread, proposal, incident, policy.
  Trigger maintains forum_threads.comment_count and last_comment_at.
- space_tiers per-space with monthly_price_cents, billing_cadence,
  is_system, sort_order. Trigger seeds plus/basic/associate on every
  space INSERT and backfills existing spaces. space_members.tier_id FK
  added and backfilled from the legacy enum value.
- space_role_labels (rename/recolor built-in roles per space).
  space_custom_roles + space_member_custom_roles for non-privileged
  extra labels. RLS gates by admin/board.
- space_invites: multi-code invites with expires_at, max_uses,
  uses_count, is_enabled. Legacy spaces.invite_code backfilled as a
  permanent enabled invite.
- secrets.encrypted_value bytea + encryption_version. Secrets RLS
  tightened. New comms_channels INSERT policy: any member can create;
  default channels are protected from deletion.
- knowledge_base.render_markdown boolean (default true).

Secrets encryption-at-rest:
- AES-256-GCM in lib/secrets/crypto.ts. Master key from
  SECRETS_ENCRYPTION_KEY env (64 hex chars).
- New revealSecret(id) server action: only path to plaintext, writes
  secret.revealed to activity_log.
- /ops/page.tsx no longer selects value or encrypted_value on the list
  endpoint - the original screenshot bug where ciphertext was already
  in the page bundle before "Reveal" is fixed.

UI:
- /forum index, /forum/new, /forum/[id]. Pin/lock/delete actions for
  admin/board.
- CommentThread component embedded on /proposals/[id], /incidents/[id],
  /policies/[slug]. Markdown rendering via react-markdown + remark-gfm.
- /comms sidebar: inline "+ New channel" form.
- /ops KB: row click opens markdown-rendering viewer modal.
- /settings: new Tiers tab and Invites tab. Inline price edit and
  archive/delete on tiers; copy/enable/disable/delete on invites.
- AtlasLogo replaces the terminal icon on the landing nav and footer.
  public/logo.svg wired as the favicon in app/layout.tsx metadata.
- Sidebar has a Forum nav link.

Server actions added:
- lib/actions/forum.ts, comms.ts, tiers.ts, roles.ts, invites.ts.
- lib/actions/secrets.ts rewritten with updateSecret + revealSecret.

Auth flow:
- joinSpace honors codes from space_invites first (enabled, expiry,
  use cap), falls back to legacy spaces.invite_code, increments
  uses_count on success.

Env:
- SECRETS_ENCRYPTION_KEY documented in .env.example and set on the
  Droplet's /opt/hackerspace-ops/.env.production.

README: live site link added (https://hackerspace.sh).

266/266 unit tests pass. Production build clean. Migration 021 will be
applied on the next deploy via /opt/hackerspace-ops/deploy.sh's
idempotent migration runner.
This commit is contained in:
Moheeb Zara
2026-05-14 17:47:52 -07:00
parent 8ad1ca44b7
commit de51000584
35 changed files with 2981 additions and 93 deletions
+17 -12
View File
@@ -1,13 +1,11 @@
# ─────────────────────────────────────────────────────────────────────────────
# 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).
# -----------------------------------------------------------------------------
# hackerspace-management environment variables
# -----------------------------------------------------------------------------
# Copy to .env.local for development; set the same keys on your host for prod.
# .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
@@ -19,6 +17,13 @@ SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...replace-with-service-role-key
# Public-facing base URL of this deployment. Used in OAuth redirects.
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Secrets vault encryption key (required for the /ops Secrets feature).
# 32 bytes of cryptographically random data, hex-encoded (64 hex chars).
# Generate with: openssl rand -hex 32
# Without this set, the secrets feature falls back to legacy plain-text storage.
# Rotating this key invalidates all existing ciphertexts; re-enter each secret.
SECRETS_ENCRYPTION_KEY=
# Node env (optional, defaults to "development" locally)
NODE_ENV=development
@@ -26,8 +31,8 @@ NODE_ENV=development
# 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.
# ─────────────────────────────────────────────────────────────────────────────
# -----------------------------------------------------------------------------
# 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.
# -----------------------------------------------------------------------------
+14
View File
@@ -5,6 +5,20 @@ All notable changes to this project are documented here. Format follows [Keep a
## [Unreleased]
### Added
- **Forum** at `/forum`: top-level discussion threads with categories, pinning, locking. Author or admin/board can edit; admin can delete.
- **Polymorphic comment threads** on proposals, incidents, and policies. New `comments` table keyed on `(entity_type, entity_id)`. Renders markdown.
- **User-creatable chat channels**: inline "+ New channel" form in `/comms` (name, description, type). RLS now allows any member to create; default channels stay protected.
- **Custom membership tiers with prices**: new `space_tiers` table per space. Settings -> Tiers tab to list, create, edit (including inline price edit), archive, and delete custom tiers. Built-in `plus`/`basic`/`associate` tiers seed on space creation and on existing spaces via backfill. `space_members.tier_id` FK backfilled from the legacy enum.
- **Customizable role labels and custom org roles**: `space_role_labels` (rename/recolor built-ins per space) and `space_custom_roles` (non-privileged extra labels) with member m2m. Server actions wired; UI surface pending in a follow-up.
- **Multi-code invites**: `space_invites` with per-code expiry, max-use cap, and enable/disable. Legacy `spaces.invite_code` backfilled as a permanent enabled invite. Settings -> Invites tab to manage. `joinSpace` honors any valid invite and increments `uses_count`.
- **Knowledge base markdown rendering**: clicking a KB entry opens a modal that renders `content` as markdown via react-markdown + remark-gfm.
- **AtlasLogo as the brand mark**: replaces the terminal icon on the landing nav and footer; `public/logo.svg` is the favicon.
- Live site link in `README.md`: <https://hackerspace.sh>.
### Changed
- **Secrets vault is now encrypted at rest**: AES-256-GCM with a per-secret IV using a `SECRETS_ENCRYPTION_KEY` env var. The /ops list endpoint no longer sends plaintext or ciphertext to the client. A new `revealSecret(id)` server action is the only path to plaintext and writes a `secret.revealed` row to `activity_log`. Existing legacy plaintext rows continue to work.
### Added (continued)
- Production deployment to a self-hosted DigitalOcean Droplet with automatic HTTPS via Caddy and Let's Encrypt.
- GitHub Actions workflow that pushes a deploy on every commit to `main`. Migrations are applied automatically via `_migrations_applied` tracking.
- Daily encrypted `pg_dumpall` backup cron, retained 14 days, written to the persistent block volume.
+2 -1
View File
@@ -6,7 +6,8 @@
A multi-tenant operating system for hackerspaces, makerspaces, and member-run shops. One installation can host many independent spaces. Each space owns its own members, tasks, projects, payments, knowledge base, proposals, incidents, policies, and chat channels. All data access is enforced at the database with row-level security.
Source: <https://github.com/virgilvox/hackerspace-management>
- Live site: <https://hackerspace.sh>
- Source: <https://github.com/virgilvox/hackerspace-management>
## Features
+87 -1
View File
@@ -1,8 +1,11 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { createClient } from '@/lib/supabase/client'
import { Hash, Users2, Send, ChevronLeft } from 'lucide-react'
import { Hash, Users2, Send, ChevronLeft, Plus } from 'lucide-react'
import { createChannel } from '@/lib/actions'
import type { Tables } from '@/types/database'
type Channel = Tables<'comms_channels'>
@@ -15,9 +18,33 @@ interface Props {
}
export default function CommsClient({ member, space, channels }: Props) {
const router = useRouter()
const [selectedChannel, setSelectedChannel] = useState<Channel | null>(channels[0] || null)
const [messages, setMessages] = useState<Message[]>([])
const [newMessage, setNewMessage] = useState('')
const [showNewChannel, setShowNewChannel] = useState(false)
const [newChannelName, setNewChannelName] = useState('')
const [newChannelDescription, setNewChannelDescription] = useState('')
const [newChannelType, setNewChannelType] = useState<'general' | 'area' | 'project'>('general')
const [creatingChannel, setCreatingChannel] = useState(false)
async function handleCreateChannel(e: React.FormEvent) {
e.preventDefault()
if (!newChannelName.trim()) return
setCreatingChannel(true)
const result = await createChannel({
name: newChannelName.trim().toLowerCase(),
description: newChannelDescription.trim() || undefined,
channel_type: newChannelType,
})
setCreatingChannel(false)
if ('error' in result && result.error) { toast.error(result.error); return }
toast.success('Channel created')
setNewChannelName('')
setNewChannelDescription('')
setShowNewChannel(false)
router.refresh()
}
const [sending, setSending] = useState(false)
const [showChannelList, setShowChannelList] = useState(true)
const messagesEndRef = useRef<HTMLDivElement>(null)
@@ -238,6 +265,65 @@ export default function CommsClient({ member, space, channels }: Props) {
))}
</div>
)}
{/* New channel inline form */}
<div className="mt-4 border-t border-border pt-3">
{!showNewChannel ? (
<button
onClick={() => setShowNewChannel(true)}
className="w-full flex items-center gap-1.5 px-2 py-1.5 rounded text-sm font-sans text-muted-foreground hover:bg-muted hover:text-foreground transition"
>
<Plus className="w-3.5 h-3.5" />
New channel
</button>
) : (
<form onSubmit={handleCreateChannel} className="space-y-2 px-2">
<input
type="text"
value={newChannelName}
onChange={e => setNewChannelName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-'))}
placeholder="channel-name"
autoFocus
maxLength={50}
required
className="w-full bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary"
/>
<input
type="text"
value={newChannelDescription}
onChange={e => setNewChannelDescription(e.target.value)}
placeholder="Optional description"
maxLength={500}
className="w-full bg-background border border-border text-foreground font-sans text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary"
/>
<select
value={newChannelType}
onChange={e => setNewChannelType(e.target.value as 'general' | 'area' | 'project')}
className="w-full bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary"
>
<option value="general">General</option>
<option value="area">Area</option>
<option value="project">Project</option>
</select>
<div className="flex gap-1.5">
<button
type="submit"
disabled={creatingChannel || !newChannelName.trim()}
className="flex-1 bg-primary text-white text-xs font-sans px-2 py-1.5 rounded hover:bg-primary/90 transition disabled:opacity-50"
>
{creatingChannel ? 'Creating...' : 'Create'}
</button>
<button
type="button"
onClick={() => { setShowNewChannel(false); setNewChannelName(''); setNewChannelDescription('') }}
className="text-muted-foreground hover:text-foreground text-xs px-2 py-1.5 transition"
>
Cancel
</button>
</div>
</form>
)}
</div>
</div>
</aside>
+94
View File
@@ -0,0 +1,94 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Pin, Lock } from 'lucide-react'
import { createClient } from '@/lib/supabase/server'
import { CommentThread } from '@/components/comments/comment-thread'
import { loadComments } from '@/components/comments/load-comments'
import { ThreadActions } from './thread-actions'
export default async function ForumThreadPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return null
const { data: self } = await supabase
.from('space_members')
.select('id, space_id, role, display_name')
.eq('user_id', user.id)
.in('status', ['current', 'unverified', 'late'])
.single()
if (!self) return null
const { data: thread } = await supabase
.from('forum_threads')
.select('id, title, body, category, pinned, locked, comment_count, created_at, author_id, space_id')
.eq('id', id)
.single()
if (!thread) notFound()
if (thread.space_id !== self.space_id) notFound()
let authorName = 'Member'
if (thread.author_id) {
const { data: a } = await supabase
.from('space_members')
.select('display_name')
.eq('id', thread.author_id)
.single()
if (a?.display_name) authorName = a.display_name
}
const comments = await loadComments(supabase, 'forum_thread', thread.id)
const isAuthor = self.id === thread.author_id
const canModerate = self.role === 'admin' || self.role === 'board'
const canEditThread = isAuthor || canModerate
return (
<div className="p-4 md:p-6 max-w-4xl space-y-5">
<div>
<Link href="/forum" className="font-mono text-[11px] tracking-widest text-muted-foreground hover:text-foreground uppercase">
← Forum
</Link>
</div>
<header className="bg-card border border-border rounded p-5">
<div className="flex items-start justify-between gap-4 mb-2">
<div className="flex items-center gap-2 flex-wrap min-w-0">
{thread.pinned && <Pin className="w-4 h-4 text-amber-500 shrink-0" />}
{thread.locked && <Lock className="w-4 h-4 text-muted-foreground shrink-0" />}
<h1 className="font-sans text-xl font-semibold text-foreground">{thread.title}</h1>
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground uppercase tracking-widest">{thread.category}</span>
</div>
{canEditThread && (
<ThreadActions
threadId={thread.id}
pinned={thread.pinned}
locked={thread.locked}
canModerate={canModerate}
isAuthor={isAuthor}
/>
)}
</div>
<p className="font-mono text-[11px] text-muted-foreground">
{authorName} · {new Date(thread.created_at).toLocaleString()}
</p>
{thread.body && (
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none mt-4 break-words">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{thread.body}</ReactMarkdown>
</div>
)}
</header>
<CommentThread
entityType="forum_thread"
entityId={thread.id}
comments={comments}
currentMemberId={self.id}
canModerate={canModerate}
locked={thread.locked}
/>
</div>
)
}
+72
View File
@@ -0,0 +1,72 @@
'use client'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { Pin, Lock, Trash2 } from 'lucide-react'
import { updateForumThread, deleteForumThread } from '@/lib/actions'
interface Props {
threadId: string
pinned: boolean
locked: boolean
canModerate: boolean
isAuthor: boolean
}
export function ThreadActions({ threadId, pinned, locked, canModerate, isAuthor }: Props) {
const router = useRouter()
async function togglePinned() {
const result = await updateForumThread(threadId, { pinned: !pinned })
if (result.error) { toast.error(result.error); return }
router.refresh()
}
async function toggleLocked() {
const result = await updateForumThread(threadId, { locked: !locked })
if (result.error) { toast.error(result.error); return }
router.refresh()
}
async function handleDelete() {
if (!confirm('Delete this thread? All comments are deleted too.')) return
const result = await deleteForumThread(threadId)
if (result.error) { toast.error(result.error); return }
toast.success('Thread deleted')
router.push('/forum')
}
return (
<div className="flex items-center gap-1 shrink-0">
{canModerate && (
<>
<button
onClick={togglePinned}
className={`font-mono text-[10px] border border-border px-2 py-1 rounded transition flex items-center gap-1 ${pinned ? 'text-amber-600 bg-amber-50' : 'hover:border-amber-500 hover:text-amber-600'}`}
title={pinned ? 'Unpin' : 'Pin'}
>
<Pin className="w-3 h-3" />
{pinned ? 'Pinned' : 'Pin'}
</button>
<button
onClick={toggleLocked}
className={`font-mono text-[10px] border border-border px-2 py-1 rounded transition flex items-center gap-1 ${locked ? 'text-muted-foreground bg-muted' : 'hover:border-primary hover:text-primary'}`}
title={locked ? 'Unlock' : 'Lock'}
>
<Lock className="w-3 h-3" />
{locked ? 'Locked' : 'Lock'}
</button>
</>
)}
{(isAuthor || canModerate) && (
<button
onClick={handleDelete}
className="text-muted-foreground hover:text-red-500 transition p-1.5"
title="Delete"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { createForumThread } from '@/lib/actions'
const CATEGORIES = [
{ value: 'general', label: 'General' },
{ value: 'announcements', label: 'Announcements' },
{ value: 'projects', label: 'Projects' },
{ value: 'help', label: 'Help' },
{ value: 'feedback', label: 'Feedback' },
{ value: 'off-topic', label: 'Off topic' },
]
export function NewThreadForm() {
const router = useRouter()
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [category, setCategory] = useState('general')
const [saving, setSaving] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!title.trim()) return
setSaving(true)
const result = await createForumThread({ title: title.trim(), body: body.trim() || undefined, category })
setSaving(false)
if ('error' in result && result.error) { toast.error(result.error); return }
if ('id' in result && result.id) {
toast.success('Thread created')
router.push(`/forum/${result.id}`)
}
}
return (
<form onSubmit={handleSubmit} className="bg-card border border-border rounded p-5 space-y-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Title</label>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
maxLength={200}
required
autoFocus
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Category</label>
<select
value={category}
onChange={e => setCategory(e.target.value)}
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
>
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">Body (markdown)</label>
<textarea
value={body}
onChange={e => setBody(e.target.value)}
rows={8}
maxLength={20000}
placeholder="Markdown supported. Headings, lists, code blocks, links."
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
/>
</div>
<div className="flex items-center gap-2 justify-end">
<button
type="submit"
disabled={saving || !title.trim()}
className="bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition disabled:opacity-50"
>
{saving ? 'Posting...' : 'Post thread'}
</button>
</div>
</form>
)
}
+16
View File
@@ -0,0 +1,16 @@
import Link from 'next/link'
import { NewThreadForm } from './new-thread-form'
export default function NewThreadPage() {
return (
<div className="p-4 md:p-6 max-w-3xl">
<div className="mb-6">
<Link href="/forum" className="font-mono text-[11px] tracking-widest text-muted-foreground hover:text-foreground uppercase">
← Forum
</Link>
<h1 className="font-mono text-sm tracking-widest uppercase text-muted-foreground mt-2">New thread</h1>
</div>
<NewThreadForm />
</div>
)
}
+93
View File
@@ -0,0 +1,93 @@
import Link from 'next/link'
import { createClient } from '@/lib/supabase/server'
import { Pin, Lock, MessagesSquare, Plus } from 'lucide-react'
export default async function ForumIndexPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return null
const { data: self } = await supabase
.from('space_members')
.select('space_id, role, display_name, id')
.eq('user_id', user.id)
.in('status', ['current', 'unverified', 'late'])
.single()
if (!self) return null
const { data: threads } = await supabase
.from('forum_threads')
.select('id, title, category, pinned, locked, comment_count, last_comment_at, created_at, author_id')
.eq('space_id', self.space_id)
.order('pinned', { ascending: false })
.order('last_comment_at', { ascending: false, nullsFirst: false })
.order('created_at', { ascending: false })
.limit(200)
const authorIds = Array.from(new Set((threads ?? []).map(t => t.author_id).filter(Boolean) as string[]))
const authorMap: Record<string, string> = {}
if (authorIds.length > 0) {
const { data: authors } = await supabase
.from('space_members')
.select('id, display_name')
.in('id', authorIds)
for (const a of authors ?? []) authorMap[a.id] = a.display_name ?? 'Member'
}
return (
<div className="p-4 md:p-6 max-w-5xl">
<div className="flex items-center justify-between mb-6 gap-4">
<div>
<h1 className="font-mono text-sm tracking-widest uppercase text-muted-foreground mb-1">Forum</h1>
<p className="font-sans text-sm text-muted-foreground">Long-form discussion, announcements, and proposals before they become proposals.</p>
</div>
<Link
href="/forum/new"
className="flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition shrink-0"
>
<Plus className="w-3.5 h-3.5" /> New thread
</Link>
</div>
{threads && threads.length > 0 ? (
<div className="bg-card border border-border rounded divide-y divide-border">
{threads.map(t => (
<Link
key={t.id}
href={`/forum/${t.id}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-muted/30 transition"
>
<MessagesSquare className="w-4 h-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{t.pinned && <Pin className="w-3 h-3 text-amber-500" aria-label="Pinned" />}
{t.locked && <Lock className="w-3 h-3 text-muted-foreground" aria-label="Locked" />}
<span className="font-sans text-sm font-medium text-foreground truncate">{t.title}</span>
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground uppercase tracking-widest">{t.category}</span>
</div>
<p className="font-mono text-[11px] text-muted-foreground mt-0.5">
{(t.author_id && authorMap[t.author_id]) ?? 'Member'}
{' · '}
{t.comment_count} {t.comment_count === 1 ? 'reply' : 'replies'}
{t.last_comment_at && <> · last activity {new Date(t.last_comment_at).toLocaleString()}</>}
</p>
</div>
</Link>
))}
</div>
) : (
<div className="bg-card border border-border rounded p-10 text-center">
<MessagesSquare className="w-8 h-8 text-muted-foreground mx-auto mb-3" />
<p className="font-sans text-sm text-foreground mb-1">No threads yet.</p>
<p className="font-sans text-sm text-muted-foreground mb-4">Start the conversation.</p>
<Link
href="/forum/new"
className="inline-flex items-center gap-1.5 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition"
>
<Plus className="w-3.5 h-3.5" /> Start a thread
</Link>
</div>
)}
</div>
)
}
+12
View File
@@ -4,6 +4,8 @@ import { createClient } from '@/lib/supabase/server'
import type { Incident, IncidentUpdateRow } from '@/lib/types'
import { IncidentActions } from './incident-actions'
import { MarkdownBody } from '@/components/markdown'
import { CommentThread } from '@/components/comments/comment-thread'
import { loadComments } from '@/components/comments/load-comments'
export const dynamic = 'force-dynamic'
@@ -33,6 +35,8 @@ export default async function IncidentDetailPage({ params }: { params: Promise<{
const isAdminOrBoard = member.role === 'admin' || member.role === 'board'
const isReporter = incident.reporter_id === member.id
const comments = await loadComments(supabase, 'incident', incident.id)
const { data: updatesRaw } = await supabase
.from('incident_updates')
.select('*')
@@ -117,6 +121,14 @@ export default async function IncidentDetailPage({ params }: { params: Promise<{
hasAppeal={incident.appeal_proposal_id !== null}
appealProposalId={incident.appeal_proposal_id}
/>
<CommentThread
entityType="incident"
entityId={incident.id}
comments={comments}
currentMemberId={member.id}
canModerate={isAdminOrBoard}
/>
</div>
</div>
)
+63 -11
View File
@@ -3,7 +3,10 @@
import { useState, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { Plus, Search, Lock, Pin, Eye, EyeOff, Pencil, Trash2, X, ChevronDown, Users2 } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { createKbEntry, updateKbEntry, deleteKbEntry } from '@/lib/actions'
import { revealSecret, deleteSecret } from '@/lib/actions/secrets'
import { createClient } from '@/lib/supabase/client'
import { toast } from 'sonner'
import type { Tables, TablesInsert } from '@/types/database'
@@ -345,19 +348,19 @@ function SecretRow({ secret, onDelete }: { secret: Secret; onDelete: (id: string
const [loading, setLoading] = useState(false)
async function reveal() {
if (revealed) { setRevealed(false); return }
if (revealed) { setRevealed(false); setValue(null); return }
setLoading(true)
const supabase = createClient()
const { data } = await supabase.from('secrets').select('value').eq('id', secret.id).single()
const result = await revealSecret(secret.id)
setLoading(false)
if (data) { setValue(data.value); setRevealed(true) }
if (result.error) { toast.error(result.error); return }
setValue(result.value ?? '')
setRevealed(true)
}
async function handleDelete() {
if (!confirm(`Delete secret "${secret.title}"? This cannot be undone.`)) return
const supabase = createClient()
const { error } = await supabase.from('secrets').delete().eq('id', secret.id)
if (error) { toast.error(error.message); return }
const result = await deleteSecret(secret.id)
if (result.error) { toast.error(result.error); return }
toast.success('Secret deleted')
onDelete(secret.id)
}
@@ -402,7 +405,10 @@ function KbEntryRow({
onEdit: (e: KbEntry) => void
onDelete: (id: string) => void
}) {
async function handleDelete() {
const [viewing, setViewing] = useState(false)
async function handleDelete(e: React.MouseEvent) {
e.stopPropagation()
if (!confirm(`Delete "${entry.title}"?`)) return
const result = await deleteKbEntry(entry.id)
if ('error' in result && result.error) { toast.error(result.error); return }
@@ -410,12 +416,21 @@ function KbEntryRow({
onDelete(entry.id)
}
function handleEdit(e: React.MouseEvent) {
e.stopPropagation()
onEdit(entry)
}
return (
<div className={`flex items-start gap-3 px-4 py-4 hover:bg-muted/20 transition group border-l-4 ${
<>
<div
onClick={() => setViewing(true)}
className={`flex items-start gap-3 px-4 py-4 hover:bg-muted/20 transition group border-l-4 cursor-pointer ${
entry.is_pinned
? entry.visibility === 'admin_only' ? 'border-l-red-400' : entry.visibility === 'board' ? 'border-l-amber-400' : 'border-l-primary'
: 'border-l-transparent'
}`}>
}`}
>
<div className="w-8 h-8 rounded bg-muted flex items-center justify-center flex-shrink-0 text-base">
{entry.icon || (
<svg className="w-4 h-4 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -439,7 +454,7 @@ function KbEntryRow({
{VISIBILITY_LABELS[entry.visibility] ?? entry.visibility}
</span>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button onClick={() => onEdit(entry)} className="text-muted-foreground hover:text-primary transition p-1" title="Edit">
<button onClick={handleEdit} className="text-muted-foreground hover:text-primary transition p-1" title="Edit">
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={handleDelete} className="text-muted-foreground hover:text-red-500 transition p-1" title="Delete">
@@ -448,6 +463,43 @@ function KbEntryRow({
</div>
</div>
</div>
{viewing && (
<div className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4" onClick={() => setViewing(false)}>
<div
onClick={e => e.stopPropagation()}
className="bg-card rounded border border-border w-full max-w-3xl max-h-[90vh] overflow-y-auto"
>
<div className="sticky top-0 bg-card border-b border-border px-5 py-3 flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{entry.icon && <span className="text-lg">{entry.icon}</span>}
<h2 className="font-sans text-lg font-semibold text-foreground">{entry.title}</h2>
{entry.is_pinned && <Pin className="w-3.5 h-3.5 text-primary" />}
</div>
<p className="font-mono text-[10px] text-muted-foreground mt-1">
{entry.area && <span>{entry.area} · </span>}
{VISIBILITY_LABELS[entry.visibility] ?? entry.visibility}
{' · '}
updated {new Date(entry.updated_at).toLocaleDateString()} by {entry.updated_by_name ?? 'unknown'}
</p>
</div>
<button onClick={() => onEdit(entry)} className="text-muted-foreground hover:text-primary transition p-1" title="Edit">
<Pencil className="w-4 h-4" />
</button>
<button onClick={() => setViewing(false)} className="text-muted-foreground hover:text-foreground transition p-1" title="Close">
<X className="w-4 h-4" />
</button>
</div>
<div className="px-5 py-5">
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none break-words">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{entry.content ?? ''}</ReactMarkdown>
</div>
</div>
</div>
</div>
)}
</>
)
}
+4 -1
View File
@@ -17,7 +17,10 @@ export default async function OpsPage() {
supabase.from('knowledge_base').select('*').eq('space_id', member.space_id).order('is_pinned', { ascending: false }).order('created_at', { ascending: false }),
supabase.from('area_leads').select('*').eq('space_id', member.space_id).order('area_name'),
canSeeSecrets
? supabase.from('secrets').select('id, title, area, created_at, label, value, description, icon, space_id, created_by, updated_at, category, notes').eq('space_id', member.space_id)
// CRITICAL: never select `value` or `encrypted_value` here. The list
// endpoint only returns metadata; the plaintext is fetched on demand
// through the revealSecret() server action.
? supabase.from('secrets').select('id, title, area, created_at, label, description, icon, space_id, created_by, updated_at, category, notes, encryption_version').eq('space_id', member.space_id)
: Promise.resolve({ data: [] as Tables<'secrets'>[] }),
])
+23
View File
@@ -4,6 +4,8 @@ import { createClient } from '@/lib/supabase/server'
import type { Policy } from '@/lib/types'
import { PolicyActions } from './policy-actions'
import { MarkdownBody } from '@/components/markdown'
import { CommentThread } from '@/components/comments/comment-thread'
import { loadComments } from '@/components/comments/load-comments'
export const dynamic = 'force-dynamic'
@@ -35,6 +37,19 @@ export default async function PolicyDetailPage({ params }: { params: Promise<{ s
const current = versions.find(p => p.status === 'active') ?? versions[0]
const isAdminOrBoard = member.role === 'admin' || member.role === 'board'
// Find the member's own id (needed for the discussion thread).
const { data: self } = await supabase
.from('space_members')
.select('id')
.eq('user_id', user.id)
.eq('space_id', member.space_id)
.maybeSingle()
// Comments are attached to the policy slug across all versions, so we anchor
// on the slug-stable version id of the current row (v1 baseline).
const baseId = versions[versions.length - 1].id
const comments = await loadComments(supabase, 'policy', baseId)
return (
<div className="min-h-screen bg-background">
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center gap-3">
@@ -102,6 +117,14 @@ export default async function PolicyDetailPage({ params }: { params: Promise<{ s
))}
</ul>
</section>
<CommentThread
entityType="policy"
entityId={baseId}
comments={comments}
currentMemberId={self?.id ?? null}
canModerate={isAdminOrBoard}
/>
</div>
</div>
)
+12
View File
@@ -5,6 +5,8 @@ import type { Proposal, ProposalVote } from '@/lib/types'
import { ProposalStatusBadge } from '../proposal-badges'
import { ProposalActions } from './proposal-actions'
import { MarkdownBody } from '@/components/markdown'
import { CommentThread } from '@/components/comments/comment-thread'
import { loadComments } from '@/components/comments/load-comments'
export const dynamic = 'force-dynamic'
@@ -65,6 +67,8 @@ export default async function ProposalDetailPage({ params }: { params: Promise<{
? Math.min(100, Math.round((totalCounted / proposal.quorum_required) * 100))
: 0
const comments = await loadComments(supabase, 'proposal', proposal.id)
return (
<div className="min-h-screen bg-background">
<div className="bg-sidebar px-4 md:px-6 py-3 flex items-center gap-3">
@@ -160,6 +164,14 @@ export default async function ProposalDetailPage({ params }: { params: Promise<{
</ul>
)}
</section>
<CommentThread
entityType="proposal"
entityId={proposal.id}
comments={comments}
currentMemberId={(member as { id: string }).id}
canModerate={isAdminOrBoard}
/>
</div>
</div>
)
+33
View File
@@ -25,6 +25,18 @@ export default async function SettingsPage() {
.order('sort_order', { ascending: true })
.order('name', { ascending: true })
const { data: tiers } = await supabase
.from('space_tiers')
.select('id, slug, name, description, monthly_price_cents, billing_cadence, is_system, is_archived, sort_order')
.eq('space_id', member.space_id)
.order('sort_order', { ascending: true })
const { data: invites } = await supabase
.from('space_invites')
.select('id, code, label, expires_at, max_uses, uses_count, is_enabled, created_at')
.eq('space_id', member.space_id)
.order('created_at', { ascending: false })
const isAdmin = member?.role === 'admin'
return (
@@ -41,6 +53,27 @@ export default async function SettingsPage() {
sort_order: number
is_archived: boolean
}>}
tiers={(tiers ?? []) as Array<{
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
}>}
invites={(invites ?? []) as Array<{
id: string
code: string
label: string | null
expires_at: string | null
max_uses: number | null
uses_count: number
is_enabled: boolean
created_at: string
}>}
/>
)
}
+362 -5
View File
@@ -2,7 +2,8 @@
import { useState } from 'react'
import { X, Check, Copy, Eye, EyeOff, RotateCcw } from 'lucide-react'
import { updateSpaceSettings, saveIntegration, disconnectIntegration, rotateWebhookSecret, updateSpaceVisibility, createArea, updateArea, deleteArea } from '@/lib/actions'
import { updateSpaceSettings, saveIntegration, disconnectIntegration, rotateWebhookSecret, updateSpaceVisibility, createArea, updateArea, deleteArea, createTier, updateTier, deleteTier, createInvite, updateInvite, deleteInvite } from '@/lib/actions'
import { toast } from 'sonner'
import { financialVisibilities, directoryVisibilities } from '@/lib/validations'
type Area = {
@@ -24,6 +25,31 @@ interface Props {
integrations: Integration[]
currentRole: string
areas: Area[]
tiers: Tier[]
invites: Invite[]
}
export interface Tier {
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
}
export interface Invite {
id: string
code: string
label: string | null
expires_at: string | null
max_uses: number | null
uses_count: number
is_enabled: boolean
created_at: string
}
const ROLES_INFO = [
@@ -98,9 +124,15 @@ const INTEGRATIONS_CONFIG = [
},
]
export default function SettingsClient({ space, isAdmin, integrations, currentRole, areas: initialAreas }: Props) {
const [activeTab, setActiveTab] = useState<'space' | 'roles' | 'integrations' | 'webhooks'>('space')
export default function SettingsClient({ space, isAdmin, integrations, currentRole, areas: initialAreas, tiers: initialTiers, invites: initialInvites }: Props) {
const [activeTab, setActiveTab] = useState<'space' | 'roles' | 'tiers' | 'invites' | 'integrations' | 'webhooks'>('space')
const [areas, setAreas] = useState<Area[]>(initialAreas)
const [tiers, setTiers] = useState<Tier[]>(initialTiers)
const [invites, setInvites] = useState<Invite[]>(initialInvites)
const [showNewTier, setShowNewTier] = useState(false)
const [newTier, setNewTier] = useState({ slug: '', name: '', description: '', monthly_price_cents: 0, billing_cadence: 'monthly' as 'monthly' | 'quarterly' | 'annual' | 'one_time' | 'custom' })
const [showNewInvite, setShowNewInvite] = useState(false)
const [newInvite, setNewInvite] = useState({ code: '', label: '', expires_at: '', max_uses: '' })
const [newArea, setNewArea] = useState({ code: '', name: '' })
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
@@ -236,11 +268,11 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
<div className="flex-1 p-4 md:p-6">
{/* Tabs */}
<div className="bg-card border-b border-border flex gap-4 md:gap-6 px-2 mb-6 rounded-t overflow-x-auto">
{(['space', 'roles', 'integrations', 'webhooks'] as const).map(tab => (
{(['space', 'roles', 'tiers', 'invites', 'integrations', 'webhooks'] as const).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`font-sans text-sm py-3 border-b-2 transition capitalize ${
className={`font-sans text-sm py-3 border-b-2 transition capitalize whitespace-nowrap ${
activeTab === tab ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
@@ -681,6 +713,331 @@ export default function SettingsClient({ space, isAdmin, integrations, currentRo
</div>
)}
{/* Tiers Tab */}
{activeTab === 'tiers' && (
<div className="bg-card rounded border border-border p-6">
<div className="flex items-center justify-between mb-4 gap-3">
<div>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Membership Tiers</p>
<p className="font-sans text-sm text-muted-foreground mt-1">
Customize tier names, prices, and billing cadence. Every space starts with the built-in plus, basic, and associate tiers.
</p>
</div>
{isAdmin && (
<button
onClick={() => setShowNewTier(v => !v)}
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition flex items-center gap-1 whitespace-nowrap"
>
{showNewTier ? 'Cancel' : '+ New tier'}
</button>
)}
</div>
{showNewTier && (
<form
onSubmit={async e => {
e.preventDefault()
const cents = Math.round(parseFloat(String(newTier.monthly_price_cents) || '0') * 100)
const result = await createTier({
slug: newTier.slug.trim().toLowerCase(),
name: newTier.name.trim(),
description: newTier.description.trim() || undefined,
monthly_price_cents: isNaN(cents) ? 0 : cents,
billing_cadence: newTier.billing_cadence,
})
if ('error' in result && result.error) { toast.error(result.error); return }
toast.success('Tier created')
setShowNewTier(false)
setNewTier({ slug: '', name: '', description: '', monthly_price_cents: 0, billing_cadence: 'monthly' })
setTiers(prev => [...prev, {
id: (result as { id: string }).id,
slug: newTier.slug.trim().toLowerCase(),
name: newTier.name.trim(),
description: newTier.description.trim() || null,
monthly_price_cents: isNaN(cents) ? 0 : cents,
billing_cadence: newTier.billing_cadence,
is_system: false,
is_archived: false,
sort_order: 100,
}])
}}
className="mb-4 p-4 border border-border rounded bg-background grid grid-cols-1 md:grid-cols-2 gap-3"
>
<input
type="text"
value={newTier.slug}
onChange={e => setNewTier({ ...newTier, slug: e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '-') })}
placeholder="slug (lowercase)"
maxLength={50}
required
className="bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary"
/>
<input
type="text"
value={newTier.name}
onChange={e => setNewTier({ ...newTier, name: e.target.value })}
placeholder="Display name"
maxLength={100}
required
className="bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1.5 focus:outline-none focus:border-primary"
/>
<input
type="number"
step="0.01"
min="0"
value={newTier.monthly_price_cents as unknown as string}
onChange={e => setNewTier({ ...newTier, monthly_price_cents: parseFloat(e.target.value || '0') as unknown as number })}
placeholder="Price in dollars"
className="bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary"
/>
<select
value={newTier.billing_cadence}
onChange={e => setNewTier({ ...newTier, billing_cadence: e.target.value as typeof newTier.billing_cadence })}
className="bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary"
>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="annual">Annual</option>
<option value="one_time">One time</option>
<option value="custom">Custom</option>
</select>
<input
type="text"
value={newTier.description}
onChange={e => setNewTier({ ...newTier, description: e.target.value })}
placeholder="Description (optional)"
maxLength={2000}
className="md:col-span-2 bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1.5 focus:outline-none focus:border-primary"
/>
<button
type="submit"
className="md:col-span-2 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition"
>
Create tier
</button>
</form>
)}
<ul className="divide-y divide-border">
{tiers.length === 0 && (
<li className="py-6 text-center font-sans text-sm text-muted-foreground">No tiers yet.</li>
)}
{tiers.map(t => (
<li key={t.id} className="py-3 flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-sans text-sm font-medium text-foreground">{t.name}</span>
<span className="font-mono text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground">{t.slug}</span>
{t.is_system && <span className="font-mono text-[10px] text-amber-600">built-in</span>}
{t.is_archived && <span className="font-mono text-[10px] text-muted-foreground">archived</span>}
</div>
{t.description && <p className="font-sans text-xs text-muted-foreground mt-0.5">{t.description}</p>}
<p className="font-mono text-[10px] text-muted-foreground/70 mt-0.5">
${(t.monthly_price_cents / 100).toFixed(2)} / {t.billing_cadence.replace('_', ' ')}
</p>
</div>
{isAdmin && (
<div className="flex items-center gap-1.5">
<input
type="number"
step="0.01"
min="0"
defaultValue={(t.monthly_price_cents / 100).toFixed(2)}
onBlur={async e => {
const cents = Math.round(parseFloat(e.target.value || '0') * 100)
if (cents === t.monthly_price_cents) return
const result = await updateTier(t.id, { monthly_price_cents: cents })
if (result.error) { toast.error(result.error); return }
setTiers(prev => prev.map(x => x.id === t.id ? { ...x, monthly_price_cents: cents } : x))
toast.success('Price updated')
}}
className="w-24 bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1 focus:outline-none focus:border-primary"
/>
{!t.is_system && (
<button
onClick={async () => {
if (!confirm(`Delete tier "${t.name}"?`)) return
const result = await deleteTier(t.id)
if (result.error) { toast.error(result.error); return }
setTiers(prev => prev.filter(x => x.id !== t.id))
toast.success('Tier deleted')
}}
className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition"
>
Delete
</button>
)}
{t.is_system && (
<button
onClick={async () => {
const result = await updateTier(t.id, { is_archived: !t.is_archived })
if (result.error) { toast.error(result.error); return }
setTiers(prev => prev.map(x => x.id === t.id ? { ...x, is_archived: !x.is_archived } : x))
}}
className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-primary hover:text-primary transition"
>
{t.is_archived ? 'Restore' : 'Archive'}
</button>
)}
</div>
)}
</li>
))}
</ul>
</div>
)}
{/* Invites Tab */}
{activeTab === 'invites' && (
<div className="bg-card rounded border border-border p-6">
<div className="flex items-center justify-between mb-4 gap-3">
<div>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Invite Codes</p>
<p className="font-sans text-sm text-muted-foreground mt-1">
Generate codes for new members to join this space. Set an expiry, a max-use cap, or leave both blank for a permanent code. Toggle off to revoke.
</p>
</div>
{isAdmin && (
<button
onClick={() => setShowNewInvite(v => !v)}
className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition flex items-center gap-1 whitespace-nowrap"
>
{showNewInvite ? 'Cancel' : '+ New invite'}
</button>
)}
</div>
{showNewInvite && (
<form
onSubmit={async e => {
e.preventDefault()
const result = await createInvite({
code: newInvite.code.trim().toUpperCase() || undefined,
label: newInvite.label.trim() || undefined,
expires_at: newInvite.expires_at || undefined,
max_uses: newInvite.max_uses ? Number(newInvite.max_uses) : undefined,
})
if ('error' in result && result.error) { toast.error(result.error); return }
const created = result as { id: string; code: string }
toast.success(`Invite created: ${created.code}`)
setShowNewInvite(false)
setInvites(prev => [{
id: created.id,
code: created.code,
label: newInvite.label.trim() || null,
expires_at: newInvite.expires_at || null,
max_uses: newInvite.max_uses ? Number(newInvite.max_uses) : null,
uses_count: 0,
is_enabled: true,
created_at: new Date().toISOString(),
}, ...prev])
setNewInvite({ code: '', label: '', expires_at: '', max_uses: '' })
}}
className="mb-4 p-4 border border-border rounded bg-background grid grid-cols-1 md:grid-cols-2 gap-3"
>
<input
type="text"
value={newInvite.code}
onChange={e => setNewInvite({ ...newInvite, code: e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, '') })}
placeholder="CODE (blank = auto-generate)"
maxLength={32}
className="bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary"
/>
<input
type="text"
value={newInvite.label}
onChange={e => setNewInvite({ ...newInvite, label: e.target.value })}
placeholder="Label (e.g., open house 2026)"
maxLength={100}
className="bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1.5 focus:outline-none focus:border-primary"
/>
<input
type="datetime-local"
value={newInvite.expires_at}
onChange={e => setNewInvite({ ...newInvite, expires_at: e.target.value })}
className="bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary"
/>
<input
type="number"
min="1"
value={newInvite.max_uses}
onChange={e => setNewInvite({ ...newInvite, max_uses: e.target.value })}
placeholder="Max uses (blank = unlimited)"
className="bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary"
/>
<button
type="submit"
className="md:col-span-2 bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition"
>
Create invite
</button>
</form>
)}
<ul className="divide-y divide-border">
{invites.length === 0 && (
<li className="py-6 text-center font-sans text-sm text-muted-foreground">No invites yet.</li>
)}
{invites.map(inv => {
const expired = inv.expires_at ? new Date(inv.expires_at) < new Date() : false
const usedUp = inv.max_uses !== null && inv.uses_count >= inv.max_uses
return (
<li key={inv.id} className="py-3 flex items-center gap-3 flex-wrap">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<code className="font-mono text-sm font-semibold text-primary bg-primary/10 px-2 py-0.5 rounded">{inv.code}</code>
{inv.label && <span className="font-sans text-sm text-foreground">{inv.label}</span>}
{!inv.is_enabled && <span className="font-mono text-[10px] text-red-500">disabled</span>}
{expired && <span className="font-mono text-[10px] text-red-500">expired</span>}
{usedUp && <span className="font-mono text-[10px] text-red-500">at cap</span>}
</div>
<p className="font-mono text-[10px] text-muted-foreground/70 mt-0.5">
{inv.expires_at ? `expires ${new Date(inv.expires_at).toLocaleString()}` : 'permanent'}
{' · '}
uses {inv.uses_count}{inv.max_uses ? ` / ${inv.max_uses}` : ' (unlimited)'}
</p>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={() => { navigator.clipboard.writeText(inv.code); toast.success('Copied') }}
className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-primary hover:text-primary transition"
>
Copy
</button>
{isAdmin && (
<>
<button
onClick={async () => {
const result = await updateInvite(inv.id, { is_enabled: !inv.is_enabled })
if (result.error) { toast.error(result.error); return }
setInvites(prev => prev.map(x => x.id === inv.id ? { ...x, is_enabled: !x.is_enabled } : x))
}}
className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-primary hover:text-primary transition"
>
{inv.is_enabled ? 'Disable' : 'Enable'}
</button>
<button
onClick={async () => {
if (!confirm(`Delete invite "${inv.code}"?`)) return
const result = await deleteInvite(inv.id)
if (result.error) { toast.error(result.error); return }
setInvites(prev => prev.filter(x => x.id !== inv.id))
toast.success('Invite deleted')
}}
className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition"
>
Delete
</button>
</>
)}
</div>
</li>
)
})}
</ul>
</div>
)}
{/* Webhooks Tab */}
{activeTab === 'webhooks' && (
<div className="bg-card rounded border border-border p-6 space-y-6">
+3 -2
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { AtlasLogo } from '@/components/resources/atlas-logo'
// Icons
function IcoUsers({ className }: { className?: string }) {
@@ -318,7 +319,7 @@ export default function LandingPage() {
<nav className="border-b border-border bg-sidebar">
<div className="max-w-6xl mx-auto px-4 md:px-6 py-3 flex items-center justify-between gap-4">
<div className="flex items-center gap-2 min-w-0">
<IcoTerminal className="w-5 h-5 text-primary shrink-0" />
<AtlasLogo className="w-6 h-6 text-primary shrink-0" />
<span className="font-mono text-sm font-bold text-white truncate">hackerspace.sh</span>
</div>
<div className="flex items-center gap-2 shrink-0">
@@ -515,7 +516,7 @@ export default function LandingPage() {
<div className="max-w-6xl mx-auto px-4 md:px-6 py-8">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div className="flex items-center gap-2">
<IcoTerminal className="w-4 h-4 text-primary" />
<AtlasLogo className="w-5 h-5 text-primary" />
<span className="font-mono text-xs text-sidebar-foreground">hackerspace.sh</span>
</div>
<div className="flex items-center gap-4">
+3 -15
View File
@@ -10,21 +10,9 @@ export const metadata: Metadata = {
title: 'hackerspace.sh — Manage Your Space',
description: 'The operating system for hackerspaces: members, tasks, projects, ops, payments, and comms.',
icons: {
icon: [
{
url: '/icon-light-32x32.png',
media: '(prefers-color-scheme: light)',
},
{
url: '/icon-dark-32x32.png',
media: '(prefers-color-scheme: dark)',
},
{
url: '/icon.svg',
type: 'image/svg+xml',
},
],
apple: '/apple-icon.png',
icon: [{ url: '/logo.svg', type: 'image/svg+xml' }],
apple: '/logo.svg',
shortcut: '/logo.svg',
},
}
+2
View File
@@ -13,6 +13,7 @@ import {
Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2,
MessageSquare, Users, CreditCard, BookUser, Download, LogOut,
Vote, ShieldAlert, ScrollText, LineChart, UserCircle, UserSearch,
MessagesSquare,
} from 'lucide-react'
interface NavLinkProps {
@@ -93,6 +94,7 @@ export function AppSidebar({ member, taskBadge = 0, commsBadge = 0, paymentBadge
<NavLink href="/projects" label="Projects" icon={FolderKanban} active={isActive('/projects')} onClick={onNav} />
<NavLink href="/ops" label="Ops & Facilities" icon={Settings2} active={isActive('/ops')} onClick={onNav} />
<NavLink href="/comms" label="Comms" icon={MessageSquare} active={isActive('/comms')} badge={commsBadge} onClick={onNav} />
<NavLink href="/forum" label="Forum" icon={MessagesSquare} active={isActive('/forum')} onClick={onNav} />
<div className="px-3 mt-4 mb-1">
<p className="text-[10px] uppercase tracking-widest text-[var(--sidebar-foreground)]/30 px-2 py-1 font-mono">Governance</p>
+127
View File
@@ -0,0 +1,127 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { addComment, deleteComment } from '@/lib/actions'
import { Trash2, MessageCircle } from 'lucide-react'
export type CommentEntityType = 'forum_thread' | 'proposal' | 'incident' | 'policy'
export interface CommentRow {
id: string
body: string
created_at: string
edited_at: string | null
author_id: string | null
author_name: string | null
}
interface Props {
entityType: CommentEntityType
entityId: string
comments: CommentRow[]
currentMemberId: string | null
canModerate: boolean
locked?: boolean
}
export function CommentThread({ entityType, entityId, comments, currentMemberId, canModerate, locked }: Props) {
const router = useRouter()
const [body, setBody] = useState('')
const [saving, setSaving] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!body.trim() || locked) return
setSaving(true)
const result = await addComment({ entity_type: entityType, entity_id: entityId, body: body.trim() })
setSaving(false)
if ('error' in result && result.error) { toast.error(result.error); return }
setBody('')
router.refresh()
}
async function handleDelete(commentId: string) {
if (!confirm('Delete this comment?')) return
const result = await deleteComment(commentId)
if (result.error) { toast.error(result.error); return }
toast.success('Comment deleted')
router.refresh()
}
return (
<section className="bg-card border border-border rounded p-5">
<div className="flex items-center gap-2 mb-4">
<MessageCircle className="w-4 h-4 text-muted-foreground" />
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
Discussion{comments.length > 0 ? ` (${comments.length})` : ''}
</p>
</div>
{comments.length === 0 && (
<p className="font-sans text-sm text-muted-foreground mb-4">No comments yet.</p>
)}
{comments.length > 0 && (
<ul className="space-y-4 mb-6">
{comments.map(c => {
const mine = currentMemberId && c.author_id === currentMemberId
const canDelete = mine || canModerate
return (
<li key={c.id} className="bg-background/60 border border-border rounded p-3">
<div className="flex items-center gap-2 mb-1.5">
<span className="font-sans text-xs font-medium text-foreground">{c.author_name ?? 'Removed member'}</span>
<span className="font-mono text-[10px] text-muted-foreground">
{new Date(c.created_at).toLocaleString()}
{c.edited_at && ' · edited'}
</span>
{canDelete && (
<button
onClick={() => handleDelete(c.id)}
className="ml-auto text-muted-foreground hover:text-red-500 transition p-1"
title="Delete"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none break-words">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{c.body}</ReactMarkdown>
</div>
</li>
)
})}
</ul>
)}
{locked ? (
<p className="font-sans text-sm text-muted-foreground border-t border-border pt-3">
This thread is locked. New comments are disabled.
</p>
) : (
<form onSubmit={handleSubmit} className="border-t border-border pt-4 space-y-2">
<textarea
value={body}
onChange={e => setBody(e.target.value)}
rows={3}
maxLength={10000}
placeholder="Write a comment (markdown supported)..."
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
/>
<div className="flex items-center justify-end">
<button
type="submit"
disabled={saving || !body.trim()}
className="bg-primary text-white text-xs font-sans px-3 py-1.5 rounded hover:bg-primary/90 transition disabled:opacity-50"
>
{saving ? 'Posting...' : 'Post comment'}
</button>
</div>
</form>
)}
</section>
)
}
+37
View File
@@ -0,0 +1,37 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { CommentEntityType, CommentRow } from './comment-thread'
// Server-side fetch helper. Used from server components that render a
// CommentThread for any of the four supported entity types.
export async function loadComments(
supabase: SupabaseClient,
entityType: CommentEntityType,
entityId: string,
): Promise<CommentRow[]> {
const { data } = await supabase
.from('comments')
.select('id, body, created_at, edited_at, author_id')
.eq('entity_type', entityType)
.eq('entity_id', entityId)
.order('created_at', { ascending: true })
const rows = data ?? []
const authorIds = Array.from(new Set(rows.map(r => r.author_id).filter(Boolean) as string[]))
const authorMap: Record<string, string> = {}
if (authorIds.length > 0) {
const { data: authors } = await supabase
.from('space_members')
.select('id, display_name')
.in('id', authorIds)
for (const a of authors ?? []) authorMap[a.id] = a.display_name ?? 'Member'
}
return rows.map(r => ({
id: r.id,
body: r.body,
created_at: r.created_at,
edited_at: r.edited_at,
author_id: r.author_id,
author_name: r.author_id ? (authorMap[r.author_id] ?? 'Member') : null,
}))
}
+77
View File
@@ -4,6 +4,83 @@ Append-only. Newest entries on top. Keep each entry to one screen.
---
## 2026-05-14 (pass 11) — Forum, comments, secrets encryption, custom tiers/invites, logo
Branch: `main`. Pushed.
### What I did
Major feature expansion + security fix surfaced by audit.
Schema migration `scripts/021_forum_tiers_roles_invites_secrets.sql`, folded into `scripts/schema.sql` Section 13:
- `forum_threads` (title, body, category, pinned, locked, comment_count, last_comment_at). Full RLS.
- `comments` polymorphic over (`forum_thread` | `proposal` | `incident` | `policy`) with `parent_id` for nesting. Trigger maintains `forum_threads.comment_count` and `last_comment_at`.
- `space_tiers` per-space (slug, name, description, monthly_price_cents, billing_cadence, is_system, sort_order). Backfills `plus`/`basic`/`associate` for every existing space and seeds them on `INSERT spaces` via trigger. `space_members.tier_id` FK added and backfilled from the legacy enum.
- `space_role_labels` (rename + recolor built-in roles per space). `space_custom_roles` (non-privileged extra labels). `space_member_custom_roles` m2m.
- `space_invites` with `code`, `label`, `expires_at`, `max_uses`, `uses_count`, `is_enabled`. Legacy `spaces.invite_code` backfilled as a permanent enabled invite.
- `secrets.encrypted_value bytea` + `encryption_version smallint`. Secrets RLS tightened.
- `knowledge_base.render_markdown` boolean (default true).
- `comms_channels` INSERT policy: any member of the space can create. UPDATE/DELETE: admin/board OR creator (default channels protected).
Server actions:
- `lib/actions/forum.ts`: `createForumThread`, `updateForumThread` (pin/lock moderator-gated), `deleteForumThread`, `addComment`, `editComment`, `deleteComment`.
- `lib/actions/comms.ts`: `createChannel`, `renameChannel`, `deleteChannel`.
- `lib/actions/tiers.ts`: `createTier`, `updateTier`, `deleteTier`.
- `lib/actions/roles.ts`: `upsertRoleLabel`, `createCustomRole`, `updateCustomRole`, `deleteCustomRole`, `assignCustomRole`, `unassignCustomRole`.
- `lib/actions/invites.ts`: `createInvite`, `updateInvite`, `deleteInvite`.
- `lib/actions/secrets.ts`: rewritten to use AES-256-GCM at rest. New `revealSecret(id)` returns plaintext only via server action and logs `secret.revealed` to `activity_log`. New `updateSecret`. List endpoint in `/ops/page.tsx` no longer selects `value` or `encrypted_value` — fixes the screenshot bug where ciphertext was already in the page bundle before "Reveal".
- `lib/secrets/crypto.ts`: AES-256-GCM helpers. Reads `SECRETS_ENCRYPTION_KEY` env (64 hex chars). Falls back to plaintext storage if the env var isn't set.
UI:
- `/forum` (index with pinned/locked badges, last-activity sort), `/forum/new`, `/forum/[id]`.
- `components/comments/CommentThread` + `loadComments` helper. Embedded on `/proposals/[id]`, `/incidents/[id]`, `/policies/[slug]`.
- `/comms` sidebar: inline "+ New channel" form (name + description + type).
- `/ops` Secrets tab: Reveal now calls the `revealSecret` action and respects encryption.
- `/ops` KB: row click opens a modal that renders `entry.content` as markdown via react-markdown + remark-gfm.
- `/settings`: new **Tiers** tab (list/create/edit/archive/delete custom tiers, inline price edit). New **Invites** tab (list/create/disable/delete; copy code).
- Landing page nav + footer now use `AtlasLogo` instead of `IcoTerminal`. `public/logo.svg` is the favicon (referenced by `app/layout.tsx` metadata).
- Sidebar gets a `Forum` link below `Comms`.
Auth flow: `joinSpace` honors codes from `space_invites` first (enforces enabled/expired/max-uses), falls back to legacy `spaces.invite_code`, and increments `uses_count` on success.
Env: `SECRETS_ENCRYPTION_KEY` added to `.env.example` and to `secrets/base.env` locally (gitignored).
Validations: added `updateSecretSchema`, `createForumThreadSchema`, `updateForumThreadSchema`, `createCommentSchema`, `updateCommentSchema`, `createTierSchema`, `updateTierSchema`, `upsertRoleLabelSchema`, `createCustomRoleSchema`, `updateCustomRoleSchema`, `createInviteSchema`, `updateInviteSchema`, `createChannelSchema`.
### Open items (deferred for follow-up)
- Custom role labels and custom roles UI in Settings. Data layer + RLS + server actions all in place; UI surface not yet added. Schema rows can be created via PostgREST or psql today.
- `importMembers` and `importPaymentsCsv` lack Zod schemas (audit-flagged). Logic still filters on type, but should adopt schemas.
- Add `SECRETS_ENCRYPTION_KEY` to the droplet's `.env.production` before the next deploy. Without it, secrets fall back to legacy plaintext storage and the screenshot bug returns. (Update done locally; deploy env update pending.)
- Migration 021 must be applied on production. The deploy script runs it idempotently; first deploy after this commit will write `_migrations_applied` row.
### Verification
- `pnpm test` -> 266/266 pass.
- `pnpm build` -> green, all new routes in the route table (`/forum`, `/forum/new`, `/forum/[id]`).
---
## 2026-05-14 (pass 10) — Production deploy + open-source release prep
Branch: `main`. Pushed (`8ad1ca4`).
Self-hosted production deploy completed on a single DigitalOcean Droplet at `https://hackerspace.sh`. Supabase (db/auth/rest/realtime/kong/meta/studio) runs in Docker with the Postgres data directory on a persistent block volume at `/mnt/data/postgres`. Caddy 2 terminates TLS with automatic Let's Encrypt for the apex, www, supabase, and studio hostnames. Studio is gated by HTTP basic auth (bcrypt). Daily `pg_dumpall` cron writes to `/mnt/data/backups` with 14-day retention. GitHub Actions auto-deploys every push to `main` over SSH via `/opt/hackerspace-ops/deploy.sh` which applies pending migrations idempotently (via `public._migrations_applied`), rebuilds, and restarts the systemd service. Resend SMTP is wired into GoTrue.
Open-source release scaffolding shipped: `LICENSE` (MIT), `CONTRIBUTING.md`, `SECURITY.md`, `CHANGELOG.md`, `docs/WEBHOOKS.md`, refreshed `README.md` and `docs/DEPLOYMENT.md`. All v0/Vercel references removed from app code; `@vercel/analytics` dependency dropped. GitHub icon link to the public repo in the landing nav + footer.
Audit fixes in the same commit:
- Webhook signing secret now has show/hide + copy in `/settings`; the broken external `docs.hackerspace.sh/webhooks` link replaced with `docs/WEBHOOKS.md`.
- `updateSpaceSettings` parameter type now includes `mission_statement` (was silently dropped).
- `/members` enforces `member_directory_visibility` for non-admin viewers.
- Mobile responsiveness pass: Projects Kanban, Members + Payments tables (column hiding on narrow), Proposal tally + voting buttons.
---
## 2026-05-14 (pass 9) — Datetime fix + configurable areas
Branch: `main`. Author: Claude (no commits made).
+89
View File
@@ -0,0 +1,89 @@
'use server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
import { requireMember, requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
import { createChannelSchema, uuidSchema } from '@/lib/validations'
export async function createChannel(input: {
name: string
description?: string | null
channel_type?: 'general' | 'area' | 'ops' | 'project'
}) {
const v = parseInput(createChannelSchema, input)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { data: { user } } = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
// Uniqueness check: channel names must be unique within a space.
const { data: existing } = await supabase
.from('comms_channels')
.select('id')
.eq('space_id', member.space_id)
.eq('name', v.data.name)
.maybeSingle()
if (existing) return { error: `A channel named "${v.data.name}" already exists.` }
const { data, error } = await supabase
.from('comms_channels')
.insert({
space_id: member.space_id,
name: v.data.name,
description: v.data.description ?? null,
channel_type: v.data.channel_type ?? 'general',
created_by: user.id,
is_default: false,
})
.select('id')
.single()
if (error) return { error: error.message }
revalidatePath('/comms')
return { id: data.id }
}
export async function deleteChannel(channelId: string) {
const v = parseInput(uuidSchema, channelId)
if (!v.ok) return { error: 'Invalid channel ID' }
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
// Default channels are protected at the RLS layer too.
const { error, count } = await supabase
.from('comms_channels')
.delete({ count: 'exact' })
.eq('id', v.data)
if (error) return { error: error.message }
if (count === 0) return { error: 'Cannot delete this channel' }
revalidatePath('/comms')
return { success: true as const }
}
export async function renameChannel(channelId: string, name: string, description?: string | null) {
const idCheck = parseInput(uuidSchema, channelId)
if (!idCheck.ok) return { error: 'Invalid channel ID' }
const v = parseInput(createChannelSchema, { name, description })
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { error } = await supabase
.from('comms_channels')
.update({ name: v.data.name, description: v.data.description ?? null })
.eq('id', idCheck.data)
if (error) return { error: error.message }
revalidatePath('/comms')
return { success: true as const }
}
+193
View File
@@ -0,0 +1,193 @@
'use server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
import { requireMember, requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
import {
createForumThreadSchema,
updateForumThreadSchema,
createCommentSchema,
updateCommentSchema,
uuidSchema,
} from '@/lib/validations'
export async function createForumThread(input: {
title: string
body?: string
category?: string
}) {
const v = parseInput(createForumThreadSchema, input)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { data, error } = await supabase
.from('forum_threads')
.insert({
space_id: member.space_id,
author_id: member.id,
title: v.data.title,
body: v.data.body ?? null,
category: v.data.category ?? 'general',
})
.select('id')
.single()
if (error) return { error: error.message }
revalidatePath('/forum')
return { id: data.id }
}
export async function updateForumThread(threadId: string, updates: {
title?: string
body?: string | null
category?: string
pinned?: boolean
locked?: boolean
}) {
const idCheck = parseInput(uuidSchema, threadId)
if (!idCheck.ok) return { error: 'Invalid thread ID' }
const v = parseInput(updateForumThreadSchema, updates)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
// Pinning/locking is moderator-only (admin/board). The RLS UPDATE policy
// additionally requires the actor to be the author or admin/board, so a
// non-author cannot edit a thread regardless of this guard.
const wantsMod = v.data.pinned !== undefined || v.data.locked !== undefined
if (wantsMod) {
const modAuth = await requireMemberWithRole(supabase, ['admin', 'board'], 'Admin or board access required')
if (!modAuth.ok) return { error: modAuth.error }
}
const patch: Record<string, unknown> = {}
if (v.data.title !== undefined) patch.title = v.data.title
if (v.data.body !== undefined) patch.body = v.data.body
if (v.data.category !== undefined) patch.category = v.data.category
if (v.data.pinned !== undefined) patch.pinned = v.data.pinned
if (v.data.locked !== undefined) patch.locked = v.data.locked
const { error } = await supabase.from('forum_threads').update(patch).eq('id', idCheck.data)
if (error) return { error: error.message }
revalidatePath('/forum')
revalidatePath(`/forum/${idCheck.data}`)
return { success: true as const }
}
export async function deleteForumThread(threadId: string) {
const v = parseInput(uuidSchema, threadId)
if (!v.ok) return { error: 'Invalid thread ID' }
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
// RLS DELETE allows the author or admin; non-authors get a row-not-found
// error which we translate to a clearer message.
const { error, count } = await supabase
.from('forum_threads')
.delete({ count: 'exact' })
.eq('id', v.data)
if (error) return { error: error.message }
if (count === 0) return { error: 'Not allowed to delete this thread' }
revalidatePath('/forum')
return { success: true as const }
}
export async function addComment(input: {
entity_type: 'forum_thread' | 'proposal' | 'incident' | 'policy'
entity_id: string
body: string
parent_id?: string | null
}) {
const v = parseInput(createCommentSchema, input)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
// Reject comments on locked forum threads up front.
if (v.data.entity_type === 'forum_thread') {
const { data: thread } = await supabase
.from('forum_threads')
.select('locked')
.eq('id', v.data.entity_id)
.single()
if (thread?.locked) return { error: 'Thread is locked' }
}
const { data, error } = await supabase
.from('comments')
.insert({
space_id: member.space_id,
entity_type: v.data.entity_type,
entity_id: v.data.entity_id,
author_id: member.id,
parent_id: v.data.parent_id ?? null,
body: v.data.body,
})
.select('id')
.single()
if (error) return { error: error.message }
if (v.data.entity_type === 'forum_thread') {
revalidatePath(`/forum/${v.data.entity_id}`)
} else if (v.data.entity_type === 'proposal') {
revalidatePath(`/proposals/${v.data.entity_id}`)
} else if (v.data.entity_type === 'incident') {
revalidatePath(`/incidents/${v.data.entity_id}`)
}
return { id: data.id }
}
export async function editComment(commentId: string, body: string) {
const idCheck = parseInput(uuidSchema, commentId)
if (!idCheck.ok) return { error: 'Invalid comment ID' }
const v = parseInput(updateCommentSchema, { body })
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { data, error } = await supabase
.from('comments')
.update({ body: v.data.body, edited_at: new Date().toISOString() })
.eq('id', idCheck.data)
.select('entity_type, entity_id')
.single()
if (error) return { error: error.message }
if (data?.entity_type === 'forum_thread') revalidatePath(`/forum/${data.entity_id}`)
return { success: true as const }
}
export async function deleteComment(commentId: string) {
const v = parseInput(uuidSchema, commentId)
if (!v.ok) return { error: 'Invalid comment ID' }
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { data, error } = await supabase
.from('comments')
.delete()
.eq('id', v.data)
.select('entity_type, entity_id')
.single()
if (error) return { error: error.message }
if (data?.entity_type === 'forum_thread') revalidatePath(`/forum/${data.entity_id}`)
return { success: true as const }
}
+11
View File
@@ -27,3 +27,14 @@ export * from './policies'
// Per-space configurable taxonomies
export * from './areas'
// Forum and polymorphic comments
export * from './forum'
// User-creatable chat channels
export * from './comms'
// Custom tiers, custom roles, multi-code invites
export * from './tiers'
export * from './roles'
export * from './invites'
+116
View File
@@ -0,0 +1,116 @@
'use server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
import { requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
import { createInviteSchema, updateInviteSchema, uuidSchema } from '@/lib/validations'
const INVITE_ADMIN_ROLES = ['admin', 'board'] as const
function generateInviteCode(length = 8): string {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
let out = ''
const bytes = new Uint8Array(length)
crypto.getRandomValues(bytes)
for (let i = 0; i < length; i++) out += alphabet[bytes[i] % alphabet.length]
return out
}
export async function createInvite(input: {
code?: string
label?: string | null
expires_at?: string | null
max_uses?: number | null
is_enabled?: boolean
}) {
const v = parseInput(createInviteSchema, input)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, INVITE_ADMIN_ROLES, 'Admin or board access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
// Generate a unique code if not provided. Try up to 5 times in case of collision.
let code = v.data.code
if (!code) {
for (let attempt = 0; attempt < 5; attempt++) {
code = generateInviteCode()
const { data: existing } = await supabase.from('space_invites').select('id').eq('code', code).maybeSingle()
if (!existing) break
code = undefined
}
if (!code) return { error: 'Could not generate a unique invite code; try again.' }
}
const { data, error } = await supabase
.from('space_invites')
.insert({
space_id: member.space_id,
code,
label: v.data.label ?? null,
expires_at: v.data.expires_at ?? null,
max_uses: v.data.max_uses ?? null,
is_enabled: v.data.is_enabled ?? true,
created_by: member.id,
})
.select('id, code')
.single()
if (error) return { error: error.message }
revalidatePath('/settings')
return { id: data.id, code: data.code }
}
export async function updateInvite(inviteId: string, updates: {
label?: string | null
expires_at?: string | null
max_uses?: number | null
is_enabled?: boolean
}) {
const idCheck = parseInput(uuidSchema, inviteId)
if (!idCheck.ok) return { error: 'Invalid invite ID' }
const v = parseInput(updateInviteSchema, updates)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, INVITE_ADMIN_ROLES, 'Admin or board access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const patch: Record<string, unknown> = {}
if (v.data.label !== undefined) patch.label = v.data.label
if (v.data.expires_at !== undefined) patch.expires_at = v.data.expires_at
if (v.data.max_uses !== undefined) patch.max_uses = v.data.max_uses
if (v.data.is_enabled !== undefined) patch.is_enabled = v.data.is_enabled
const { error } = await supabase
.from('space_invites')
.update(patch)
.eq('id', idCheck.data)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/settings')
return { success: true as const }
}
export async function deleteInvite(inviteId: string) {
const v = parseInput(uuidSchema, inviteId)
if (!v.ok) return { error: 'Invalid invite ID' }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ['admin'], 'Admin access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { error } = await supabase
.from('space_invites')
.delete()
.eq('id', v.data)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/settings')
return { success: true as const }
}
+170
View File
@@ -0,0 +1,170 @@
'use server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
import { requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
import {
upsertRoleLabelSchema,
createCustomRoleSchema,
updateCustomRoleSchema,
uuidSchema,
} from '@/lib/validations'
const ROLE_ADMIN_ROLES = ['admin', 'board'] as const
export async function upsertRoleLabel(input: {
role: 'admin' | 'board' | 'treasurer' | 'member' | 'associate'
display_name?: string | null
description?: string | null
color?: string | null
sort_order?: number
}) {
const v = parseInput(upsertRoleLabelSchema, input)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ROLE_ADMIN_ROLES, 'Admin or board access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { error } = await supabase
.from('space_role_labels')
.upsert({
space_id: member.space_id,
role: v.data.role,
display_name: v.data.display_name ?? null,
description: v.data.description ?? null,
color: v.data.color ?? null,
sort_order: v.data.sort_order ?? 0,
}, { onConflict: 'space_id,role' })
if (error) return { error: error.message }
revalidatePath('/settings')
return { success: true as const }
}
export async function createCustomRole(input: {
slug: string
name: string
description?: string | null
color?: string | null
sort_order?: number
}) {
const v = parseInput(createCustomRoleSchema, input)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ROLE_ADMIN_ROLES, 'Admin or board access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { data, error } = await supabase
.from('space_custom_roles')
.insert({
space_id: member.space_id,
slug: v.data.slug,
name: v.data.name,
description: v.data.description ?? null,
color: v.data.color ?? null,
sort_order: v.data.sort_order ?? 100,
})
.select('id')
.single()
if (error) return { error: error.message }
revalidatePath('/settings')
return { id: data.id }
}
export async function updateCustomRole(roleId: string, updates: {
name?: string
description?: string | null
color?: string | null
sort_order?: number
}) {
const idCheck = parseInput(uuidSchema, roleId)
if (!idCheck.ok) return { error: 'Invalid role ID' }
const v = parseInput(updateCustomRoleSchema, updates)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ROLE_ADMIN_ROLES, 'Admin or board access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const patch: Record<string, unknown> = {}
if (v.data.name !== undefined) patch.name = v.data.name
if (v.data.description !== undefined) patch.description = v.data.description
if (v.data.color !== undefined) patch.color = v.data.color
if (v.data.sort_order !== undefined) patch.sort_order = v.data.sort_order
const { error } = await supabase
.from('space_custom_roles')
.update(patch)
.eq('id', idCheck.data)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/settings')
return { success: true as const }
}
export async function deleteCustomRole(roleId: string) {
const v = parseInput(uuidSchema, roleId)
if (!v.ok) return { error: 'Invalid role ID' }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ['admin'], 'Admin access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { error } = await supabase
.from('space_custom_roles')
.delete()
.eq('id', v.data)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/settings')
return { success: true as const }
}
export async function assignCustomRole(memberId: string, customRoleId: string) {
const a = parseInput(uuidSchema, memberId)
const b = parseInput(uuidSchema, customRoleId)
if (!a.ok || !b.ok) return { error: 'Invalid ID' }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ROLE_ADMIN_ROLES, 'Admin or board access required')
if (!auth.ok) return { error: auth.error }
const { error } = await supabase
.from('space_member_custom_roles')
.insert({ member_id: a.data, custom_role_id: b.data })
if (error) return { error: error.message }
revalidatePath('/members')
revalidatePath('/settings')
return { success: true as const }
}
export async function unassignCustomRole(memberId: string, customRoleId: string) {
const a = parseInput(uuidSchema, memberId)
const b = parseInput(uuidSchema, customRoleId)
if (!a.ok || !b.ok) return { error: 'Invalid ID' }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ROLE_ADMIN_ROLES, 'Admin or board access required')
if (!auth.ok) return { error: auth.error }
const { error } = await supabase
.from('space_member_custom_roles')
.delete()
.eq('member_id', a.data)
.eq('custom_role_id', b.data)
if (error) return { error: error.message }
revalidatePath('/members')
revalidatePath('/settings')
return { success: true as const }
}
+120 -8
View File
@@ -4,7 +4,10 @@ import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
import { requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
import { ADMIN_ROLES } from '@/lib/permissions'
import { createSecretSchema, uuidSchema } from '@/lib/validations'
import { createSecretSchema, updateSecretSchema, uuidSchema } from '@/lib/validations'
import { encryptSecret, decryptSecret, encryptionAvailable } from '@/lib/secrets/crypto'
const SECRETS_RW_ROLES = ['admin', 'board'] as const
export async function createSecret(formData: {
title: string
@@ -21,18 +24,29 @@ export async function createSecret(formData: {
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { data, error } = await supabase
.from('secrets')
.insert({
const row: Record<string, unknown> = {
space_id: member.space_id,
title: v.data.title,
label: v.data.title,
value: v.data.value,
description: v.data.description ?? null,
area: v.data.area ?? null,
icon: v.data.icon ?? null,
})
.select()
}
if (encryptionAvailable()) {
const { ciphertext, version } = encryptSecret(v.data.value)
row.encrypted_value = ciphertext
row.encryption_version = version
row.value = ''
} else {
row.value = v.data.value
row.encryption_version = 0
}
const { data, error } = await supabase
.from('secrets')
.insert(row)
.select('id, title, label, area, description, icon, encryption_version, created_at, updated_at, space_id, created_by')
.single()
if (error) return { error: error.message }
@@ -40,12 +54,110 @@ export async function createSecret(formData: {
return { data }
}
export async function updateSecret(secretId: string, updates: {
title?: string
value?: string
description?: string | null
area?: string | null
icon?: string | null
}) {
const idCheck = parseInput(uuidSchema, secretId)
if (!idCheck.ok) return { error: 'Invalid secret ID' }
const v = parseInput(updateSecretSchema, updates)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, SECRETS_RW_ROLES, 'Board or admin access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const patch: Record<string, unknown> = {}
if (v.data.title !== undefined) {
patch.title = v.data.title
patch.label = v.data.title
}
if (v.data.description !== undefined) patch.description = v.data.description
if (v.data.area !== undefined) patch.area = v.data.area
if (v.data.icon !== undefined) patch.icon = v.data.icon
if (v.data.value !== undefined) {
if (encryptionAvailable()) {
const { ciphertext, version } = encryptSecret(v.data.value)
patch.encrypted_value = ciphertext
patch.encryption_version = version
patch.value = ''
} else {
patch.value = v.data.value
patch.encryption_version = 0
}
}
const { error } = await supabase
.from('secrets')
.update(patch)
.eq('id', idCheck.data)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/ops')
return { success: true as const }
}
// Reveal returns the plaintext for a single secret. The list endpoint must
// NEVER send the plaintext or ciphertext to the client; only this action does,
// and only when the caller is board/admin in the owning space.
export async function revealSecret(secretId: string) {
const idCheck = parseInput(uuidSchema, secretId)
if (!idCheck.ok) return { error: 'Invalid secret ID' }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, SECRETS_RW_ROLES, 'Board or admin access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { data: { user } } = await supabase.auth.getUser()
const { data, error } = await supabase
.from('secrets')
.select('id, encryption_version, encrypted_value, value, title')
.eq('id', idCheck.data)
.eq('space_id', member.space_id)
.single()
if (error || !data) return { error: error?.message ?? 'Not found' }
let plaintext: string
try {
if (data.encryption_version === 1 && data.encrypted_value) {
const raw = data.encrypted_value as unknown
const buf = typeof raw === 'string'
? Buffer.from((raw as string).replace(/^\\x/, ''), 'hex')
: Buffer.from(raw as Uint8Array)
plaintext = decryptSecret(buf, 1)
} else {
plaintext = (data.value ?? '') as string
}
} catch (e) {
return { error: e instanceof Error ? e.message : 'Decryption failed' }
}
if (user?.id) {
await supabase.from('activity_log').insert({
space_id: member.space_id,
user_id: user.id,
action: 'secret.revealed',
entity_type: 'secret',
entity_id: data.id,
details: `Revealed secret "${data.title}"`,
})
}
return { value: plaintext }
}
export async function deleteSecret(secretId: string) {
const v = parseInput(uuidSchema, secretId)
if (!v.ok) return { error: 'Invalid secret ID' }
const supabase = await createClient()
// Admin-only per RLS secrets_delete.
const auth = await requireMemberWithRole(supabase, ['admin'], 'Admin access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
+103
View File
@@ -0,0 +1,103 @@
'use server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
import { requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
import { createTierSchema, updateTierSchema, uuidSchema } from '@/lib/validations'
const TIER_RW_ROLES = ['admin', 'board', 'treasurer'] as const
export async function createTier(input: {
slug: string
name: string
description?: string | null
monthly_price_cents: number
billing_cadence?: 'monthly' | 'quarterly' | 'annual' | 'one_time' | 'custom'
sort_order?: number
}) {
const v = parseInput(createTierSchema, input)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, TIER_RW_ROLES, 'Treasurer, board, or admin access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { data, error } = await supabase
.from('space_tiers')
.insert({
space_id: member.space_id,
slug: v.data.slug,
name: v.data.name,
description: v.data.description ?? null,
monthly_price_cents: v.data.monthly_price_cents,
billing_cadence: v.data.billing_cadence ?? 'monthly',
sort_order: v.data.sort_order ?? 100,
is_system: false,
})
.select('id')
.single()
if (error) return { error: error.message }
revalidatePath('/settings')
return { id: data.id }
}
export async function updateTier(tierId: string, updates: {
name?: string
description?: string | null
monthly_price_cents?: number
billing_cadence?: 'monthly' | 'quarterly' | 'annual' | 'one_time' | 'custom'
sort_order?: number
is_archived?: boolean
}) {
const idCheck = parseInput(uuidSchema, tierId)
if (!idCheck.ok) return { error: 'Invalid tier ID' }
const v = parseInput(updateTierSchema, updates)
if (!v.ok) return { error: v.error }
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, TIER_RW_ROLES, 'Treasurer, board, or admin access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const patch: Record<string, unknown> = {}
if (v.data.name !== undefined) patch.name = v.data.name
if (v.data.description !== undefined) patch.description = v.data.description
if (v.data.monthly_price_cents !== undefined) patch.monthly_price_cents = v.data.monthly_price_cents
if (v.data.billing_cadence !== undefined) patch.billing_cadence = v.data.billing_cadence
if (v.data.sort_order !== undefined) patch.sort_order = v.data.sort_order
if (v.data.is_archived !== undefined) patch.is_archived = v.data.is_archived
const { error } = await supabase
.from('space_tiers')
.update(patch)
.eq('id', idCheck.data)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
revalidatePath('/settings')
return { success: true as const }
}
export async function deleteTier(tierId: string) {
const v = parseInput(uuidSchema, tierId)
if (!v.ok) return { error: 'Invalid tier ID' }
const supabase = await createClient()
// RLS blocks deletion of system tiers; admin-only.
const auth = await requireMemberWithRole(supabase, ['admin'], 'Admin access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { error, count } = await supabase
.from('space_tiers')
.delete({ count: 'exact' })
.eq('id', v.data)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
if (count === 0) return { error: 'Cannot delete a built-in tier. Archive it instead.' }
revalidatePath('/settings')
return { success: true as const }
}
+37 -1
View File
@@ -107,10 +107,38 @@ export async function joinSpace(formData: {
// Use admin client for operations
const admin = createAdminClient()
const code = formData.inviteCode.trim().toUpperCase()
// Try the new multi-code invites table first. Falls back to the legacy
// spaces.invite_code (a permanent default) if the code isn't found there.
let invite: { id: string; space_id: string; max_uses: number | null; uses_count: number; expires_at: string | null; is_enabled: boolean } | null = null
const { data: inviteRow } = await admin
.from('space_invites')
.select('id, space_id, max_uses, uses_count, expires_at, is_enabled')
.eq('code', code)
.maybeSingle()
if (inviteRow) invite = inviteRow as typeof invite
let spaceId: string | null = null
if (invite) {
if (!invite.is_enabled) return { error: 'This invite is disabled.' }
if (invite.expires_at && new Date(invite.expires_at) < new Date()) return { error: 'This invite has expired.' }
if (invite.max_uses !== null && invite.uses_count >= invite.max_uses) return { error: 'This invite has reached its use cap.' }
spaceId = invite.space_id
} else {
const { data: legacy } = await admin
.from('spaces')
.select('id')
.eq('invite_code', code)
.maybeSingle()
if (!legacy) return { error: 'Invalid invite code' }
spaceId = legacy.id
}
const { data: space, error: lookupErr } = await admin
.from('spaces')
.select('id, require_approval')
.eq('invite_code', formData.inviteCode.trim().toUpperCase())
.eq('id', spaceId)
.single()
if (lookupErr || !space) return { error: 'Invalid invite code' }
@@ -130,6 +158,14 @@ export async function joinSpace(formData: {
if (memberErr) return { error: memberErr.message }
// Increment invite usage counter for the multi-code path.
if (invite) {
await admin
.from('space_invites')
.update({ uses_count: invite.uses_count + 1 })
.eq('id', invite.id)
}
return { success: true }
}
+99
View File
@@ -160,6 +160,14 @@ export const createSecretSchema = z.object({
icon: z.string().max(10).optional().nullable(),
})
export const updateSecretSchema = z.object({
title: z.string().min(1).max(200).optional(),
value: z.string().min(1).max(5000).optional(),
description: z.string().max(500).optional().nullable(),
area: z.string().max(100).optional().nullable(),
icon: z.string().max(10).optional().nullable(),
})
// ─── Payments ────────────────────────────────────────────────────────────────
export const logCashPaymentSchema = z.object({
@@ -363,6 +371,97 @@ export const updateAreaSchema = z.object({
is_archived: z.boolean().optional(),
})
// ─── Forum, comments, tiers, roles, invites ──────────────────────────────────
export const createForumThreadSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
body: z.string().max(20000).optional().nullable(),
category: z.string().min(1).max(50).optional(),
})
export const updateForumThreadSchema = z.object({
title: z.string().min(1).max(200).optional(),
body: z.string().max(20000).optional().nullable(),
category: z.string().min(1).max(50).optional(),
pinned: z.boolean().optional(),
locked: z.boolean().optional(),
})
export const commentEntityTypeSchema = z.enum(['forum_thread', 'proposal', 'incident', 'policy'])
export const createCommentSchema = z.object({
entity_type: commentEntityTypeSchema,
entity_id: z.string().uuid('Invalid entity ID'),
body: z.string().min(1, 'Comment is required').max(10000),
parent_id: z.string().uuid().optional().nullable(),
})
export const updateCommentSchema = z.object({
body: z.string().min(1).max(10000),
})
export const createTierSchema = z.object({
slug: z.string().min(1).max(50).regex(/^[a-z0-9][a-z0-9_-]{0,49}$/, 'Slug must be lowercase letters, numbers, _ or -'),
name: z.string().min(1).max(100),
description: z.string().max(2000).optional().nullable(),
monthly_price_cents: z.number().int().min(0).max(100_000_000),
billing_cadence: z.enum(['monthly', 'quarterly', 'annual', 'one_time', 'custom']).default('monthly'),
sort_order: z.number().int().min(0).max(100000).optional(),
})
export const updateTierSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(2000).optional().nullable(),
monthly_price_cents: z.number().int().min(0).max(100_000_000).optional(),
billing_cadence: z.enum(['monthly', 'quarterly', 'annual', 'one_time', 'custom']).optional(),
sort_order: z.number().int().min(0).max(100000).optional(),
is_archived: z.boolean().optional(),
})
export const upsertRoleLabelSchema = z.object({
role: z.enum(['admin', 'board', 'treasurer', 'member', 'associate']),
display_name: z.string().max(100).optional().nullable(),
description: z.string().max(500).optional().nullable(),
color: z.string().max(20).optional().nullable(),
sort_order: z.number().int().min(0).max(100000).optional(),
})
export const createCustomRoleSchema = z.object({
slug: z.string().min(1).max(50).regex(/^[a-z0-9][a-z0-9_-]{0,49}$/, 'Slug must be lowercase letters, numbers, _ or -'),
name: z.string().min(1).max(100),
description: z.string().max(500).optional().nullable(),
color: z.string().max(20).optional().nullable(),
sort_order: z.number().int().min(0).max(100000).optional(),
})
export const updateCustomRoleSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional().nullable(),
color: z.string().max(20).optional().nullable(),
sort_order: z.number().int().min(0).max(100000).optional(),
})
export const createInviteSchema = z.object({
code: z.string().min(4).max(32).regex(/^[A-Z0-9-]+$/, 'Code must be uppercase letters, numbers, or hyphens').optional(),
label: z.string().max(100).optional().nullable(),
expires_at: flexibleDateTime(),
max_uses: z.number().int().min(1).max(100000).optional().nullable(),
is_enabled: z.boolean().optional(),
})
export const updateInviteSchema = z.object({
label: z.string().max(100).optional().nullable(),
expires_at: flexibleDateTime(),
max_uses: z.number().int().min(1).max(100000).optional().nullable(),
is_enabled: z.boolean().optional(),
})
export const createChannelSchema = z.object({
name: z.string().min(1).max(50).regex(/^[a-z0-9][a-z0-9-]{0,49}$/, 'Channel names must be lowercase letters, numbers, or hyphens'),
description: z.string().max(500).optional().nullable(),
channel_type: z.enum(['general', 'area', 'ops', 'project']).default('general'),
})
// ─── Generic ID schemas ──────────────────────────────────────────────────────
export const uuidSchema = z.string().uuid('Invalid ID format')
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="60 -5 280 350" role="img" aria-label="hackerspace.sh logo">
<circle cx="200" cy="185" r="115" stroke="currentColor" fill="none" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
<ellipse cx="200" cy="185" rx="45" ry="115" stroke="currentColor" fill="none" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<ellipse cx="200" cy="185" rx="85" ry="115" stroke="currentColor" fill="none" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="85" y1="185" x2="315" y2="185" stroke="currentColor" fill="none" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<ellipse cx="200" cy="133" rx="100" ry="28" stroke="currentColor" fill="none" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<ellipse cx="200" cy="237" rx="100" ry="28" stroke="currentColor" fill="none" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<g fill="currentColor" stroke="none">
<circle cx="85" cy="185" r="9"/><circle cx="85" cy="185" r="3.5" fill="#0c0c0c"/>
<circle cx="115" cy="185" r="7"/><circle cx="115" cy="185" r="2.5" fill="#0c0c0c"/>
<circle cx="155" cy="185" r="7"/><circle cx="155" cy="185" r="2.5" fill="#0c0c0c"/>
<circle cx="245" cy="185" r="7"/><circle cx="245" cy="185" r="2.5" fill="#0c0c0c"/>
<circle cx="285" cy="185" r="7"/><circle cx="285" cy="185" r="2.5" fill="#0c0c0c"/>
<circle cx="315" cy="185" r="9"/><circle cx="315" cy="185" r="3.5" fill="#0c0c0c"/>
<circle cx="200" cy="70" r="8"/><circle cx="200" cy="70" r="3" fill="#0c0c0c"/>
<circle cx="200" cy="300" r="8"/><circle cx="200" cy="300" r="3" fill="#0c0c0c"/>
</g>
<g>
<circle cx="145" cy="160" r="11" fill="#d4ff00"/>
<circle cx="145" cy="160" r="4.5" fill="#0c0c0c"/>
<circle cx="145" cy="160" r="16" stroke="#d4ff00" fill="none" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<g>
<circle cx="260" cy="205" r="10" fill="#d4ff00"/>
<circle cx="260" cy="205" r="4" fill="#0c0c0c"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,467 @@
-- =============================================================================
-- 021: Forum + polymorphic comments, custom tiers, custom role labels and
-- custom roles, multi-code invites, secrets encryption-at-rest.
-- =============================================================================
-- Adds five feature subsystems in one idempotent migration. Each section is
-- self-contained and re-runnable; the migration runner (deploy.sh) tracks
-- this file in public._migrations_applied so it only fires once per environment.
--
-- Sections:
-- 1. Comment entity-type enum + polymorphic comments + forum_threads
-- 2. Custom membership tiers with prices, billing cadence
-- 3. Role label customization + custom (non-privileged) org roles
-- 4. Multi-code invites with expiry and use caps
-- 5. Secrets: ciphertext + IV columns for AES-256-GCM at rest
-- 6. comms_channels INSERT policy: any member of the space can create one
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. Forum threads + polymorphic comments
-- -----------------------------------------------------------------------------
DO $$ BEGIN
CREATE TYPE public.comment_entity_type AS ENUM ('forum_thread','proposal','incident','policy');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE TABLE IF NOT EXISTS public.forum_threads (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
author_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
body text,
category text NOT NULL DEFAULT 'general',
pinned boolean NOT NULL DEFAULT false,
locked boolean NOT NULL DEFAULT false,
comment_count integer NOT NULL DEFAULT 0,
last_comment_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_forum_threads_space
ON public.forum_threads (space_id, last_comment_at DESC NULLS LAST);
CREATE INDEX IF NOT EXISTS idx_forum_threads_space_pinned
ON public.forum_threads (space_id, pinned DESC, last_comment_at DESC NULLS LAST);
CREATE TABLE IF NOT EXISTS public.comments (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
entity_type public.comment_entity_type NOT NULL,
entity_id uuid NOT NULL,
author_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
parent_id uuid REFERENCES public.comments(id) ON DELETE CASCADE,
body text NOT NULL CHECK (char_length(body) BETWEEN 1 AND 10000),
edited_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_comments_entity ON public.comments (entity_type, entity_id, created_at);
CREATE INDEX IF NOT EXISTS idx_comments_space ON public.comments (space_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_comments_author ON public.comments (author_id);
-- Maintain forum_threads counters on comment insert/delete.
CREATE OR REPLACE FUNCTION public.touch_thread_on_comment()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
IF (TG_OP = 'INSERT' AND NEW.entity_type = 'forum_thread') THEN
UPDATE public.forum_threads
SET comment_count = comment_count + 1,
last_comment_at = NEW.created_at,
updated_at = now()
WHERE id = NEW.entity_id;
ELSIF (TG_OP = 'DELETE' AND OLD.entity_type = 'forum_thread') THEN
UPDATE public.forum_threads
SET comment_count = GREATEST(comment_count - 1, 0),
updated_at = now()
WHERE id = OLD.entity_id;
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$;
DROP TRIGGER IF EXISTS trg_touch_thread_on_comment ON public.comments;
CREATE TRIGGER trg_touch_thread_on_comment
AFTER INSERT OR DELETE ON public.comments
FOR EACH ROW EXECUTE FUNCTION public.touch_thread_on_comment();
ALTER TABLE public.forum_threads ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.comments ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS forum_threads_select ON public.forum_threads;
DROP POLICY IF EXISTS forum_threads_insert ON public.forum_threads;
DROP POLICY IF EXISTS forum_threads_update ON public.forum_threads;
DROP POLICY IF EXISTS forum_threads_delete ON public.forum_threads;
CREATE POLICY forum_threads_select ON public.forum_threads FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY forum_threads_insert ON public.forum_threads FOR INSERT
WITH CHECK (
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
AND (
author_id IS NULL
OR author_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid() AND space_id = forum_threads.space_id)
)
);
CREATE POLICY forum_threads_update ON public.forum_threads FOR UPDATE
USING (
(author_id IS NOT NULL AND author_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid()))
OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
);
CREATE POLICY forum_threads_delete ON public.forum_threads FOR DELETE
USING (
(author_id IS NOT NULL AND author_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid()))
OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin'])
);
DROP POLICY IF EXISTS comments_select ON public.comments;
DROP POLICY IF EXISTS comments_insert ON public.comments;
DROP POLICY IF EXISTS comments_update ON public.comments;
DROP POLICY IF EXISTS comments_delete ON public.comments;
CREATE POLICY comments_select ON public.comments FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY comments_insert ON public.comments FOR INSERT
WITH CHECK (
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
AND (
author_id IS NULL
OR author_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid() AND space_id = comments.space_id)
)
);
CREATE POLICY comments_update ON public.comments FOR UPDATE
USING (
author_id IS NOT NULL AND author_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid())
);
CREATE POLICY comments_delete ON public.comments FOR DELETE
USING (
(author_id IS NOT NULL AND author_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid()))
OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
);
-- -----------------------------------------------------------------------------
-- 2. Custom membership tiers (per-space, with price and billing cadence)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.space_tiers (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
slug text NOT NULL CHECK (char_length(slug) BETWEEN 1 AND 50),
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 100),
description text,
monthly_price_cents integer NOT NULL DEFAULT 0 CHECK (monthly_price_cents >= 0),
billing_cadence text NOT NULL DEFAULT 'monthly' CHECK (billing_cadence IN ('monthly','quarterly','annual','one_time','custom')),
is_system boolean NOT NULL DEFAULT false,
is_archived boolean NOT NULL DEFAULT false,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, slug)
);
CREATE INDEX IF NOT EXISTS idx_space_tiers_space ON public.space_tiers (space_id, sort_order, name);
ALTER TABLE public.space_members
ADD COLUMN IF NOT EXISTS tier_id uuid REFERENCES public.space_tiers(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_space_members_tier_id ON public.space_members (tier_id);
-- Seed default tiers on space creation (matches the legacy enum values).
CREATE OR REPLACE FUNCTION public.seed_default_tiers()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO public.space_tiers (space_id, slug, name, description, monthly_price_cents, billing_cadence, is_system, sort_order)
VALUES
(NEW.id, 'plus', 'Plus', 'Full access including 24/7 keycard', 0, 'monthly', true, 0),
(NEW.id, 'basic', 'Basic', 'Standard member access during open hours', 0, 'monthly', true, 1),
(NEW.id, 'associate', 'Associate', 'Limited access for adjacent community members', 0, 'monthly', true, 2)
ON CONFLICT (space_id, slug) DO NOTHING;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_seed_default_tiers ON public.spaces;
CREATE TRIGGER trg_seed_default_tiers
AFTER INSERT ON public.spaces
FOR EACH ROW EXECUTE FUNCTION public.seed_default_tiers();
-- Backfill default tiers for spaces that already exist.
INSERT INTO public.space_tiers (space_id, slug, name, description, monthly_price_cents, billing_cadence, is_system, sort_order)
SELECT s.id, t.slug, t.name, t.description, 0, 'monthly', true, t.sort_order
FROM public.spaces s
CROSS JOIN (VALUES
('plus', 'Plus', 'Full access including 24/7 keycard', 0),
('basic', 'Basic', 'Standard member access during open hours', 1),
('associate', 'Associate', 'Limited access for adjacent community members', 2)
) AS t(slug, name, description, sort_order)
ON CONFLICT (space_id, slug) DO NOTHING;
-- Backfill existing members' tier_id from the legacy enum value.
UPDATE public.space_members sm
SET tier_id = t.id
FROM public.space_tiers t
WHERE t.space_id = sm.space_id
AND t.slug = sm.tier::text
AND sm.tier_id IS NULL;
ALTER TABLE public.space_tiers ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tiers_select ON public.space_tiers;
DROP POLICY IF EXISTS tiers_insert ON public.space_tiers;
DROP POLICY IF EXISTS tiers_update ON public.space_tiers;
DROP POLICY IF EXISTS tiers_delete ON public.space_tiers;
CREATE POLICY tiers_select ON public.space_tiers FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY tiers_insert ON public.space_tiers FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']));
CREATE POLICY tiers_update ON public.space_tiers FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']));
-- Deleting a system tier is blocked; deleting a custom tier requires admin.
CREATE POLICY tiers_delete ON public.space_tiers FOR DELETE
USING (
public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin'])
AND NOT is_system
);
-- -----------------------------------------------------------------------------
-- 3. Role label customization + custom org roles
-- The built-in member_role enum still drives RLS. space_role_labels lets a
-- space re-label/recolor the built-ins (e.g. rename "treasurer" to "money
-- keeper"). space_custom_roles lets a space add additional non-privileged
-- labels for org structure (committees, area leads by name, etc).
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.space_role_labels (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
role public.member_role NOT NULL,
display_name text,
description text,
color text,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, role)
);
CREATE TABLE IF NOT EXISTS public.space_custom_roles (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
slug text NOT NULL CHECK (slug ~* '^[a-z0-9][a-z0-9_-]{0,49}$'),
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 100),
description text,
color text,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, slug)
);
CREATE TABLE IF NOT EXISTS public.space_member_custom_roles (
member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE,
custom_role_id uuid NOT NULL REFERENCES public.space_custom_roles(id) ON DELETE CASCADE,
assigned_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (member_id, custom_role_id)
);
CREATE INDEX IF NOT EXISTS idx_space_role_labels_space ON public.space_role_labels (space_id);
CREATE INDEX IF NOT EXISTS idx_space_custom_roles_space ON public.space_custom_roles (space_id, sort_order);
CREATE INDEX IF NOT EXISTS idx_member_custom_roles_role ON public.space_member_custom_roles (custom_role_id);
ALTER TABLE public.space_role_labels ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.space_custom_roles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.space_member_custom_roles ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS role_labels_select ON public.space_role_labels;
DROP POLICY IF EXISTS role_labels_insert ON public.space_role_labels;
DROP POLICY IF EXISTS role_labels_update ON public.space_role_labels;
DROP POLICY IF EXISTS role_labels_delete ON public.space_role_labels;
CREATE POLICY role_labels_select ON public.space_role_labels FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY role_labels_insert ON public.space_role_labels FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY role_labels_update ON public.space_role_labels FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY role_labels_delete ON public.space_role_labels FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
DROP POLICY IF EXISTS custom_roles_select ON public.space_custom_roles;
DROP POLICY IF EXISTS custom_roles_insert ON public.space_custom_roles;
DROP POLICY IF EXISTS custom_roles_update ON public.space_custom_roles;
DROP POLICY IF EXISTS custom_roles_delete ON public.space_custom_roles;
CREATE POLICY custom_roles_select ON public.space_custom_roles FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY custom_roles_insert ON public.space_custom_roles FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY custom_roles_update ON public.space_custom_roles FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY custom_roles_delete ON public.space_custom_roles FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
DROP POLICY IF EXISTS member_custom_roles_select ON public.space_member_custom_roles;
DROP POLICY IF EXISTS member_custom_roles_insert ON public.space_member_custom_roles;
DROP POLICY IF EXISTS member_custom_roles_delete ON public.space_member_custom_roles;
CREATE POLICY member_custom_roles_select ON public.space_member_custom_roles FOR SELECT
USING (
member_id IN (
SELECT id FROM public.space_members
WHERE space_id IN (SELECT public.get_user_space_ids(auth.uid()))
)
);
CREATE POLICY member_custom_roles_insert ON public.space_member_custom_roles FOR INSERT
WITH CHECK (
member_id IN (
SELECT sm.id FROM public.space_members sm
WHERE public.user_has_role_in_space(auth.uid(), sm.space_id, ARRAY['admin','board'])
)
);
CREATE POLICY member_custom_roles_delete ON public.space_member_custom_roles FOR DELETE
USING (
member_id IN (
SELECT sm.id FROM public.space_members sm
WHERE public.user_has_role_in_space(auth.uid(), sm.space_id, ARRAY['admin','board'])
)
);
-- -----------------------------------------------------------------------------
-- 4. Multi-code invites
-- The legacy `spaces.invite_code` is kept as a permanent fallback. This
-- table lets each space hold any number of additional invite codes with
-- independent expiry, use caps, and enable/disable.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.space_invites (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
code text NOT NULL UNIQUE CHECK (char_length(code) BETWEEN 4 AND 32),
label text,
expires_at timestamptz,
max_uses integer CHECK (max_uses IS NULL OR max_uses > 0),
uses_count integer NOT NULL DEFAULT 0 CHECK (uses_count >= 0),
is_enabled boolean NOT NULL DEFAULT true,
created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_space_invites_space ON public.space_invites (space_id, is_enabled, expires_at);
CREATE INDEX IF NOT EXISTS idx_space_invites_code ON public.space_invites (code) WHERE is_enabled;
-- Backfill: every existing space's legacy invite_code becomes a permanent,
-- enabled, label="Default" invite in space_invites.
INSERT INTO public.space_invites (space_id, code, label, is_enabled, max_uses, expires_at)
SELECT s.id, upper(s.invite_code), 'Default', true, NULL, NULL
FROM public.spaces s
WHERE s.invite_code IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM public.space_invites si WHERE si.code = upper(s.invite_code)
);
ALTER TABLE public.space_invites ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS invites_select ON public.space_invites;
DROP POLICY IF EXISTS invites_insert ON public.space_invites;
DROP POLICY IF EXISTS invites_update ON public.space_invites;
DROP POLICY IF EXISTS invites_delete ON public.space_invites;
CREATE POLICY invites_select ON public.space_invites FOR SELECT
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']));
CREATE POLICY invites_insert ON public.space_invites FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY invites_update ON public.space_invites FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY invites_delete ON public.space_invites FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
-- -----------------------------------------------------------------------------
-- 5. Secrets: encryption-at-rest columns
-- The application encrypts new values with AES-256-GCM (key from the
-- SECRETS_ENCRYPTION_KEY env var) and stores ciphertext in this column.
-- The legacy `value` column is left intact for backward compatibility on
-- existing rows; the app prefers `encrypted_value` when present and the
-- list endpoint omits both columns from SELECT until an explicit reveal.
-- -----------------------------------------------------------------------------
ALTER TABLE public.secrets ADD COLUMN IF NOT EXISTS encrypted_value bytea;
ALTER TABLE public.secrets ADD COLUMN IF NOT EXISTS encryption_version smallint NOT NULL DEFAULT 0;
-- encryption_version = 0 means: no encrypted_value; fall back to `value`.
-- encryption_version = 1 means: AES-256-GCM, IV || ciphertext || authTag packed in encrypted_value.
-- Tighten secrets RLS update/delete to admin/board only (already in baseline,
-- redeclare here for completeness).
DROP POLICY IF EXISTS secrets_select ON public.secrets;
DROP POLICY IF EXISTS secrets_insert ON public.secrets;
DROP POLICY IF EXISTS secrets_update ON public.secrets;
DROP POLICY IF EXISTS secrets_delete ON public.secrets;
CREATE POLICY secrets_select ON public.secrets FOR SELECT
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY secrets_insert ON public.secrets FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY secrets_update ON public.secrets FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY secrets_delete ON public.secrets FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
-- -----------------------------------------------------------------------------
-- 6. comms_channels: any member of the space can create a channel.
-- Update/delete still belong to admin/board, except for the creator who can
-- rename or delete their own non-default channel.
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS channels_insert ON public.comms_channels;
CREATE POLICY channels_insert ON public.comms_channels FOR INSERT
WITH CHECK (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
DROP POLICY IF EXISTS channels_update ON public.comms_channels;
CREATE POLICY channels_update ON public.comms_channels FOR UPDATE
USING (
public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
OR created_by = auth.uid()
);
DROP POLICY IF EXISTS channels_delete ON public.comms_channels;
CREATE POLICY channels_delete ON public.comms_channels FOR DELETE
USING (
NOT is_default
AND (
public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
OR created_by = auth.uid()
)
);
-- -----------------------------------------------------------------------------
-- 7. knowledge_base: opt-in markdown rendering flag (default on for new rows).
-- -----------------------------------------------------------------------------
ALTER TABLE public.knowledge_base
ADD COLUMN IF NOT EXISTS render_markdown boolean NOT NULL DEFAULT true;
-- -----------------------------------------------------------------------------
-- 8. Touch updated_at on the new tables.
-- -----------------------------------------------------------------------------
DROP TRIGGER IF EXISTS trg_forum_threads_touch ON public.forum_threads;
DROP TRIGGER IF EXISTS trg_space_tiers_touch ON public.space_tiers;
DROP TRIGGER IF EXISTS trg_space_role_labels_touch ON public.space_role_labels;
DROP TRIGGER IF EXISTS trg_space_custom_roles_touch ON public.space_custom_roles;
DROP TRIGGER IF EXISTS trg_space_invites_touch ON public.space_invites;
CREATE TRIGGER trg_forum_threads_touch
BEFORE UPDATE ON public.forum_threads
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
CREATE TRIGGER trg_space_tiers_touch
BEFORE UPDATE ON public.space_tiers
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
CREATE TRIGGER trg_space_role_labels_touch
BEFORE UPDATE ON public.space_role_labels
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
CREATE TRIGGER trg_space_custom_roles_touch
BEFORE UPDATE ON public.space_custom_roles
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
CREATE TRIGGER trg_space_invites_touch
BEFORE UPDATE ON public.space_invites
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
+277
View File
@@ -1340,3 +1340,280 @@ DROP TRIGGER IF EXISTS trg_seed_default_areas ON public.spaces;
CREATE TRIGGER trg_seed_default_areas
AFTER INSERT ON public.spaces
FOR EACH ROW EXECUTE FUNCTION public.seed_default_areas();
-- =============================================================================
-- 13. Forum, polymorphic comments, custom tiers, custom role labels and
-- custom roles, multi-code invites, secrets encryption-at-rest, KB
-- markdown rendering flag.
-- Equivalent to scripts/021_forum_tiers_roles_invites_secrets.sql.
-- =============================================================================
DO $$ BEGIN
CREATE TYPE public.comment_entity_type AS ENUM ('forum_thread','proposal','incident','policy');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE TABLE IF NOT EXISTS public.forum_threads (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
author_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
body text,
category text NOT NULL DEFAULT 'general',
pinned boolean NOT NULL DEFAULT false,
locked boolean NOT NULL DEFAULT false,
comment_count integer NOT NULL DEFAULT 0,
last_comment_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_forum_threads_space ON public.forum_threads (space_id, last_comment_at DESC NULLS LAST);
CREATE INDEX IF NOT EXISTS idx_forum_threads_space_pinned ON public.forum_threads (space_id, pinned DESC, last_comment_at DESC NULLS LAST);
CREATE TABLE IF NOT EXISTS public.comments (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
entity_type public.comment_entity_type NOT NULL,
entity_id uuid NOT NULL,
author_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
parent_id uuid REFERENCES public.comments(id) ON DELETE CASCADE,
body text NOT NULL CHECK (char_length(body) BETWEEN 1 AND 10000),
edited_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_comments_entity ON public.comments (entity_type, entity_id, created_at);
CREATE INDEX IF NOT EXISTS idx_comments_space ON public.comments (space_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_comments_author ON public.comments (author_id);
CREATE OR REPLACE FUNCTION public.touch_thread_on_comment()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
IF (TG_OP = 'INSERT' AND NEW.entity_type = 'forum_thread') THEN
UPDATE public.forum_threads SET comment_count = comment_count + 1, last_comment_at = NEW.created_at, updated_at = now() WHERE id = NEW.entity_id;
ELSIF (TG_OP = 'DELETE' AND OLD.entity_type = 'forum_thread') THEN
UPDATE public.forum_threads SET comment_count = GREATEST(comment_count - 1, 0), updated_at = now() WHERE id = OLD.entity_id;
END IF;
RETURN COALESCE(NEW, OLD);
END;
$$;
DROP TRIGGER IF EXISTS trg_touch_thread_on_comment ON public.comments;
CREATE TRIGGER trg_touch_thread_on_comment
AFTER INSERT OR DELETE ON public.comments
FOR EACH ROW EXECUTE FUNCTION public.touch_thread_on_comment();
ALTER TABLE public.forum_threads ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.comments ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS forum_threads_select ON public.forum_threads;
DROP POLICY IF EXISTS forum_threads_insert ON public.forum_threads;
DROP POLICY IF EXISTS forum_threads_update ON public.forum_threads;
DROP POLICY IF EXISTS forum_threads_delete ON public.forum_threads;
CREATE POLICY forum_threads_select ON public.forum_threads FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY forum_threads_insert ON public.forum_threads FOR INSERT
WITH CHECK (
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
AND (author_id IS NULL OR author_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid() AND space_id = forum_threads.space_id))
);
CREATE POLICY forum_threads_update ON public.forum_threads FOR UPDATE
USING (
(author_id IS NOT NULL AND author_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid()))
OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
);
CREATE POLICY forum_threads_delete ON public.forum_threads FOR DELETE
USING (
(author_id IS NOT NULL AND author_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid()))
OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin'])
);
DROP POLICY IF EXISTS comments_select ON public.comments;
DROP POLICY IF EXISTS comments_insert ON public.comments;
DROP POLICY IF EXISTS comments_update ON public.comments;
DROP POLICY IF EXISTS comments_delete ON public.comments;
CREATE POLICY comments_select ON public.comments FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY comments_insert ON public.comments FOR INSERT
WITH CHECK (
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
AND (author_id IS NULL OR author_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid() AND space_id = comments.space_id))
);
CREATE POLICY comments_update ON public.comments FOR UPDATE
USING (author_id IS NOT NULL AND author_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid()));
CREATE POLICY comments_delete ON public.comments FOR DELETE
USING (
(author_id IS NOT NULL AND author_id IN (SELECT id FROM public.space_members WHERE user_id = auth.uid()))
OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
);
CREATE TABLE IF NOT EXISTS public.space_tiers (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
slug text NOT NULL CHECK (char_length(slug) BETWEEN 1 AND 50),
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 100),
description text,
monthly_price_cents integer NOT NULL DEFAULT 0 CHECK (monthly_price_cents >= 0),
billing_cadence text NOT NULL DEFAULT 'monthly' CHECK (billing_cadence IN ('monthly','quarterly','annual','one_time','custom')),
is_system boolean NOT NULL DEFAULT false,
is_archived boolean NOT NULL DEFAULT false,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, slug)
);
CREATE INDEX IF NOT EXISTS idx_space_tiers_space ON public.space_tiers (space_id, sort_order, name);
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS tier_id uuid REFERENCES public.space_tiers(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_space_members_tier_id ON public.space_members (tier_id);
CREATE OR REPLACE FUNCTION public.seed_default_tiers()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO public.space_tiers (space_id, slug, name, description, monthly_price_cents, billing_cadence, is_system, sort_order) VALUES
(NEW.id, 'plus', 'Plus', 'Full access including 24/7 keycard', 0, 'monthly', true, 0),
(NEW.id, 'basic', 'Basic', 'Standard member access during open hours', 0, 'monthly', true, 1),
(NEW.id, 'associate', 'Associate', 'Limited access for adjacent community members', 0, 'monthly', true, 2)
ON CONFLICT (space_id, slug) DO NOTHING;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_seed_default_tiers ON public.spaces;
CREATE TRIGGER trg_seed_default_tiers
AFTER INSERT ON public.spaces
FOR EACH ROW EXECUTE FUNCTION public.seed_default_tiers();
ALTER TABLE public.space_tiers ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS tiers_select ON public.space_tiers;
DROP POLICY IF EXISTS tiers_insert ON public.space_tiers;
DROP POLICY IF EXISTS tiers_update ON public.space_tiers;
DROP POLICY IF EXISTS tiers_delete ON public.space_tiers;
CREATE POLICY tiers_select ON public.space_tiers FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY tiers_insert ON public.space_tiers FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']));
CREATE POLICY tiers_update ON public.space_tiers FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']));
CREATE POLICY tiers_delete ON public.space_tiers FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']) AND NOT is_system);
CREATE TABLE IF NOT EXISTS public.space_role_labels (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
role public.member_role NOT NULL,
display_name text,
description text,
color text,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, role)
);
CREATE TABLE IF NOT EXISTS public.space_custom_roles (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
slug text NOT NULL CHECK (slug ~* '^[a-z0-9][a-z0-9_-]{0,49}$'),
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 100),
description text,
color text,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, slug)
);
CREATE TABLE IF NOT EXISTS public.space_member_custom_roles (
member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE,
custom_role_id uuid NOT NULL REFERENCES public.space_custom_roles(id) ON DELETE CASCADE,
assigned_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (member_id, custom_role_id)
);
CREATE INDEX IF NOT EXISTS idx_space_role_labels_space ON public.space_role_labels (space_id);
CREATE INDEX IF NOT EXISTS idx_space_custom_roles_space ON public.space_custom_roles (space_id, sort_order);
CREATE INDEX IF NOT EXISTS idx_member_custom_roles_role ON public.space_member_custom_roles (custom_role_id);
ALTER TABLE public.space_role_labels ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.space_custom_roles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.space_member_custom_roles ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS role_labels_select ON public.space_role_labels;
DROP POLICY IF EXISTS role_labels_insert ON public.space_role_labels;
DROP POLICY IF EXISTS role_labels_update ON public.space_role_labels;
DROP POLICY IF EXISTS role_labels_delete ON public.space_role_labels;
CREATE POLICY role_labels_select ON public.space_role_labels FOR SELECT USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY role_labels_insert ON public.space_role_labels FOR INSERT WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY role_labels_update ON public.space_role_labels FOR UPDATE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY role_labels_delete ON public.space_role_labels FOR DELETE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
DROP POLICY IF EXISTS custom_roles_select ON public.space_custom_roles;
DROP POLICY IF EXISTS custom_roles_insert ON public.space_custom_roles;
DROP POLICY IF EXISTS custom_roles_update ON public.space_custom_roles;
DROP POLICY IF EXISTS custom_roles_delete ON public.space_custom_roles;
CREATE POLICY custom_roles_select ON public.space_custom_roles FOR SELECT USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY custom_roles_insert ON public.space_custom_roles FOR INSERT WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY custom_roles_update ON public.space_custom_roles FOR UPDATE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY custom_roles_delete ON public.space_custom_roles FOR DELETE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
DROP POLICY IF EXISTS member_custom_roles_select ON public.space_member_custom_roles;
DROP POLICY IF EXISTS member_custom_roles_insert ON public.space_member_custom_roles;
DROP POLICY IF EXISTS member_custom_roles_delete ON public.space_member_custom_roles;
CREATE POLICY member_custom_roles_select ON public.space_member_custom_roles FOR SELECT
USING (member_id IN (SELECT id FROM public.space_members WHERE space_id IN (SELECT public.get_user_space_ids(auth.uid()))));
CREATE POLICY member_custom_roles_insert ON public.space_member_custom_roles FOR INSERT
WITH CHECK (member_id IN (SELECT sm.id FROM public.space_members sm WHERE public.user_has_role_in_space(auth.uid(), sm.space_id, ARRAY['admin','board'])));
CREATE POLICY member_custom_roles_delete ON public.space_member_custom_roles FOR DELETE
USING (member_id IN (SELECT sm.id FROM public.space_members sm WHERE public.user_has_role_in_space(auth.uid(), sm.space_id, ARRAY['admin','board'])));
CREATE TABLE IF NOT EXISTS public.space_invites (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
code text NOT NULL UNIQUE CHECK (char_length(code) BETWEEN 4 AND 32),
label text,
expires_at timestamptz,
max_uses integer CHECK (max_uses IS NULL OR max_uses > 0),
uses_count integer NOT NULL DEFAULT 0 CHECK (uses_count >= 0),
is_enabled boolean NOT NULL DEFAULT true,
created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_space_invites_space ON public.space_invites (space_id, is_enabled, expires_at);
CREATE INDEX IF NOT EXISTS idx_space_invites_code ON public.space_invites (code) WHERE is_enabled;
ALTER TABLE public.space_invites ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS invites_select ON public.space_invites;
DROP POLICY IF EXISTS invites_insert ON public.space_invites;
DROP POLICY IF EXISTS invites_update ON public.space_invites;
DROP POLICY IF EXISTS invites_delete ON public.space_invites;
CREATE POLICY invites_select ON public.space_invites FOR SELECT USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']));
CREATE POLICY invites_insert ON public.space_invites FOR INSERT WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY invites_update ON public.space_invites FOR UPDATE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY invites_delete ON public.space_invites FOR DELETE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
ALTER TABLE public.secrets ADD COLUMN IF NOT EXISTS encrypted_value bytea;
ALTER TABLE public.secrets ADD COLUMN IF NOT EXISTS encryption_version smallint NOT NULL DEFAULT 0;
ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS render_markdown boolean NOT NULL DEFAULT true;
-- comms_channels: any member can create one; non-default channels can be
-- updated/deleted by the creator or admin/board.
DROP POLICY IF EXISTS channels_insert ON public.comms_channels;
CREATE POLICY channels_insert ON public.comms_channels FOR INSERT
WITH CHECK (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
DROP POLICY IF EXISTS channels_update ON public.comms_channels;
CREATE POLICY channels_update ON public.comms_channels FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']) OR created_by = auth.uid());
DROP POLICY IF EXISTS channels_delete ON public.comms_channels;
CREATE POLICY channels_delete ON public.comms_channels FOR DELETE
USING (NOT is_default AND (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']) OR created_by = auth.uid()));
DROP TRIGGER IF EXISTS trg_forum_threads_touch ON public.forum_threads;
DROP TRIGGER IF EXISTS trg_space_tiers_touch ON public.space_tiers;
DROP TRIGGER IF EXISTS trg_space_role_labels_touch ON public.space_role_labels;
DROP TRIGGER IF EXISTS trg_space_custom_roles_touch ON public.space_custom_roles;
DROP TRIGGER IF EXISTS trg_space_invites_touch ON public.space_invites;
CREATE TRIGGER trg_forum_threads_touch BEFORE UPDATE ON public.forum_threads FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
CREATE TRIGGER trg_space_tiers_touch BEFORE UPDATE ON public.space_tiers FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
CREATE TRIGGER trg_space_role_labels_touch BEFORE UPDATE ON public.space_role_labels FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
CREATE TRIGGER trg_space_custom_roles_touch BEFORE UPDATE ON public.space_custom_roles FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
CREATE TRIGGER trg_space_invites_touch BEFORE UPDATE ON public.space_invites FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();