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 | | 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 | | 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 | | 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 | | 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 { Badge } from '@/components/ui/badge'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { SectionTitle } from '@/components/ui/page-title'
import { import {
certificationStatus, certificationStatus,
CERT_STATUS_LABEL, CERT_STATUS_LABEL,
@@ -14,6 +15,7 @@ import { DuesCard, type DuesMethod } from '@/components/billing/dues-card'
import { ProfileForm, type ProfileInitial } from './profile-form' import { ProfileForm, type ProfileInitial } from './profile-form'
import { CancelAction } from './cancel-action' import { CancelAction } from './cancel-action'
import { NotificationPrefs } from './notification-prefs' import { NotificationPrefs } from './notification-prefs'
import { NotificationsInbox } from './notifications-inbox'
import type { PrefMap } from '@/lib/notifications-prefs-logic' import type { PrefMap } from '@/lib/notifications-prefs-logic'
import { cancelMySignup, cancelReservation } from '@/lib/actions' 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 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 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 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 HeldPermission = { code: string; label: string }
export type Billing = { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean; configured: boolean } | null export type Billing = { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean; configured: boolean } | null
@@ -66,6 +68,7 @@ type Props = {
duesMethods: DuesMethod[] duesMethods: DuesMethod[]
payments: MyPayment[] payments: MyPayment[]
notifs: MyNotif[] notifs: MyNotif[]
unreadNotifs: number
notifPrefs: PrefMap notifPrefs: PrefMap
classSignups: ClassSignup[] classSignups: ClassSignup[]
reservations: Reservation[] reservations: Reservation[]
@@ -73,8 +76,6 @@ type Props = {
myVisits: MyVisit[] myVisits: MyVisit[]
} }
const sectionH = 'font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-3'
export function MePortalClient({ export function MePortalClient({
profile, profile,
grants, grants,
@@ -83,6 +84,7 @@ export function MePortalClient({
duesMethods, duesMethods,
payments, payments,
notifs, notifs,
unreadNotifs,
notifPrefs, notifPrefs,
classSignups, classSignups,
reservations, reservations,
@@ -109,12 +111,12 @@ export function MePortalClient({
<TabsContent value="profile" className="space-y-8"> <TabsContent value="profile" className="space-y-8">
<section> <section>
<h2 className={sectionH}>Profile</h2> <SectionTitle className="mb-3">Profile</SectionTitle>
<ProfileForm initial={profile} /> <ProfileForm initial={profile} />
</section> </section>
<section> <section>
<h2 className={sectionH}>My permissions</h2> <SectionTitle className="mb-3">My permissions</SectionTitle>
{held.length === 0 ? ( {held.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground"> <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. 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"> <TabsContent value="membership" className="space-y-8">
<section> <section>
<h2 className={sectionH}>Dues</h2> <SectionTitle className="mb-3">Dues</SectionTitle>
<DuesCard billing={billing} methods={duesMethods} /> <DuesCard billing={billing} methods={duesMethods} />
</section> </section>
<section> <section>
<h2 className={sectionH}>My payments</h2> <SectionTitle className="mb-3">My payments</SectionTitle>
{payments.length === 0 ? ( {payments.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground"> <p className="font-sans text-sm text-muted-foreground">
No payments recorded for you yet. No payments recorded for you yet.
</p> </p>
) : ( ) : (
<ul className="divide-y rounded-lg border border-border"> <ul className="divide-y rounded border border-border">
{payments.map(p => ( {payments.map(p => (
<li key={p.id} className="p-4 flex items-start justify-between gap-4 flex-wrap"> <li key={p.id} className="p-4 flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0"> <div className="min-w-0">
@@ -171,13 +173,13 @@ export function MePortalClient({
</section> </section>
<section> <section>
<h2 className={sectionH}>Certifications</h2> <SectionTitle className="mb-3">Certifications</SectionTitle>
{grants.length === 0 ? ( {grants.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground"> <p className="font-sans text-sm text-muted-foreground">
You have no certifications yet. An instructor can award one to you. You have no certifications yet. An instructor can award one to you.
</p> </p>
) : ( ) : (
<ul className="divide-y rounded-lg border border-border"> <ul className="divide-y rounded border border-border">
{grants.map(g => { {grants.map(g => {
const status = certificationStatus(g) const status = certificationStatus(g)
return ( return (
@@ -217,7 +219,7 @@ export function MePortalClient({
<TabsContent value="activity" className="space-y-8"> <TabsContent value="activity" className="space-y-8">
<section> <section>
<h2 className={sectionH}>Email preferences</h2> <SectionTitle className="mb-3">Email preferences</SectionTitle>
<NotificationPrefs initial={notifPrefs} /> <NotificationPrefs initial={notifPrefs} />
<p className="font-sans text-xs text-muted-foreground mt-3"> <p className="font-sans text-xs text-muted-foreground mt-3">
Billing emails (dues receipts, payment alerts) are always sent. Billing emails (dues receipts, payment alerts) are always sent.
@@ -225,46 +227,19 @@ export function MePortalClient({
</section> </section>
<section> <section>
<h2 className={sectionH}>Notifications</h2> <SectionTitle className="mb-3">Notifications</SectionTitle>
{notifs.length === 0 ? ( <NotificationsInbox items={notifs} unreadCount={unreadNotifs} />
<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>
)}
</section> </section>
<section> <section>
<h2 className={sectionH}>My classes</h2> <SectionTitle className="mb-3">My classes</SectionTitle>
{classSignups.length === 0 ? ( {classSignups.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground"> <p className="font-sans text-sm text-muted-foreground">
You have not signed up for any classes. Browse what&rsquo;s on at{' '} You have not signed up for any classes. Browse what&rsquo;s on at{' '}
<a href="/classes" className="text-primary underline">Classes</a>. <a href="/classes" className="text-primary underline">Classes</a>.
</p> </p>
) : ( ) : (
<ul className="divide-y rounded-lg border border-border"> <ul className="divide-y rounded border border-border">
{classSignups.map(cs => { {classSignups.map(cs => {
const sess = sessionOf(cs) const sess = sessionOf(cs)
return ( return (
@@ -302,14 +277,14 @@ export function MePortalClient({
</section> </section>
<section> <section>
<h2 className={sectionH}>My equipment reservations</h2> <SectionTitle className="mb-3">My equipment reservations</SectionTitle>
{reservations.length === 0 ? ( {reservations.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground"> <p className="font-sans text-sm text-muted-foreground">
You have no reservations. Browse what you can reserve at{' '} You have no reservations. Browse what you can reserve at{' '}
<a href="/equipment" className="text-primary underline">Equipment</a>. <a href="/equipment" className="text-primary underline">Equipment</a>.
</p> </p>
) : ( ) : (
<ul className="divide-y rounded-lg border border-border"> <ul className="divide-y rounded border border-border">
{reservations.map(r => { {reservations.map(r => {
const eq = equipOf(r) const eq = equipOf(r)
return ( return (
@@ -343,13 +318,13 @@ export function MePortalClient({
</section> </section>
<section> <section>
<h2 className={sectionH}>My access cards</h2> <SectionTitle className="mb-3">My access cards</SectionTitle>
{myCards.length === 0 ? ( {myCards.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground"> <p className="font-sans text-sm text-muted-foreground">
No access cards are registered to you. A door manager can add one. No access cards are registered to you. A door manager can add one.
</p> </p>
) : ( ) : (
<ul className="divide-y rounded-lg border border-border"> <ul className="divide-y rounded border border-border">
{myCards.map(c => ( {myCards.map(c => (
<li key={c.id} className="p-4 flex items-center justify-between gap-4"> <li key={c.id} className="p-4 flex items-center justify-between gap-4">
<div> <div>
@@ -370,13 +345,13 @@ export function MePortalClient({
</section> </section>
<section> <section>
<h2 className={sectionH}>My recent visits</h2> <SectionTitle className="mb-3">My recent visits</SectionTitle>
{myVisits.length === 0 ? ( {myVisits.length === 0 ? (
<p className="font-sans text-sm text-muted-foreground"> <p className="font-sans text-sm text-muted-foreground">
No visits yet. Check in from the dashboard when you are at the space. No visits yet. Check in from the dashboard when you are at the space.
</p> </p>
) : ( ) : (
<ul className="divide-y rounded-lg border border-border"> <ul className="divide-y rounded border border-border">
{myVisits.map(v => { {myVisits.map(v => {
const st = presenceStatus(v.checked_in_at, v.checked_out_at) const st = presenceStatus(v.checked_in_at, v.checked_out_at)
return ( return (
+1 -1
View File
@@ -29,7 +29,7 @@ export function NotificationPrefs({ initial }: { initial: PrefMap }) {
} }
return ( return (
<ul className="divide-y rounded-lg border border-border"> <ul className="divide-y rounded border border-border">
{CATEGORY_META.map(meta => { {CATEGORY_META.map(meta => {
const enabled = prefs[meta.category] ?? true const enabled = prefs[meta.category] ?? true
return ( 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 const { data: member } = await supabase
.from('space_members') .from('space_members')
.select( .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) .eq('user_id', user.id)
.in('status', ['current', 'unverified', 'late']) .in('status', ['current', 'unverified', 'late'])
@@ -59,6 +59,8 @@ export default async function MePage() {
interests: (member.interests as string[] | null) ?? [], interests: (member.interests as string[] | null) ?? [],
willing_to: (member.willing_to as string[] | null) ?? [], willing_to: (member.willing_to as string[] | null) ?? [],
affiliations: (member.affiliations 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 const { data: grantsRaw } = await supabase
@@ -119,6 +121,7 @@ export default async function MePage() {
const notifsRes = await getMyNotifications() const notifsRes = await getMyNotifications()
const notifs: MyNotif[] = 'data' in notifsRes ? (notifsRes.data as MyNotif[]) : [] const notifs: MyNotif[] = 'data' in notifsRes ? (notifsRes.data as MyNotif[]) : []
const unreadNotifs = 'unreadCount' in notifsRes ? notifsRes.unreadCount : 0
const notifPrefsRes = await getMyNotificationPreferences() const notifPrefsRes = await getMyNotificationPreferences()
const notifPrefs: PrefMap = 'data' in notifPrefsRes ? notifPrefsRes.data : {} const notifPrefs: PrefMap = 'data' in notifPrefsRes ? notifPrefsRes.data : {}
@@ -140,6 +143,7 @@ export default async function MePage() {
duesMethods={duesMethods} duesMethods={duesMethods}
payments={payments} payments={payments}
notifs={notifs} notifs={notifs}
unreadNotifs={unreadNotifs}
notifPrefs={notifPrefs} notifPrefs={notifPrefs}
classSignups={classSignups} classSignups={classSignups}
reservations={reservations} reservations={reservations}
+20 -2
View File
@@ -21,6 +21,11 @@ export type ProfileInitial = {
interests: string[] interests: string[]
willing_to: string[] willing_to: string[]
affiliations: 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[]) => const sameList = (a: string[], b: string[]) =>
@@ -90,7 +95,7 @@ export function ProfileForm({ initial }: { initial: ProfileInitial }) {
return ( return (
<div className="space-y-4"> <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="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="pf-name">Display name</Label> <Label htmlFor="pf-name">Display name</Label>
@@ -138,7 +143,20 @@ export function ProfileForm({ initial }: { initial: ProfileInitial }) {
<div className="space-y-1.5"> <div className="space-y-1.5">
<span className={fieldLabel}>Affiliations (conflict-of-interest)</span> <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} /> <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>
<div className="flex justify-end"> <div className="flex justify-end">
@@ -148,7 +166,7 @@ export function ProfileForm({ initial }: { initial: ProfileInitial }) {
</div> </div>
</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> <div>
<p className="font-sans text-sm font-medium text-foreground">Login email</p> <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"> <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> <EmptyMedia variant="icon"><Users /></EmptyMedia>
<EmptyTitle>No members have declared willingness or skills yet</EmptyTitle> <EmptyTitle>No members have declared willingness or skills yet</EmptyTitle>
<EmptyDescription> <EmptyDescription>
Members opt in from their <Link href="/profile">profile</Link> page. Members opt in from their <Link href="/me">membership</Link> page.
</EmptyDescription> </EmptyDescription>
</EmptyHeader> </EmptyHeader>
</Empty> </Empty>
+2 -3
View File
@@ -13,7 +13,7 @@ type Space = Tables<'spaces'>
import { import {
Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2, Wrench, Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2, Wrench,
MessageSquare, Users, CreditCard, BookUser, Download, LogOut, 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, MessagesSquare, SlidersHorizontal, ClipboardList, FileText, Award, BadgeCheck, GraduationCap, Hammer,
ChevronDown, DoorClosed, CalendarCheck, ChevronDown, DoorClosed, CalendarCheck,
} from 'lucide-react' } from 'lucide-react'
@@ -219,8 +219,7 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
</NavSection> </NavSection>
<NavSection id="account" title="Account"> <NavSection id="account" title="Account">
<NavLink href="/profile" label="My profile" icon={UserCircle} active={isActive('/profile')} onClick={onNav} /> <NavLink href="/me" label="My membership" icon={BadgeCheck} active={isActive('/me')} onClick={onNav} />
<NavLink href="/me" label="My access" icon={BadgeCheck} active={isActive('/me')} onClick={onNav} />
</NavSection> </NavSection>
{showAdminSection && ( {showAdminSection && (
+1 -1
View File
@@ -42,7 +42,7 @@ export function DuesCard({ billing, methods = [] }: { billing: Billing; methods?
} }
return ( 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 className="flex items-start justify-between gap-3 flex-wrap">
<div> <div>
<p className="font-sans text-sm font-medium text-foreground">Membership dues</p> <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', group: 'Account',
items: [ items: [
{ label: 'My profile', href: '/profile' }, { label: 'My membership', href: '/me' },
{ label: 'My access', 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. 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. 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`). 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. 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. 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 | | 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 | | 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 | | 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 | | 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`. 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) ## 2026-05-19 (pass 58): Dues payment options + /me visual pass (DEPLOYED)
> DEPLOY STATE: pass-57 + pass-58 shipped together in commit `ab8c22a` > 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, type PrefMap,
} from '@/lib/notifications-prefs-logic' } from '@/lib/notifications-prefs-logic'
// The caller's own notification history. The notifications RLS SELECT is // The caller's own notification history, plus the in-app inbox read state and
// admin/board/treasurer only (mirrors member_billing), so the member // an unread count. The notifications RLS SELECT is admin/board/treasurer only
// self-view goes through the validated service client, same as getMyBilling. // (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() { export async function getMyNotifications() {
const supabase = await createClient() const supabase = await createClient()
const auth = await requireMember(supabase) const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error } if (!auth.ok) return { error: auth.error }
const { member } = auth const { member } = auth
const { data } = await createAdminClient() const admin = createAdminClient()
const { data } = await admin
.from('notifications') .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('space_id', member.space_id)
.eq('member_id', member.id) .eq('member_id', member.id)
.order('created_at', { ascending: false }) .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 { return {
unreadCount: count ?? 0,
data: (data ?? []).map(r => ({ data: (data ?? []).map(r => ({
id: r.id as string, id: r.id as string,
type: r.type as string, type: r.type as string,
subject: r.subject as string, subject: r.subject as string,
bodyText: (r.body_text as string | null) ?? '',
status: r.status as string, status: r.status as string,
createdAt: r.created_at as string, createdAt: r.created_at as string,
sentAt: (r.sent_at as string | null) ?? null, 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. // The caller's own notification preferences as a category -> enabled map.
// notification_preferences has no client policy (same convention as // notification_preferences has no client policy (same convention as
// notifications / member_billing), so the read goes through the service client // 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: [] Relationships: []
} }
notifications: { 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 } 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; 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; 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: [] Relationships: []
} }
notification_preferences: { notification_preferences: {