In-app notification inbox, consolidate /profile into /me, theme realign

In-app notification center (Phase 5, migration 051):
- notifications.read_at + partial unread index. /me Notifications is now
  an inbox: unread styling, click to expand the message + mark read,
  "mark all as read", unread count. markNotificationsRead is a service-
  client write scoped to the caller's own member_id. Always-on channel:
  email-muted ('skipped') notifications still appear in-app.

Consolidate /profile into /me:
- Delete app/(app)/profile/*; /me is the single member page. Port the
  conflict-of-interest privileged-role nudge + last-disclosed date into
  /me's profile editor. Remove the "My profile" sidebar + command-palette
  entries; rename the /me nav label to "My membership". Repoint the
  recruitment empty-state link to /me.

Theme realign:
- Revert the rounded-lg cards introduced in the prior /me visual pass to
  rounded (the app norm, matching the former /profile). Route /me section
  headers through the SectionTitle primitive.

Regression guard:
- integration/auth-embed.test.ts runs the auth layout's
  space_members.select('*, spaces(*)') embed through PostgREST and fails
  on PGRST201, so a future junction table cannot silently break auth again.
This commit is contained in:
Moheeb Zara
2026-05-19 22:51:44 -07:00
parent a7d880d747
commit 5d153b4d68
20 changed files with 319 additions and 380 deletions
+2
View File
@@ -415,6 +415,8 @@ Default channels created by trigger on space INSERT: `general`, `announcements`,
| 047 | `members_with_permission(sid, perm)` SECURITY DEFINER, set-returning inverse of `user_has_permission` (same current/late gate, same admin shortcut + `space_role_permissions`/`user_effective_roles` fallback). Lets Phase 4 fan-outs (e.g. `form_submission_admin`) resolve permission holders in one query. No new table/column/policy |
| 048 | `notification_preferences` (Phase 5): per-member opt-out of muteable categories (`bookings, classes, forms, admin_alerts`); `billing` never muteable/stored. Cols `space_id, member_id, category, enabled`; PK `(space_id, member_id, category)`. Opt-out: no row = enabled. RLS on, NO client policy (default-deny); dispatcher reads via service client and marks muted rows `skipped`; `/me` view + toggle via validated service-client actions. `touch_updated_at` trigger |
| 049 | `dues_payment_methods`: admin-configured external dues links. Cols `id, space_id, platform (payment_platform), url, instructions, is_active, sort_order`; `UNIQUE (space_id, platform)`. One pay-here URL per platform (PayPal/Zeffy/Venmo); members click out, treasurer reconciles manually (platform pre-typed). RLS: SELECT any space member, write admin/board. `url` validated https-only at the action boundary. `touch_updated_at` trigger |
| 051 | `notifications.read_at timestamptz` + partial idx `idx_notifications_unread (space_id, member_id) WHERE read_at IS NULL`. In-app inbox read state on `/me`; unread = `read_at IS NULL`. Mark-read = service-client action scoped to caller's member_id (no RLS change). Email-muted (`skipped`) rows still show in-app |
| 052 | HOTFIX: drop `notification_preferences_space_id_fkey`. FKs to BOTH space_members and spaces made the table a PostgREST junction -> `space_members.select('*, spaces(*)')` ambiguous (PGRST201) -> all logged-in users bounced to /signup. One FK left (member_id); space reachable + cascades via space_members. NEVER add a spaces FK to a space_members-referencing table. Guarded by integration/auth-embed.test.ts |
| 050 | CHECK `dues_payment_methods_url_https` (`url ~* '^https://'`): the url renders as a member-clickable href and RLS lets admins write directly past the app Zod check, so the DB enforces https (no stored `javascript:`/`http:` XSS). Member-read action also re-filters with `isSafeDuesUrl`. Idempotent DO-block |
---
+23 -48
View File
@@ -2,6 +2,7 @@
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { SectionTitle } from '@/components/ui/page-title'
import {
certificationStatus,
CERT_STATUS_LABEL,
@@ -14,6 +15,7 @@ import { DuesCard, type DuesMethod } from '@/components/billing/dues-card'
import { ProfileForm, type ProfileInitial } from './profile-form'
import { CancelAction } from './cancel-action'
import { NotificationPrefs } from './notification-prefs'
import { NotificationsInbox } from './notifications-inbox'
import type { PrefMap } from '@/lib/notifications-prefs-logic'
import { cancelMySignup, cancelReservation } from '@/lib/actions'
@@ -53,7 +55,7 @@ export type Reservation = {
}
export type MyCard = { id: string; card_type: string; label: string | null; is_active: boolean; last4: string }
export type MyVisit = { id: string; checked_in_at: string; checked_out_at: string | null; is_host: boolean; check_in_note: string | null; check_out_note: string | null }
export type MyNotif = { id: string; type: string; subject: string; status: string; createdAt: string; sentAt: string | null }
export type MyNotif = { id: string; type: string; subject: string; bodyText: string; status: string; createdAt: string; sentAt: string | null; readAt: string | null }
export type MyPayment = { id: string; amount: number; currency: string; platform: string; description: string | null; status: string; date: string }
export type HeldPermission = { code: string; label: string }
export type Billing = { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean; configured: boolean } | null
@@ -66,6 +68,7 @@ type Props = {
duesMethods: DuesMethod[]
payments: MyPayment[]
notifs: MyNotif[]
unreadNotifs: number
notifPrefs: PrefMap
classSignups: ClassSignup[]
reservations: Reservation[]
@@ -73,8 +76,6 @@ type Props = {
myVisits: MyVisit[]
}
const sectionH = 'font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-3'
export function MePortalClient({
profile,
grants,
@@ -83,6 +84,7 @@ export function MePortalClient({
duesMethods,
payments,
notifs,
unreadNotifs,
notifPrefs,
classSignups,
reservations,
@@ -109,12 +111,12 @@ export function MePortalClient({
<TabsContent value="profile" className="space-y-8">
<section>
<h2 className={sectionH}>Profile</h2>
<SectionTitle className="mb-3">Profile</SectionTitle>
<ProfileForm initial={profile} />
</section>
<section>
<h2 className={sectionH}>My permissions</h2>
<SectionTitle className="mb-3">My permissions</SectionTitle>
{held.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground">
You have the standard member access for this space. No extra permissions are granted to your role.
@@ -140,18 +142,18 @@ export function MePortalClient({
<TabsContent value="membership" className="space-y-8">
<section>
<h2 className={sectionH}>Dues</h2>
<SectionTitle className="mb-3">Dues</SectionTitle>
<DuesCard billing={billing} methods={duesMethods} />
</section>
<section>
<h2 className={sectionH}>My payments</h2>
<SectionTitle className="mb-3">My payments</SectionTitle>
{payments.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground">
No payments recorded for you yet.
</p>
) : (
<ul className="divide-y rounded-lg border border-border">
<ul className="divide-y rounded border border-border">
{payments.map(p => (
<li key={p.id} className="p-4 flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0">
@@ -171,13 +173,13 @@ export function MePortalClient({
</section>
<section>
<h2 className={sectionH}>Certifications</h2>
<SectionTitle className="mb-3">Certifications</SectionTitle>
{grants.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground">
You have no certifications yet. An instructor can award one to you.
</p>
) : (
<ul className="divide-y rounded-lg border border-border">
<ul className="divide-y rounded border border-border">
{grants.map(g => {
const status = certificationStatus(g)
return (
@@ -217,7 +219,7 @@ export function MePortalClient({
<TabsContent value="activity" className="space-y-8">
<section>
<h2 className={sectionH}>Email preferences</h2>
<SectionTitle className="mb-3">Email preferences</SectionTitle>
<NotificationPrefs initial={notifPrefs} />
<p className="font-sans text-xs text-muted-foreground mt-3">
Billing emails (dues receipts, payment alerts) are always sent.
@@ -225,46 +227,19 @@ export function MePortalClient({
</section>
<section>
<h2 className={sectionH}>Notifications</h2>
{notifs.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground">
No notifications yet. Dues receipts and payment alerts will show up here.
</p>
) : (
<ul className="divide-y rounded-lg border border-border">
{notifs.map(n => (
<li key={n.id} className="p-4 flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0">
<span className="font-sans text-sm text-foreground">{n.subject}</span>
<p className="font-mono text-[10px] text-muted-foreground mt-1">
{new Date(n.createdAt).toLocaleString()}
{n.sentAt ? ` · sent ${new Date(n.sentAt).toLocaleDateString()}` : ''}
</p>
</div>
<Badge variant={n.status === 'sent' ? 'default' : 'outline'}>
{n.status === 'sent'
? 'Sent'
: n.status === 'failed'
? 'Failed'
: n.status === 'skipped'
? 'Muted'
: 'Queued'}
</Badge>
</li>
))}
</ul>
)}
<SectionTitle className="mb-3">Notifications</SectionTitle>
<NotificationsInbox items={notifs} unreadCount={unreadNotifs} />
</section>
<section>
<h2 className={sectionH}>My classes</h2>
<SectionTitle className="mb-3">My classes</SectionTitle>
{classSignups.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground">
You have not signed up for any classes. Browse what&rsquo;s on at{' '}
<a href="/classes" className="text-primary underline">Classes</a>.
</p>
) : (
<ul className="divide-y rounded-lg border border-border">
<ul className="divide-y rounded border border-border">
{classSignups.map(cs => {
const sess = sessionOf(cs)
return (
@@ -302,14 +277,14 @@ export function MePortalClient({
</section>
<section>
<h2 className={sectionH}>My equipment reservations</h2>
<SectionTitle className="mb-3">My equipment reservations</SectionTitle>
{reservations.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground">
You have no reservations. Browse what you can reserve at{' '}
<a href="/equipment" className="text-primary underline">Equipment</a>.
</p>
) : (
<ul className="divide-y rounded-lg border border-border">
<ul className="divide-y rounded border border-border">
{reservations.map(r => {
const eq = equipOf(r)
return (
@@ -343,13 +318,13 @@ export function MePortalClient({
</section>
<section>
<h2 className={sectionH}>My access cards</h2>
<SectionTitle className="mb-3">My access cards</SectionTitle>
{myCards.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground">
No access cards are registered to you. A door manager can add one.
</p>
) : (
<ul className="divide-y rounded-lg border border-border">
<ul className="divide-y rounded border border-border">
{myCards.map(c => (
<li key={c.id} className="p-4 flex items-center justify-between gap-4">
<div>
@@ -370,13 +345,13 @@ export function MePortalClient({
</section>
<section>
<h2 className={sectionH}>My recent visits</h2>
<SectionTitle className="mb-3">My recent visits</SectionTitle>
{myVisits.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground">
No visits yet. Check in from the dashboard when you are at the space.
</p>
) : (
<ul className="divide-y rounded-lg border border-border">
<ul className="divide-y rounded border border-border">
{myVisits.map(v => {
const st = presenceStatus(v.checked_in_at, v.checked_out_at)
return (
+1 -1
View File
@@ -29,7 +29,7 @@ export function NotificationPrefs({ initial }: { initial: PrefMap }) {
}
return (
<ul className="divide-y rounded-lg border border-border">
<ul className="divide-y rounded border border-border">
{CATEGORY_META.map(meta => {
const enabled = prefs[meta.category] ?? true
return (
+116
View File
@@ -0,0 +1,116 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { markNotificationsRead } from '@/lib/actions'
export type InboxItem = {
id: string
type: string
subject: string
bodyText: string
status: string
createdAt: string
sentAt: string | null
readAt: string | null
}
// The member's in-app notification inbox. Click an item to expand its message
// (plain text) and mark it read; "Mark all as read" clears the rest. Read
// state lives in notifications.read_at; this is the always-on channel, so a
// notification whose email was muted ('skipped') still shows here.
export function NotificationsInbox({
items,
unreadCount,
}: {
items: InboxItem[]
unreadCount: number
}) {
const router = useRouter()
const [expanded, setExpanded] = useState<string | null>(null)
const [pending, startTransition] = useTransition()
function open(n: InboxItem) {
setExpanded(e => (e === n.id ? null : n.id))
if (!n.readAt) {
startTransition(async () => {
await markNotificationsRead({ ids: [n.id] })
router.refresh()
})
}
}
function markAll() {
startTransition(async () => {
await markNotificationsRead({})
router.refresh()
})
}
if (items.length === 0) {
return (
<p className="font-sans text-sm text-muted-foreground">
No notifications yet. Dues receipts, booking and class updates, and form receipts show up here.
</p>
)
}
return (
<div className="space-y-3">
{unreadCount > 0 && (
<div className="flex items-center justify-between">
<span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
{unreadCount} unread
</span>
<Button size="sm" variant="outline" disabled={pending} onClick={markAll}>
Mark all as read
</Button>
</div>
)}
<ul className="divide-y rounded border border-border">
{items.map(n => {
const unread = !n.readAt
const isOpen = expanded === n.id
return (
<li key={n.id}>
<button
type="button"
onClick={() => open(n)}
className="w-full text-left p-4 flex items-start justify-between gap-4 hover:bg-muted/40"
aria-expanded={isOpen}
>
<div className="min-w-0 flex items-start gap-2">
<span
className={`mt-1.5 h-2 w-2 rounded-full shrink-0 ${unread ? 'bg-primary' : 'bg-transparent'}`}
aria-hidden
/>
<div className="min-w-0">
<span className={`font-sans text-sm text-foreground ${unread ? 'font-semibold' : ''}`}>
{n.subject}
</span>
<p className="font-mono text-[10px] text-muted-foreground mt-1">
{new Date(n.createdAt).toLocaleString()}
{n.sentAt
? ` · emailed ${new Date(n.sentAt).toLocaleDateString()}`
: n.status === 'skipped'
? ' · email muted'
: ''}
</p>
</div>
</div>
{unread && <Badge variant="outline">New</Badge>}
</button>
{isOpen && n.bodyText && (
<pre className="px-4 pb-4 font-sans text-sm text-muted-foreground whitespace-pre-wrap break-words">
{n.bodyText}
</pre>
)}
</li>
)
})}
</ul>
</div>
)
}
+5 -1
View File
@@ -29,7 +29,7 @@ export default async function MePage() {
const { data: member } = await supabase
.from('space_members')
.select(
'id, space_id, role, email, display_name, handle, phone, bio, skills, interests, willing_to, affiliations',
'id, space_id, role, email, display_name, handle, phone, bio, skills, interests, willing_to, affiliations, coi_last_disclosed_at',
)
.eq('user_id', user.id)
.in('status', ['current', 'unverified', 'late'])
@@ -59,6 +59,8 @@ export default async function MePage() {
interests: (member.interests as string[] | null) ?? [],
willing_to: (member.willing_to as string[] | null) ?? [],
affiliations: (member.affiliations as string[] | null) ?? [],
isPrivileged: ['admin', 'board', 'treasurer'].includes(member.role as string),
coiLastDisclosed: (member.coi_last_disclosed_at as string | null) ?? null,
}
const { data: grantsRaw } = await supabase
@@ -119,6 +121,7 @@ export default async function MePage() {
const notifsRes = await getMyNotifications()
const notifs: MyNotif[] = 'data' in notifsRes ? (notifsRes.data as MyNotif[]) : []
const unreadNotifs = 'unreadCount' in notifsRes ? notifsRes.unreadCount : 0
const notifPrefsRes = await getMyNotificationPreferences()
const notifPrefs: PrefMap = 'data' in notifPrefsRes ? notifPrefsRes.data : {}
@@ -140,6 +143,7 @@ export default async function MePage() {
duesMethods={duesMethods}
payments={payments}
notifs={notifs}
unreadNotifs={unreadNotifs}
notifPrefs={notifPrefs}
classSignups={classSignups}
reservations={reservations}
+20 -2
View File
@@ -21,6 +21,11 @@ export type ProfileInitial = {
interests: string[]
willing_to: string[]
affiliations: string[]
// Conflict-of-interest disclosure (ported from the former /profile page):
// privileged roles are nudged to disclose; the last-disclosed date is shown
// when set.
isPrivileged: boolean
coiLastDisclosed: string | null
}
const sameList = (a: string[], b: string[]) =>
@@ -90,7 +95,7 @@ export function ProfileForm({ initial }: { initial: ProfileInitial }) {
return (
<div className="space-y-4">
<div className="bg-card rounded-lg border border-border p-5 space-y-5">
<div className="bg-card rounded border border-border p-5 space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="pf-name">Display name</Label>
@@ -138,7 +143,20 @@ export function ProfileForm({ initial }: { initial: ProfileInitial }) {
<div className="space-y-1.5">
<span className={fieldLabel}>Affiliations (conflict-of-interest)</span>
{initial.isPrivileged && !initial.coiLastDisclosed && (
<div className="rounded border border-orange-200 bg-orange-50 dark:border-orange-900/50 dark:bg-orange-950/30 p-3">
<p className="font-sans text-xs text-orange-900 dark:text-orange-200">
You hold a privileged role. Please disclose any outside affiliations that could
conflict with space governance. Your disclosure is visible to other members.
</p>
</div>
)}
<ChipInput values={affiliations} onChange={setAffiliations} placeholder="add an affiliation and press enter" maxLength={200} maxItems={50} />
{initial.coiLastDisclosed && (
<p className="font-mono text-[10px] text-muted-foreground">
last disclosed {new Date(initial.coiLastDisclosed).toLocaleDateString()}
</p>
)}
</div>
<div className="flex justify-end">
@@ -148,7 +166,7 @@ export function ProfileForm({ initial }: { initial: ProfileInitial }) {
</div>
</div>
<div className="bg-card rounded-lg border border-border p-5 space-y-3">
<div className="bg-card rounded border border-border p-5 space-y-3">
<div>
<p className="font-sans text-sm font-medium text-foreground">Login email</p>
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
-64
View File
@@ -1,64 +0,0 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { discloseAffiliations } from '@/lib/actions'
import { ChipInput } from '@/components/chip-input'
export function AffiliationsForm({ initial }: { initial: { affiliations: string[] } }) {
const router = useRouter()
const [affiliations, setAffiliations] = useState<string[]>(initial.affiliations)
const [error, setError] = useState('')
const [status, setStatus] = useState<'idle' | 'saved'>('idle')
const [isPending, startTransition] = useTransition()
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setStatus('idle')
startTransition(async () => {
const r = await discloseAffiliations({ affiliations })
if ('error' in r && r.error) setError(r.error)
else {
setStatus('saved')
router.refresh()
}
})
}
return (
<form onSubmit={handleSubmit} className="bg-card rounded border border-border p-5 space-y-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-2">
Outside affiliations
</label>
<ChipInput
values={affiliations}
onChange={setAffiliations}
placeholder='add an affiliation and press enter, e.g. "ACME Robotics, contractor"'
maxLength={200}
maxItems={50}
/>
<p className="font-mono text-[10px] text-muted-foreground mt-1">
Organizations, businesses, or roles outside this space that could intersect with space
decisions. Visible to all members. Submitting stamps your disclosure date.
</p>
</div>
{error && <p className="font-mono text-xs text-red-500">{error}</p>}
{status === 'saved' && !error && (
<p className="font-mono text-xs text-primary">Disclosed. Your timestamp is updated.</p>
)}
<div className="flex items-center justify-end">
<button
type="submit"
disabled={isPending}
className="bg-primary text-white font-sans text-sm font-medium px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
>
{isPending ? 'Disclosing...' : 'Disclose now'}
</button>
</div>
</form>
)
}
-101
View File
@@ -1,101 +0,0 @@
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { ProfileForm } from './profile-form'
import { AffiliationsForm } from './affiliations-form'
import { PageTitle } from '@/components/ui/page-title'
export const dynamic = 'force-dynamic'
export default async function ProfilePage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: memberRaw } = await supabase
.from('space_members')
.select('*')
.eq('user_id', user.id)
.in('status', ['current', 'unverified', 'late'])
.maybeSingle()
if (!memberRaw) redirect('/signup')
const member = memberRaw as unknown as {
id: string
display_name: string | null
handle: string | null
phone: string | null
role: string
tier: string
skills: string[]
interests: string[]
willing_to: string[]
affiliations: string[]
coi_last_disclosed_at: string | null
email: string | null
}
const privilegedRoles = ['admin', 'board', 'treasurer']
const isPrivileged = privilegedRoles.includes(member.role)
return (
<div className="min-h-screen bg-background">
<div className="bg-sidebar px-4 md:px-6 py-3">
<PageTitle>My profile</PageTitle>
</div>
<div className="p-4 md:p-6 space-y-6 max-w-3xl">
<header className="bg-card rounded border border-border p-5">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-1">
Account
</p>
<p className="font-sans text-sm text-foreground">{member.email}</p>
<p className="font-mono text-[10px] text-muted-foreground mt-1">
role: {member.role} · tier: {member.tier}
</p>
<p className="font-mono text-[10px] text-muted-foreground mt-2">
Role and tier can only be changed by an admin or board member. The privilege-escalation
trigger blocks self-edits to those fields at the database level.
</p>
</header>
<section>
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase mb-3">
Profile
</p>
<ProfileForm
initial={{
display_name: member.display_name ?? '',
handle: member.handle ?? '',
phone: member.phone ?? '',
skills: member.skills ?? [],
interests: member.interests ?? [],
willing_to: member.willing_to ?? [],
}}
/>
</section>
<section>
<div className="flex items-baseline justify-between mb-3">
<p className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">
Conflict-of-interest disclosure
</p>
{member.coi_last_disclosed_at && (
<p className="font-mono text-[10px] text-muted-foreground">
last disclosed {new Date(member.coi_last_disclosed_at).toLocaleDateString()}
</p>
)}
</div>
{isPrivileged && !member.coi_last_disclosed_at && (
<div className="bg-orange-50 border border-orange-200 rounded p-3 mb-3">
<p className="font-sans text-sm text-orange-900">
You hold a privileged role ({member.role}). Please disclose any outside affiliations
that could conflict with space governance. Disclosure is visible to other members.
</p>
</div>
)}
<AffiliationsForm initial={{ affiliations: member.affiliations ?? [] }} />
</section>
</div>
</div>
)
}
-146
View File
@@ -1,146 +0,0 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { updateMyProfile } from '@/lib/actions'
import { ChipInput } from '@/components/chip-input'
import { WILLING_TO_SUGGESTIONS } from '@/lib/profile-presets'
type Props = {
initial: {
display_name: string
handle: string
phone: string
skills: string[]
interests: string[]
willing_to: string[]
}
}
export function ProfileForm({ initial }: Props) {
const router = useRouter()
const [displayName, setDisplayName] = useState(initial.display_name)
const [handle, setHandle] = useState(initial.handle)
const [phone, setPhone] = useState(initial.phone)
const [skills, setSkills] = useState<string[]>(initial.skills)
const [interests, setInterests] = useState<string[]>(initial.interests)
const [willingTo, setWillingTo] = useState<string[]>(initial.willing_to)
const [error, setError] = useState('')
const [status, setStatus] = useState<'idle' | 'saved'>('idle')
const [isPending, startTransition] = useTransition()
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setStatus('idle')
startTransition(async () => {
const r = await updateMyProfile({
display_name: displayName,
handle: handle || null,
phone: phone || null,
skills,
interests,
willing_to: willingTo,
})
if ('error' in r && r.error) setError(r.error)
else {
setStatus('saved')
router.refresh()
}
})
}
return (
<form onSubmit={handleSubmit} className="bg-card rounded border border-border p-5 space-y-5">
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
Display name
</label>
<input
type="text"
value={displayName}
onChange={e => setDisplayName(e.target.value)}
maxLength={100}
required
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
Handle
</label>
<input
type="text"
value={handle}
onChange={e => setHandle(e.target.value)}
maxLength={50}
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
/>
</div>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
Phone
</label>
<input
type="tel"
value={phone}
onChange={e => setPhone(e.target.value)}
maxLength={20}
className="w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
Skills
</label>
<ChipInput values={skills} onChange={setSkills} placeholder="add a skill and press enter" maxLength={60} maxItems={40} />
<p className="font-mono text-[10px] text-muted-foreground mt-1">
Examples: woodworking, laser, electronics, accounting, grant writing.
</p>
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
Interests
</label>
<ChipInput values={interests} onChange={setInterests} placeholder="add an interest and press enter" maxLength={60} maxItems={40} />
</div>
<div>
<label className="font-mono text-[10px] tracking-widest text-muted-foreground uppercase block mb-1">
Willing to do
</label>
<ChipInput
values={willingTo}
onChange={setWillingTo}
placeholder="add a role and press enter"
maxLength={60}
maxItems={20}
suggestions={WILLING_TO_SUGGESTIONS}
/>
<p className="font-mono text-[10px] text-muted-foreground mt-1">
Used by the recruitment view (board only) when looking for members willing to take on a role.
</p>
</div>
{error && <p className="font-mono text-xs text-red-500">{error}</p>}
{status === 'saved' && !error && (
<p className="font-mono text-xs text-primary">Saved.</p>
)}
<div className="flex items-center justify-end">
<button
type="submit"
disabled={isPending}
className="bg-primary text-white font-sans text-sm font-medium px-4 py-2 rounded hover:bg-primary/90 transition disabled:opacity-50"
>
{isPending ? 'Saving...' : 'Save profile'}
</button>
</div>
</form>
)
}
+1 -1
View File
@@ -75,7 +75,7 @@ export default async function RecruitmentPage() {
<EmptyMedia variant="icon"><Users /></EmptyMedia>
<EmptyTitle>No members have declared willingness or skills yet</EmptyTitle>
<EmptyDescription>
Members opt in from their <Link href="/profile">profile</Link> page.
Members opt in from their <Link href="/me">membership</Link> page.
</EmptyDescription>
</EmptyHeader>
</Empty>
+2 -3
View File
@@ -13,7 +13,7 @@ type Space = Tables<'spaces'>
import {
Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2, Wrench,
MessageSquare, Users, CreditCard, BookUser, Download, LogOut,
Vote, ShieldAlert, ScrollText, LineChart, UserCircle, UserSearch,
Vote, ShieldAlert, ScrollText, LineChart, UserSearch,
MessagesSquare, SlidersHorizontal, ClipboardList, FileText, Award, BadgeCheck, GraduationCap, Hammer,
ChevronDown, DoorClosed, CalendarCheck,
} from 'lucide-react'
@@ -219,8 +219,7 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
</NavSection>
<NavSection id="account" title="Account">
<NavLink href="/profile" label="My profile" icon={UserCircle} active={isActive('/profile')} onClick={onNav} />
<NavLink href="/me" label="My access" icon={BadgeCheck} active={isActive('/me')} onClick={onNav} />
<NavLink href="/me" label="My membership" icon={BadgeCheck} active={isActive('/me')} onClick={onNav} />
</NavSection>
{showAdminSection && (
+1 -1
View File
@@ -42,7 +42,7 @@ export function DuesCard({ billing, methods = [] }: { billing: Billing; methods?
}
return (
<div className="bg-card rounded-lg border border-border p-5 space-y-4">
<div className="bg-card rounded border border-border p-5 space-y-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<p className="font-sans text-sm font-medium text-foreground">Membership dues</p>
+1 -2
View File
@@ -60,8 +60,7 @@ const NAV: { group: string; items: Dest[] }[] = [
{
group: 'Account',
items: [
{ label: 'My profile', href: '/profile' },
{ label: 'My access', href: '/me' },
{ label: 'My membership', href: '/me' },
],
},
{
+3 -1
View File
@@ -842,6 +842,8 @@ the member's own notification history. No new permission code.
2. **Payment integrations** - Stripe recurring dues IS integrated (product spine Phase 1: per-space keys, hosted Checkout/Portal, the per-space signed webhook). A PayPal sync endpoint exists (`app/api/paypal/sync/route.ts`). Manual import/CSV reconciliation remains the path for ad-hoc/other-platform payments; Zeffy/Venmo live APIs are not integrated.
3. **Social auth** - GitHub/Google sign-in is wired, gated by the `NEXT_PUBLIC_OAUTH_GITHUB`/`NEXT_PUBLIC_OAUTH_GOOGLE` env flags.
4. **Webhooks** - The HMAC signing contract and secret rotation exist; per-event delivery is not implemented (see `docs/WEBHOOKS.md`).
5. **Email notifications** - Dues-lifecycle (Phase 2; migration 041) plus booking, class signup, and form-submission events (Phase 4; migration 047) all reuse the same outbox + dispatcher. Member notification preferences (Phase 5; migration 048) let a member opt out of the muteable categories (bookings, classes, forms, admin alerts); billing is always-on. Enforced at dispatch time (muted rows are marked `skipped`, never sent). An in-app notification center (read/unread state) is the remaining planned pass.
5. **Email notifications** - Dues-lifecycle (Phase 2; migration 041) plus booking, class signup, and form-submission events (Phase 4; migration 047) all reuse the same outbox + dispatcher. Member notification preferences (Phase 5; migration 048) let a member opt out of the muteable categories (bookings, classes, forms, admin alerts); billing is always-on. Enforced at dispatch time (muted rows are marked `skipped`, never sent). The in-app notification center (Phase 5; migration 051) gives `/me` an inbox with read/unread state (`notifications.read_at`) and is the always-on channel: a notification whose email was muted still appears in-app. The single member self-service page is `/me` (the former `/profile` was consolidated into it).
> **Schema gotcha (migration 052, learned from a production outage):** the auth layout resolves membership with `space_members.select('*, spaces(*)')`. Do NOT create a table with foreign keys to BOTH `space_members` and `spaces` -- PostgREST reads it as a junction and makes that embed ambiguous (`PGRST201`), which nulls every logged-in member and redirects them to `/signup`. `notification_preferences` keeps only its `member_id -> space_members` FK for this reason; `space_id` is a plain column. `integration/auth-embed.test.ts` guards the embed.
6. **Search** - Client-side filtering only, no server-side full-text search.
7. **Single space per user** - The auth resolver assumes one active membership per user; a user in 2+ spaces is not supported (no space switcher). Fails closed.
+2
View File
@@ -730,6 +730,8 @@ migration is mirrored as a section in it. Tables/columns added:
| 047 | `members_with_permission(sid, perm)` SECURITY DEFINER, `search_path=public`. Inverted, set-returning form of `user_has_permission`: returns `member_id` for every member of `sid` who holds `perm` (same current/late status gate as 046, same admin shortcut, same `space_role_permissions` + `user_effective_roles` fallback). Lets the Phase 4 notification fan-outs (e.g. `form_submission_admin`) find permission holders in one query instead of N round-trips. EXECUTE is locked: `REVOKE EXECUTE FROM PUBLIC`, `GRANT EXECUTE TO service_role` so the set-returning shape cannot be used by a PostgREST client to enumerate "who has permission X in space Y" across spaces (the rationale for not locking `user_has_permission` is that RLS policies depend on anon/authenticated being able to call it). Additive; existing `user_has_permission` callers unchanged. No new table/column/policy |
| 048 | `notification_preferences` (Product spine Phase 5): per-member opt-out of muteable categories. Columns `space_id, member_id, category, enabled`, PK `(space_id, member_id, category)`. Opt-out model: absence of a row = enabled. Only the four muteable categories (`bookings, classes, forms, admin_alerts`) are ever stored; `billing` (dues renewed/failed/lapsed) is membership-critical and never muteable. RLS enabled, NO client policy (default-deny): the dispatcher reads via the service client and marks a muted outbox row `skipped`; the `/me` self-view + toggle write both go through validated service-client actions, same convention as `notifications` / `member_billing`. `touch_updated_at` trigger. No new permission code |
| 049 | `dues_payment_methods`: admin-configured external dues payment links. Columns `id, space_id, platform (payment_platform), url, instructions, is_active, sort_order`, `UNIQUE (space_id, platform)`. One external pay-here URL per platform (PayPal/Zeffy/Venmo); members click out to pay dues off-platform and a treasurer reconciles manually via the existing payments flow (the platform tag pre-types that reconcile). Link configuration only, no automated payment record. RLS: SELECT = any space member (they render the buttons); INSERT/UPDATE/DELETE = admin/board (`user_has_role_in_space`). `url` is validated absolute-https at the server-action boundary. `touch_updated_at` trigger. No new permission code |
| 051 | `notifications.read_at timestamptz` (+ partial index `idx_notifications_unread` on `(space_id, member_id) WHERE read_at IS NULL`): the outbox doubles as the member's in-app inbox on `/me`; `read_at IS NULL` = unread. The mark-read action is a validated service-client write scoped to the caller's own `member_id` (no RLS change; notifications stays no-client-write). The in-app inbox is the always-on channel: a notification whose email was muted (`skipped`) still appears here. No new permission code |
| 052 | HOTFIX (production outage): drop `notification_preferences_space_id_fkey`. The table (048) had FKs to BOTH `space_members` and `spaces`, which PostgREST reads as a junction, making the auth layout's `space_members.select('*, spaces(*)')` embed ambiguous (PGRST201) -- membership resolved to null and every logged-in user was bounced to `/signup`. Dropping the redundant `space_id -> spaces` FK leaves one FK so the table is no longer a junction; space is still reachable via `member_id -> space_members`, and space deletion still cascades through it. **Never add a `spaces` FK to a `space_members`-referencing table** (reintroduces the junction). Guarded by `integration/auth-embed.test.ts`. No new table/column |
| 050 | `dues_payment_methods.url` gains a CHECK constraint (`dues_payment_methods_url_https`, `url ~* '^https://'`). The url is rendered to members as a clickable href; RLS lets an admin/board write the row directly via PostgREST, bypassing the app Zod check, so the DB enforces https as the source of truth (a `javascript:`/`http:` value can never be stored). The member-read action also re-validates with `isSafeDuesUrl` (defense-in-depth). Idempotent DO-block add-if-missing. No new table/column/policy |
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
+18
View File
@@ -4,6 +4,24 @@ Append-only. Newest entries on top. Keep each entry to one screen.
---
## 2026-05-20 (pass 59): PROD OUTAGE fix + in-app inbox + /me consolidation + style
Branch `main`. Suite 594 unit, 40 integration, build clean.
### PROD OUTAGE (fixed + deployed, migration 052)
Migration 048 (`notification_preferences`) had FKs to BOTH `space_members` and `spaces`. PostgREST read it as a junction, so the auth layout's `space_members.select('*, spaces(*)')` embed went ambiguous (`PGRST201`) -> `member` null -> EVERY logged-in user bounced to `/signup` (anon landing was fine). Live from the pass-58 `ab8c22a` deploy until the hotfix. Fix: migration 052 drops the redundant `space_id -> spaces` FK (space still reachable + cascades via `member_id -> space_members`). Verified by reproducing `PGRST201` on local PostgREST, then confirming `[]` after the drop; confirmed on PROD (run 26142626745, `a7d880d`) by testing the embed against `supabase.hackerspace.sh` with the anon key. **Regression guard added:** `integration/auth-embed.test.ts` runs the exact embed through PostgREST and fails on `PGRST201`. Root lesson: build/unit/psql-integration never exercised the PostgREST embed, so the schema-level ambiguity slipped through.
### Shipped in this pass (LOCAL, NOT yet deployed beyond the 052 hotfix)
- **In-app notification center (Phase 5; migration 051).** `notifications.read_at` + partial unread index; `/me` Notifications becomes an inbox (unread dot/bold, click to expand `body_text` + mark read, "Mark all as read", unread count). `getMyNotifications` returns read state + body + unread count; `markNotificationsRead({ids?})` is a service-client write scoped to the caller's `member_id`. Always-on channel: email-muted (`skipped`) rows still show. NOT deployed yet.
- **/me <- /profile consolidation.** Deleted `app/(app)/profile/*`; `/me` is the single member page. Ported the COI privileged-role nudge + last-disclosed date into `/me`'s profile editor. Removed the "My profile" sidebar + command-palette entries; renamed the `/me` nav label "My access" -> "My membership" (matches the page title). Repointed the recruitment empty-state link to `/me`.
- **Style: realigned `/me` to the central theme.** Reverted the `rounded-lg` cards I introduced in the pass-58 visual pass back to `rounded` (the app norm: 519 `rounded` vs 53 `rounded-lg`, and what `/profile` used). Routed `/me` section headers through the `SectionTitle` primitive instead of an inline `sectionH` const. (Self-critique: the pass-58 "visual pass" introduced the `rounded-lg` drift the user flagged.)
### Open / next
- **NOT deployed:** the inbox (051), the consolidation, and the style fixes are committed locally pending a deploy (migration 051 applies on deploy). Awaiting user go.
- Browser review of `/me` still pending (auth-gated; can't curl).
---
## 2026-05-19 (pass 58): Dues payment options + /me visual pass (DEPLOYED)
> DEPLOY STATE: pass-57 + pass-58 shipped together in commit `ab8c22a`
+43
View File
@@ -0,0 +1,43 @@
// Regression guard for the PGRST201 production outage fixed by migration 052.
// The auth layout (app/(app)/layout.tsx) resolves membership with
// `space_members.select('*, spaces(*)')`. If a migration ever adds a table
// with foreign keys to BOTH space_members and spaces, PostgREST treats it as a
// junction, the spaces embed becomes ambiguous (error code PGRST201), the query
// returns null, and EVERY logged-in user is redirected to /signup. This runs
// the exact embed through PostgREST (where the ambiguity manifests -- a raw psql
// query would not catch it) and fails if it is ever ambiguous again.
import { describe, it, expect } from 'vitest'
import { execFileSync } from 'node:child_process'
import { createClient } from '@supabase/supabase-js'
import { dbReady } from './db'
function supaEnv(): { url: string; key: string } | null {
try {
const out = execFileSync('supabase', ['status', '-o', 'env'], { encoding: 'utf8' })
const get = (k: string) =>
out.split('\n').find(l => l.startsWith(k + '='))?.slice(k.length + 1).replace(/^"|"$/g, '')
const url = get('API_URL')
const key = get('SERVICE_ROLE_KEY')
return url && key ? { url, key } : null
} catch {
return null
}
}
const env = supaEnv()
const d = describe.skipIf(!dbReady || !env)
d('auth layout membership embed (PGRST201 guard)', () => {
const sb = createClient(env!.url, env!.key)
it("space_members.select('*, spaces(*)') is unambiguous", async () => {
const { error } = await sb.from('space_members').select('*, spaces(*)').limit(1)
expect(error?.code).not.toBe('PGRST201')
expect(error).toBeNull()
})
it('space_members -> spaces(id) is unambiguous', async () => {
const { error } = await sb.from('space_members').select('id, spaces(id)').limit(1)
expect(error?.code).not.toBe('PGRST201')
expect(error).toBeNull()
})
})
+55 -6
View File
@@ -11,35 +11,84 @@ import {
type PrefMap,
} from '@/lib/notifications-prefs-logic'
// The caller's own notification history. The notifications RLS SELECT is
// admin/board/treasurer only (mirrors member_billing), so the member
// self-view goes through the validated service client, same as getMyBilling.
// The caller's own notification history, plus the in-app inbox read state and
// an unread count. The notifications RLS SELECT is admin/board/treasurer only
// (mirrors member_billing), so the member self-view goes through the validated
// service client, same as getMyBilling. body_text is the plain-text message
// shown when the member expands an inbox item (never body_html, so the inbox
// never renders untrusted markup).
export async function getMyNotifications() {
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { data } = await createAdminClient()
const admin = createAdminClient()
const { data } = await admin
.from('notifications')
.select('id, type, subject, status, created_at, sent_at')
.select('id, type, subject, body_text, status, created_at, sent_at, read_at')
.eq('space_id', member.space_id)
.eq('member_id', member.id)
.order('created_at', { ascending: false })
.limit(15)
.limit(30)
const { count } = await admin
.from('notifications')
.select('id', { count: 'exact', head: true })
.eq('space_id', member.space_id)
.eq('member_id', member.id)
.is('read_at', null)
return {
unreadCount: count ?? 0,
data: (data ?? []).map(r => ({
id: r.id as string,
type: r.type as string,
subject: r.subject as string,
bodyText: (r.body_text as string | null) ?? '',
status: r.status as string,
createdAt: r.created_at as string,
sentAt: (r.sent_at as string | null) ?? null,
readAt: (r.read_at as string | null) ?? null,
})),
}
}
// Mark the caller's own notifications read (the in-app inbox). With ids, marks
// just those; without, marks all of the caller's unread. The `.eq('member_id')`
// scope means ids from another member can never be touched (the id filter only
// narrows within the caller's own rows). Service-client write, same convention
// as the rest of the notification self-view (no client write policy).
export async function markNotificationsRead(
input: { ids?: string[] } = {},
): Promise<{ ok: true } | { error: string }> {
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
let q = createAdminClient()
.from('notifications')
.update({ read_at: new Date().toISOString() })
.eq('space_id', member.space_id)
.eq('member_id', member.id)
.is('read_at', null)
if (input.ids && input.ids.length > 0) {
// Defend the uuid column against a malformed client id (a non-uuid would
// error the cast). Only well-formed ids pass; an empty result is a no-op.
const ids = input.ids.filter(id => /^[0-9a-f-]{36}$/i.test(id))
if (ids.length === 0) return { ok: true }
q = q.in('id', ids)
}
const { error } = await q
if (error) return { error: 'Could not update notifications' }
revalidatePath('/me')
return { ok: true }
}
// The caller's own notification preferences as a category -> enabled map.
// notification_preferences has no client policy (same convention as
// notifications / member_billing), so the read goes through the service client
+23
View File
@@ -0,0 +1,23 @@
-- =============================================================================
-- 051: in-app notification center read state (Product spine Phase 5)
-- =============================================================================
-- The notifications outbox doubles as the member's in-app inbox on /me. Add a
-- per-row read marker so the inbox can show unread/read state and an unread
-- count. read_at IS NULL means unread; the member self-serve mark-read action
-- (service client, scoped to the caller's own member_id) sets it. This is the
-- always-on channel: a notification whose EMAIL was muted ('skipped' by the
-- dispatcher per the member's preferences) still appears here as unread.
--
-- No RLS change: notifications already has SELECT = admin/board/treasurer and
-- NO client write policy; the member's read + mark-read both go through
-- validated service-client actions (same convention as the rest of the
-- notification self-view).
--
-- Idempotent: ADD COLUMN / CREATE INDEX IF NOT EXISTS. Apply as-is.
-- =============================================================================
ALTER TABLE public.notifications ADD COLUMN IF NOT EXISTS read_at timestamptz;
-- Partial index for the per-member unread count / unread list.
CREATE INDEX IF NOT EXISTS idx_notifications_unread
ON public.notifications (space_id, member_id) WHERE read_at IS NULL;
+3 -3
View File
@@ -191,9 +191,9 @@ export type Database = {
Relationships: []
}
notifications: {
Row: { id: string; space_id: string; member_id: string | null; type: string; channel: string; recipient: string; subject: string; body_html: string; body_text: string; status: string; attempts: number; last_error: string | null; dedupe_key: string; sent_at: string | null; created_at: string; updated_at: string }
Insert: { id?: string; space_id: string; member_id?: string | null; type: string; channel?: string; recipient: string; subject: string; body_html: string; body_text: string; status?: string; attempts?: number; last_error?: string | null; dedupe_key: string; sent_at?: string | null; created_at?: string; updated_at?: string }
Update: { id?: string; space_id?: string; member_id?: string | null; type?: string; channel?: string; recipient?: string; subject?: string; body_html?: string; body_text?: string; status?: string; attempts?: number; last_error?: string | null; dedupe_key?: string; sent_at?: string | null; created_at?: string; updated_at?: string }
Row: { id: string; space_id: string; member_id: string | null; type: string; channel: string; recipient: string; subject: string; body_html: string; body_text: string; status: string; attempts: number; last_error: string | null; dedupe_key: string; sent_at: string | null; read_at: string | null; created_at: string; updated_at: string }
Insert: { id?: string; space_id: string; member_id?: string | null; type: string; channel?: string; recipient: string; subject: string; body_html: string; body_text: string; status?: string; attempts?: number; last_error?: string | null; dedupe_key: string; sent_at?: string | null; read_at?: string | null; created_at?: string; updated_at?: string }
Update: { id?: string; space_id?: string; member_id?: string | null; type?: string; channel?: string; recipient?: string; subject?: string; body_html?: string; body_text?: string; status?: string; attempts?: number; last_error?: string | null; dedupe_key?: string; sent_at?: string | null; read_at?: string | null; created_at?: string; updated_at?: string }
Relationships: []
}
notification_preferences: {