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:
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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’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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user