diff --git a/DB_SCHEMA_MAP.md b/DB_SCHEMA_MAP.md index 7285074..059ba6e 100644 --- a/DB_SCHEMA_MAP.md +++ b/DB_SCHEMA_MAP.md @@ -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 | --- diff --git a/app/(app)/me/me-portal-client.tsx b/app/(app)/me/me-portal-client.tsx index 24d9021..15e70ab 100644 --- a/app/(app)/me/me-portal-client.tsx +++ b/app/(app)/me/me-portal-client.tsx @@ -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({
-

Profile

+ Profile
-

My permissions

+ My permissions {held.length === 0 ? (

You have the standard member access for this space. No extra permissions are granted to your role. @@ -140,18 +142,18 @@ export function MePortalClient({

-

Dues

+ Dues
-

My payments

+ My payments {payments.length === 0 ? (

No payments recorded for you yet.

) : ( -
    +
      {payments.map(p => (
    • @@ -171,13 +173,13 @@ export function MePortalClient({
-

Certifications

+ Certifications {grants.length === 0 ? (

You have no certifications yet. An instructor can award one to you.

) : ( -
    +
      {grants.map(g => { const status = certificationStatus(g) return ( @@ -217,7 +219,7 @@ export function MePortalClient({
      -

      Email preferences

      + Email preferences

      Billing emails (dues receipts, payment alerts) are always sent. @@ -225,46 +227,19 @@ export function MePortalClient({

      -

      Notifications

      - {notifs.length === 0 ? ( -

      - No notifications yet. Dues receipts and payment alerts will show up here. -

      - ) : ( -
        - {notifs.map(n => ( -
      • -
        - {n.subject} -

        - {new Date(n.createdAt).toLocaleString()} - {n.sentAt ? ` · sent ${new Date(n.sentAt).toLocaleDateString()}` : ''} -

        -
        - - {n.status === 'sent' - ? 'Sent' - : n.status === 'failed' - ? 'Failed' - : n.status === 'skipped' - ? 'Muted' - : 'Queued'} - -
      • - ))} -
      - )} + Notifications +
      -

      My classes

      + My classes {classSignups.length === 0 ? (

      You have not signed up for any classes. Browse what’s on at{' '} Classes.

      ) : ( -
        +
          {classSignups.map(cs => { const sess = sessionOf(cs) return ( @@ -302,14 +277,14 @@ export function MePortalClient({
      -

      My equipment reservations

      + My equipment reservations {reservations.length === 0 ? (

      You have no reservations. Browse what you can reserve at{' '} Equipment.

      ) : ( -
        +
          {reservations.map(r => { const eq = equipOf(r) return ( @@ -343,13 +318,13 @@ export function MePortalClient({
      -

      My access cards

      + My access cards {myCards.length === 0 ? (

      No access cards are registered to you. A door manager can add one.

      ) : ( -
        +
          {myCards.map(c => (
        • @@ -370,13 +345,13 @@ export function MePortalClient({
      -

      My recent visits

      + My recent visits {myVisits.length === 0 ? (

      No visits yet. Check in from the dashboard when you are at the space.

      ) : ( -
        +
          {myVisits.map(v => { const st = presenceStatus(v.checked_in_at, v.checked_out_at) return ( diff --git a/app/(app)/me/notification-prefs.tsx b/app/(app)/me/notification-prefs.tsx index 5de3250..df944fa 100644 --- a/app/(app)/me/notification-prefs.tsx +++ b/app/(app)/me/notification-prefs.tsx @@ -29,7 +29,7 @@ export function NotificationPrefs({ initial }: { initial: PrefMap }) { } return ( -
            +
              {CATEGORY_META.map(meta => { const enabled = prefs[meta.category] ?? true return ( diff --git a/app/(app)/me/notifications-inbox.tsx b/app/(app)/me/notifications-inbox.tsx new file mode 100644 index 0000000..8d2a151 --- /dev/null +++ b/app/(app)/me/notifications-inbox.tsx @@ -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(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 ( +

              + No notifications yet. Dues receipts, booking and class updates, and form receipts show up here. +

              + ) + } + + return ( +
              + {unreadCount > 0 && ( +
              + + {unreadCount} unread + + +
              + )} +
                + {items.map(n => { + const unread = !n.readAt + const isOpen = expanded === n.id + return ( +
              • + + {isOpen && n.bodyText && ( +
                +                  {n.bodyText}
                +                
                + )} +
              • + ) + })} +
              +
              + ) +} diff --git a/app/(app)/me/page.tsx b/app/(app)/me/page.tsx index dea3d15..16b4a3d 100644 --- a/app/(app)/me/page.tsx +++ b/app/(app)/me/page.tsx @@ -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} diff --git a/app/(app)/me/profile-form.tsx b/app/(app)/me/profile-form.tsx index 2562a1c..d5cc9ab 100644 --- a/app/(app)/me/profile-form.tsx +++ b/app/(app)/me/profile-form.tsx @@ -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 (
              -
              +
              @@ -138,7 +143,20 @@ export function ProfileForm({ initial }: { initial: ProfileInitial }) {
              Affiliations (conflict-of-interest) + {initial.isPrivileged && !initial.coiLastDisclosed && ( +
              +

              + You hold a privileged role. Please disclose any outside affiliations that could + conflict with space governance. Your disclosure is visible to other members. +

              +
              + )} + {initial.coiLastDisclosed && ( +

              + last disclosed {new Date(initial.coiLastDisclosed).toLocaleDateString()} +

              + )}
              @@ -148,7 +166,7 @@ export function ProfileForm({ initial }: { initial: ProfileInitial }) {
              -
              +

              Login email

              diff --git a/app/(app)/profile/affiliations-form.tsx b/app/(app)/profile/affiliations-form.tsx deleted file mode 100644 index c74779f..0000000 --- a/app/(app)/profile/affiliations-form.tsx +++ /dev/null @@ -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(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 ( -

              -
              - - -

              - Organizations, businesses, or roles outside this space that could intersect with space - decisions. Visible to all members. Submitting stamps your disclosure date. -

              -
              - - {error &&

              {error}

              } - {status === 'saved' && !error && ( -

              Disclosed. Your timestamp is updated.

              - )} - -
              - -
              -
              - ) -} diff --git a/app/(app)/profile/page.tsx b/app/(app)/profile/page.tsx deleted file mode 100644 index e65d126..0000000 --- a/app/(app)/profile/page.tsx +++ /dev/null @@ -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 ( -
              -
              - My profile -
              - -
              -
              -

              - Account -

              -

              {member.email}

              -

              - role: {member.role} · tier: {member.tier} -

              -

              - 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. -

              -
              - -
              -

              - Profile -

              - -
              - -
              -
              -

              - Conflict-of-interest disclosure -

              - {member.coi_last_disclosed_at && ( -

              - last disclosed {new Date(member.coi_last_disclosed_at).toLocaleDateString()} -

              - )} -
              - {isPrivileged && !member.coi_last_disclosed_at && ( -
              -

              - You hold a privileged role ({member.role}). Please disclose any outside affiliations - that could conflict with space governance. Disclosure is visible to other members. -

              -
              - )} - -
              -
              -
              - ) -} diff --git a/app/(app)/profile/profile-form.tsx b/app/(app)/profile/profile-form.tsx deleted file mode 100644 index 71b5a3d..0000000 --- a/app/(app)/profile/profile-form.tsx +++ /dev/null @@ -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(initial.skills) - const [interests, setInterests] = useState(initial.interests) - const [willingTo, setWillingTo] = useState(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 ( -
              -
              -
              - - 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" - /> -
              -
              - - 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" - /> -
              -
              - -
              - - 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" - /> -
              - -
              - - -

              - Examples: woodworking, laser, electronics, accounting, grant writing. -

              -
              - -
              - - -
              - -
              - - -

              - Used by the recruitment view (board only) when looking for members willing to take on a role. -

              -
              - - {error &&

              {error}

              } - {status === 'saved' && !error && ( -

              Saved.

              - )} - -
              - -
              -
              - ) -} diff --git a/app/(app)/recruitment/page.tsx b/app/(app)/recruitment/page.tsx index 86fd8dd..2bfeb38 100644 --- a/app/(app)/recruitment/page.tsx +++ b/app/(app)/recruitment/page.tsx @@ -75,7 +75,7 @@ export default async function RecruitmentPage() { No members have declared willingness or skills yet - Members opt in from their profile page. + Members opt in from their membership page. diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 8a2c96e..a35a0c0 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -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 - - + {showAdminSection && ( diff --git a/components/billing/dues-card.tsx b/components/billing/dues-card.tsx index 293df90..eb488c9 100644 --- a/components/billing/dues-card.tsx +++ b/components/billing/dues-card.tsx @@ -42,7 +42,7 @@ export function DuesCard({ billing, methods = [] }: { billing: Billing; methods? } return ( -
              +

              Membership dues

              diff --git a/components/command-palette.tsx b/components/command-palette.tsx index f37ab55..445491e 100644 --- a/components/command-palette.tsx +++ b/components/command-palette.tsx @@ -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' }, ], }, { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 904acc8..108e7d4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 8e1c21d..cf3232a 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -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`. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 12b9a76..cb6158f 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -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` diff --git a/integration/auth-embed.test.ts b/integration/auth-embed.test.ts new file mode 100644 index 0000000..128d6a4 --- /dev/null +++ b/integration/auth-embed.test.ts @@ -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() + }) +}) diff --git a/lib/actions/notifications.ts b/lib/actions/notifications.ts index b0a60a5..6b6e1ba 100644 --- a/lib/actions/notifications.ts +++ b/lib/actions/notifications.ts @@ -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 diff --git a/scripts/051_notification_read_state.sql b/scripts/051_notification_read_state.sql new file mode 100644 index 0000000..4e9f0df --- /dev/null +++ b/scripts/051_notification_read_state.sql @@ -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; diff --git a/types/database.ts b/types/database.ts index 07a1278..27f3841 100644 --- a/types/database.ts +++ b/types/database.ts @@ -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: {