From 054c9e4e4723c2079c623f5852629d6f1eaabfd1 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Mon, 22 Jun 2026 14:37:29 +0300 Subject: [PATCH] feat(team): member presence (online / away / offline) on the roster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a live presence layer so the Settings → Team members roster and the inbox Assign dropdown show who is actively using the dashboard, idle, or gone. Implements #269. How it works - New `member_presence` table + `touch_presence` SECURITY DEFINER RPC (migration 024). Account-scoped SELECT RLS mirrors the read-only roster; the RPC derives account_id server-side, never client-supplied. - The active client heartbeats 'online'/'away' every ~30s; 'offline' is derived by viewers from staleness, so a closed tab resolves to offline without an unreliable unload write. - Pure, unit-tested derivation/formatting in src/lib/presence.ts. - `usePresence` reads the table, subscribes to Realtime (subscribe-then- merge so events landing mid-fetch aren't clobbered), and re-derives offline on a 15s tick. - Roster: avatar badge + tooltip + "N online · N away · N offline" summary, with an aria-label so AT/keyboard users get presence too. Inbox Assign dropdown: matching dot before each teammate. Co-Authored-By: Claude Opus 4.8 --- src/app/(dashboard)/dashboard-shell.tsx | 4 + src/components/inbox/message-thread.tsx | 14 ++ src/components/presence/presence-dot.tsx | 40 ++++ .../presence/presence-heartbeat.tsx | 105 +++++++++++ src/components/settings/members-tab.tsx | 94 ++++++++-- src/hooks/use-presence.ts | 171 ++++++++++++++++++ src/lib/presence.test.ts | 87 +++++++++ src/lib/presence.ts | 121 +++++++++++++ supabase/migrations/024_member_presence.sql | 101 +++++++++++ 9 files changed, 723 insertions(+), 14 deletions(-) create mode 100644 src/components/presence/presence-dot.tsx create mode 100644 src/components/presence/presence-heartbeat.tsx create mode 100644 src/hooks/use-presence.ts create mode 100644 src/lib/presence.test.ts create mode 100644 src/lib/presence.ts create mode 100644 supabase/migrations/024_member_presence.sql diff --git a/src/app/(dashboard)/dashboard-shell.tsx b/src/app/(dashboard)/dashboard-shell.tsx index 434ca6d..06acfde 100644 --- a/src/app/(dashboard)/dashboard-shell.tsx +++ b/src/app/(dashboard)/dashboard-shell.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { AuthProvider, useAuth } from "@/hooks/use-auth"; import { Sidebar } from "@/components/layout/sidebar"; import { Header } from "@/components/layout/header"; +import { PresenceHeartbeat } from "@/components/presence/presence-heartbeat"; // Auth-gated dashboard shell. Extracted from the layout so the layout // itself can stay a server component and export metadata (noindex) — @@ -40,6 +41,9 @@ function DashboardShellInner({ children }: { children: React.ReactNode }) { return (
+ {/* Reports this tab's online/away presence once we know a user is + signed in. Headless — renders nothing. */} +
setSidebarOpen(true)} /> diff --git a/src/components/inbox/message-thread.tsx b/src/components/inbox/message-thread.tsx index 770c2d4..a27f35f 100644 --- a/src/components/inbox/message-thread.tsx +++ b/src/components/inbox/message-thread.tsx @@ -3,6 +3,9 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { createClient } from "@/lib/supabase/client"; import { useAuth } from "@/hooks/use-auth"; +import { usePresence } from "@/hooks/use-presence"; +import { PresenceDot } from "@/components/presence/presence-dot"; +import { presenceLabel } from "@/lib/presence"; import { cn } from "@/lib/utils"; import type { Conversation, @@ -163,6 +166,7 @@ export function MessageThread({ onToggleContactPanel, }: MessageThreadProps) { const { user } = useAuth(); + const { getPresence, getRow, now } = usePresence(); const [loading, setLoading] = useState(false); const scrollRef = useRef(null); const [templateModalOpen, setTemplateModalOpen] = useState(false); @@ -949,6 +953,7 @@ export function MessageThread({ ) : ( profiles.map((p) => { const isSelected = p.user_id === assignedAgentId; + const presence = getPresence(p.user_id); return ( + {p.full_name} {p.user_id === user?.id ? " (me)" : ""} diff --git a/src/components/presence/presence-dot.tsx b/src/components/presence/presence-dot.tsx new file mode 100644 index 0000000..b95f841 --- /dev/null +++ b/src/components/presence/presence-dot.tsx @@ -0,0 +1,40 @@ +import { cn } from "@/lib/utils"; +import type { PresenceStatus } from "@/lib/presence"; + +// Single source of truth for presence colours. Semantic accents +// (emerald / amber / muted), mirroring the role-chip palette already +// used across settings, so they're intentionally not tokenized. +export const PRESENCE_DOT_CLASS: Record = { + online: "bg-emerald-500", + away: "bg-amber-500", + offline: "bg-muted-foreground/50", +}; + +/** + * A small presence dot. Used inline (e.g. the inbox Assign dropdown) + * and, with `asAvatarBadge`-style positioning supplied via className, + * layered onto an avatar. `label` powers the native title/aria for + * the lightweight inline case; the roster wraps it in a real Tooltip. + */ +export function PresenceDot({ + status, + label, + className, +}: { + status: PresenceStatus; + label?: string; + className?: string; +}) { + return ( + + ); +} diff --git a/src/components/presence/presence-heartbeat.tsx b/src/components/presence/presence-heartbeat.tsx new file mode 100644 index 0000000..0461402 --- /dev/null +++ b/src/components/presence/presence-heartbeat.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { createClient } from "@/lib/supabase/client"; +import { useAuth } from "@/hooks/use-auth"; +import { HEARTBEAT_MS, IDLE_AFTER_MS, type StoredPresence } from "@/lib/presence"; + +/** + * PresenceHeartbeat — headless. Mount ONCE per signed-in dashboard tab + * (in the dashboard shell, below the auth gate). Reports this tab's + * presence to the `member_presence` table via the `touch_presence` RPC + * roughly every HEARTBEAT_MS. + * + * The client only ever reports 'online' or 'away': + * - 'away' when the tab is hidden, or no user input for IDLE_AFTER_MS + * - 'online' otherwise + * It keeps heartbeating while away (so the row stays fresh, i.e. not + * offline). When the tab closes the beats simply stop and viewers derive + * 'offline' from staleness — no unreliable unload write needed. + */ +export function PresenceHeartbeat() { + const { accountId } = useAuth(); + + // 0 = "never recorded"; set on mount so we don't read the clock during + // render (impure). Until the effect runs the tab counts as active. + const lastActivityRef = useRef(0); + + useEffect(() => { + // Hold off until the account is known. Beating during the brief + // window on a fresh signup — authed but profile/account row not yet + // created — would make touch_presence raise "No account for caller" + // and log a spurious error. The effect re-runs once accountId lands. + if (!accountId) return; + + const supabase = createClient(); + let cancelled = false; + let lastBeatAt = 0; + lastActivityRef.current = Date.now(); + + const markActive = () => { + lastActivityRef.current = Date.now(); + }; + + const currentStatus = (): StoredPresence => { + if (typeof document !== "undefined" && document.hidden) return "away"; + if (Date.now() - lastActivityRef.current > IDLE_AFTER_MS) return "away"; + return "online"; + }; + + const beat = async () => { + if (cancelled) return; + // Coalesce bursts: a tab refocus fires visibilitychange AND focus + // together, so skip a beat within 1s of the last to avoid two RPCs + // in the same frame. The 30s interval is never affected. + const t = Date.now(); + if (t - lastBeatAt < 1_000) return; + lastBeatAt = t; + const { error } = await supabase.rpc("touch_presence", { + p_status: currentStatus(), + }); + if (error && !cancelled) { + // Non-fatal: presence is best-effort. Log once per failure so a + // misconfigured RPC is visible without spamming. + console.error("[PresenceHeartbeat] touch_presence failed:", error.message); + } + }; + + // Activity listeners. `passive` so we never block scroll/input. + const activityEvents: (keyof DocumentEventMap)[] = [ + "mousemove", + "keydown", + "pointerdown", + "scroll", + ]; + activityEvents.forEach((e) => + document.addEventListener(e, markActive, { passive: true }), + ); + + // Returning to the tab should beat immediately so a member flips + // back to online without a 30s wait. The debounce in beat() absorbs + // the visibilitychange + focus double-fire. + const onReturn = () => { + if (!document.hidden) markActive(); + void beat(); + }; + document.addEventListener("visibilitychange", onReturn); + window.addEventListener("focus", onReturn); + + void beat(); + const interval = setInterval(() => void beat(), HEARTBEAT_MS); + + return () => { + cancelled = true; + clearInterval(interval); + activityEvents.forEach((e) => + document.removeEventListener(e, markActive), + ); + document.removeEventListener("visibilitychange", onReturn); + window.removeEventListener("focus", onReturn); + }; + }, [accountId]); + + return null; +} diff --git a/src/components/settings/members-tab.tsx b/src/components/settings/members-tab.tsx index 16cfa02..840e06f 100644 --- a/src/components/settings/members-tab.tsx +++ b/src/components/settings/members-tab.tsx @@ -33,8 +33,18 @@ import { UsersRound, } from 'lucide-react'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { + Avatar, + AvatarBadge, + AvatarFallback, + AvatarImage, +} from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { @@ -54,7 +64,13 @@ import { } from '@/components/ui/select'; import { RequireRole } from '@/components/auth/require-role'; import { useAuth } from '@/hooks/use-auth'; +import { usePresence } from '@/hooks/use-presence'; import type { AccountRole } from '@/lib/auth/roles'; +import { presenceLabel, summarize } from '@/lib/presence'; +import { + PRESENCE_DOT_CLASS, + PresenceDot, +} from '@/components/presence/presence-dot'; import { InviteMemberDialog } from './invite-member-dialog'; import { SettingsPanelHead } from './settings-panel-head'; import { ROLE_META } from './role-meta'; @@ -110,6 +126,7 @@ function fmtExpiresIn(iso: string): string { export function MembersTab() { const { user, canManageMembers } = useAuth(); + const { getPresence, getRow, now } = usePresence(); const [members, setMembers] = useState([]); const [invitations, setInvitations] = useState([]); @@ -276,6 +293,32 @@ export function MembersTab() { } /> + {/* Live presence summary across the roster. Updates without a + full refresh as heartbeats and the local re-derive tick land. */} + {members.length > 0 && + (() => { + const counts = summarize(members.map((m) => getPresence(m.user_id))); + return ( +
+ + + {counts.online} online + + + + {counts.away} away + + + + {counts.offline} offline + + + · {members.length} member{members.length === 1 ? '' : 's'} + +
+ ); + })()} + {/* Roster */} @@ -286,6 +329,13 @@ export function MembersTab() { const isSelf = member.user_id === user?.id; const isOwnerRow = member.role === 'owner'; const isBusy = pendingMemberAction === member.user_id; + const presence = getPresence(member.user_id); + const presenceRow = getRow(member.user_id); + const presenceText = presenceLabel( + presence, + presenceRow?.last_seen_at ?? null, + now, + ); return (
  • - - {member.avatar_url ? ( - - ) : null} - - {(member.full_name || member.email || 'U') - .charAt(0) - .toUpperCase()} - - + + + {member.avatar_url ? ( + + ) : null} + + {(member.full_name || member.email || 'U') + .charAt(0) + .toUpperCase()} + + {/* role+label so screen readers announce + presence — the hover tooltip alone isn't + reachable by keyboard/AT on a non-focusable + avatar. */} + + + } + /> + {presenceText} +
    diff --git a/src/hooks/use-presence.ts b/src/hooks/use-presence.ts new file mode 100644 index 0000000..4d6c8b0 --- /dev/null +++ b/src/hooks/use-presence.ts @@ -0,0 +1,171 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { RealtimeChannel } from "@supabase/supabase-js"; + +import { createClient } from "@/lib/supabase/client"; +import { useAuth } from "@/hooks/use-auth"; +import { + derivePresence, + type PresenceRow, + type PresenceStatus, + type StoredPresence, +} from "@/lib/presence"; + +// How often the viewer re-derives presence locally. The online→offline +// transition fires NO database event (it's just the clock passing the +// staleness threshold), so without this tick a member who closes their +// tab would appear online forever. ~15s keeps "offline" responsive +// without busy-spinning. +const RE_DERIVE_MS = 15_000; + +type PresenceMap = Map; + +interface UsePresenceResult { + /** Derived status for one member (defaults to offline if unseen). */ + getPresence: (userId: string) => PresenceStatus; + /** Raw row for tooltips ("last seen …"). */ + getRow: (userId: string) => PresenceRow | undefined; + /** + * The clock value the hook is currently deriving against. Pass this + * to `presenceLabel` / `formatLastSeen` so labels stay in lockstep + * with the dots (both advance on the same ~15s re-derive tick). + */ + now: number; +} + +/** + * Live presence for every member of the caller's account. Reads the + * `member_presence` table (RLS-scoped to the account), subscribes to + * Realtime changes, and re-derives "offline" on a local timer. + * + * Account comes from useAuth; pass `enabled: false` to opt a consumer + * out (e.g. while a parent sheet is closed). + */ +export function usePresence(enabled = true): UsePresenceResult { + const { accountId } = useAuth(); + + // Presence rows keyed by user_id, held in immutable state — each + // update replaces the Map so React renders and the derived getters + // recompute. No ref/version dance needed. + const [rows, setRows] = useState(() => new Map()); + + // `now` ticks so derivePresence re-evaluates staleness over time. + const [now, setNow] = useState(() => Date.now()); + + const active = enabled && !!accountId; + + useEffect(() => { + if (!active || !accountId) return; + + const supabase = createClient(); + let cancelled = false; + + const applyRow = (row: { + user_id: string; + status: StoredPresence; + last_seen_at: string; + }) => { + setRows((prev) => { + const next = new Map(prev); + next.set(row.user_id, { + status: row.status, + last_seen_at: row.last_seen_at, + }); + return next; + }); + }; + + // Subscribe FIRST, then snapshot. The snapshot MERGES into whatever + // Realtime has already delivered (keeping the newer last_seen_at) + // rather than replacing the map — so an event that lands while the + // fetch is in flight isn't clobbered by a staler snapshot row. + const channel: RealtimeChannel = supabase + .channel(`presence:${accountId}`) + .on( + "postgres_changes", + { + event: "*", + schema: "public", + table: "member_presence", + filter: `account_id=eq.${accountId}`, + }, + (payload) => { + if (payload.eventType === "DELETE") { + const old = payload.old as { user_id?: string }; + if (!old.user_id) return; + setRows((prev) => { + if (!prev.has(old.user_id!)) return prev; + const next = new Map(prev); + next.delete(old.user_id!); + return next; + }); + return; + } + applyRow( + payload.new as { + user_id: string; + status: StoredPresence; + last_seen_at: string; + }, + ); + }, + ) + .subscribe(); + + supabase + .from("member_presence") + .select("user_id, status, last_seen_at") + .eq("account_id", accountId) + .then(({ data, error }) => { + if (cancelled) return; + if (error) { + console.error("[usePresence] initial fetch error:", error.message); + return; + } + setRows((prev) => { + const next = new Map(prev); + for (const r of data ?? []) { + const userId = r.user_id as string; + const incoming: PresenceRow = { + status: r.status as StoredPresence, + last_seen_at: r.last_seen_at as string, + }; + const existing = next.get(userId); + // A live event that arrived first must win over a staler + // snapshot row. + if ( + !existing || + new Date(incoming.last_seen_at) >= new Date(existing.last_seen_at) + ) { + next.set(userId, incoming); + } + } + return next; + }); + }); + + const tick = setInterval(() => setNow(Date.now()), RE_DERIVE_MS); + + return () => { + cancelled = true; + clearInterval(tick); + supabase.removeChannel(channel); + }; + }, [active, accountId]); + + const getRow = useCallback( + (userId: string): PresenceRow | undefined => rows.get(userId), + [rows], + ); + + const getPresence = useCallback( + (userId: string): PresenceStatus => { + const row = rows.get(userId); + return derivePresence(row?.status, row?.last_seen_at, now); + }, + [rows, now], + ); + + return { getPresence, getRow, now }; +} diff --git a/src/lib/presence.test.ts b/src/lib/presence.test.ts new file mode 100644 index 0000000..2eb979d --- /dev/null +++ b/src/lib/presence.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { + OFFLINE_AFTER_MS, + derivePresence, + formatLastSeen, + presenceLabel, + summarize, +} from "./presence"; + +// Fixed reference clock so every case is deterministic. +const NOW = new Date("2026-06-22T12:00:00.000Z").getTime(); +const ago = (ms: number) => new Date(NOW - ms).toISOString(); + +describe("derivePresence", () => { + it("returns the stored status for a fresh heartbeat", () => { + expect(derivePresence("online", ago(1_000), NOW)).toBe("online"); + expect(derivePresence("away", ago(1_000), NOW)).toBe("away"); + }); + + it("reads as offline when the heartbeat is stale", () => { + expect(derivePresence("online", ago(OFFLINE_AFTER_MS + 1_000), NOW)).toBe( + "offline", + ); + // Stored 'away' goes stale to offline too (tab was closed while idle). + expect(derivePresence("away", ago(OFFLINE_AFTER_MS + 1_000), NOW)).toBe( + "offline", + ); + }); + + it("treats a missing row or timestamp as offline", () => { + expect(derivePresence(undefined, null, NOW)).toBe("offline"); + expect(derivePresence("online", null, NOW)).toBe("offline"); + expect(derivePresence("online", "not-a-date", NOW)).toBe("offline"); + }); + + it("stays online exactly at the threshold and flips just past it", () => { + expect(derivePresence("online", ago(OFFLINE_AFTER_MS), NOW)).toBe("online"); + expect(derivePresence("online", ago(OFFLINE_AFTER_MS + 1), NOW)).toBe( + "offline", + ); + }); +}); + +describe("formatLastSeen", () => { + it("describes recent activity coarsely", () => { + expect(formatLastSeen(ago(10_000), NOW)).toBe("just now"); + expect(formatLastSeen(ago(60_000), NOW)).toBe("1 minute ago"); + expect(formatLastSeen(ago(5 * 60_000), NOW)).toBe("5 minutes ago"); + }); + + it("rolls up into hours and days", () => { + expect(formatLastSeen(ago(60 * 60_000), NOW)).toBe("1 hour ago"); + expect(formatLastSeen(ago(2 * 60 * 60_000), NOW)).toBe("2 hours ago"); + expect(formatLastSeen(ago(24 * 60 * 60_000), NOW)).toBe("1 day ago"); + expect(formatLastSeen(ago(3 * 24 * 60 * 60_000), NOW)).toBe("3 days ago"); + }); + + it("falls back gracefully on missing/invalid input", () => { + expect(formatLastSeen(null, NOW)).toBe("a while ago"); + expect(formatLastSeen("nonsense", NOW)).toBe("a while ago"); + }); +}); + +describe("presenceLabel", () => { + it("labels each state for the tooltip", () => { + expect(presenceLabel("online", ago(1_000), NOW)).toBe( + "Online — active now", + ); + expect(presenceLabel("away", ago(1_000), NOW)).toBe("Away — idle"); + expect(presenceLabel("offline", ago(2 * 60 * 60_000), NOW)).toBe( + "Offline — last seen 2 hours ago", + ); + }); +}); + +describe("summarize", () => { + it("counts each status", () => { + expect( + summarize(["online", "online", "online", "away", "offline"]), + ).toEqual({ online: 3, away: 1, offline: 1 }); + }); + + it("returns zeroes for an empty roster", () => { + expect(summarize([])).toEqual({ online: 0, away: 0, offline: 0 }); + }); +}); diff --git a/src/lib/presence.ts b/src/lib/presence.ts new file mode 100644 index 0000000..925cf85 --- /dev/null +++ b/src/lib/presence.ts @@ -0,0 +1,121 @@ +// ============================================================ +// Presence helpers — pure, unit-testable, no I/O. +// +// Mirrors the `member_presence` table from migration +// 024_member_presence.sql. The DB stores only what the active +// client reports ('online' / 'away'); "offline" is never stored +// — it is derived here from staleness so a closed tab resolves to +// offline without an unload write. +// +// `now` is always passed in (epoch ms) rather than read from the +// clock, so derivation and formatting stay deterministic and +// testable. See presence.test.ts. +// ============================================================ + +/** How often the active client heartbeats its own presence row. */ +export const HEARTBEAT_MS = 30_000; + +/** + * A member whose last heartbeat is older than this is treated as + * offline regardless of its stored status. ~2.5 missed beats, so a + * single dropped heartbeat doesn't flap a member offline. + */ +export const OFFLINE_AFTER_MS = 75_000; + +/** No input / hidden tab for this long flips the client to 'away'. */ +export const IDLE_AFTER_MS = 5 * 60_000; + +/** What the active client reports (and what the DB stores). */ +export type StoredPresence = "online" | "away"; + +/** What a viewer sees — adds the derived 'offline' state. */ +export type PresenceStatus = "online" | "away" | "offline"; + +/** Raw presence row as read from the `member_presence` table. */ +export interface PresenceRow { + status: StoredPresence; + last_seen_at: string; +} + +/** + * Derive the user-facing presence for a member. A missing row, or a + * heartbeat staler than OFFLINE_AFTER_MS, reads as offline; otherwise + * the member's last reported status (online / away) stands. + */ +export function derivePresence( + stored: StoredPresence | undefined, + lastSeenAt: string | null | undefined, + now: number, +): PresenceStatus { + if (!stored || !lastSeenAt) return "offline"; + const last = new Date(lastSeenAt).getTime(); + if (Number.isNaN(last)) return "offline"; + if (now - last > OFFLINE_AFTER_MS) return "offline"; + return stored; +} + +/** + * Relative "last seen" string for tooltips. Coarse on purpose — the + * issue calls for relative time only, never a precise timestamp. + * + * Deliberately separate from `formatRelative` in + * src/lib/automations/trigger-meta.ts: that one reads `Date.now()` + * internally (not injectable) and emits terse chip wording ("2h ago"), + * whereas presence needs an injected `now` — so the dots and labels + * advance in lockstep and the unit tests stay deterministic — plus + * full-sentence wording for the tooltip ("Offline — last seen …"). + */ +export function formatLastSeen( + lastSeenAt: string | null | undefined, + now: number, +): string { + if (!lastSeenAt) return "a while ago"; + const last = new Date(lastSeenAt).getTime(); + if (Number.isNaN(last)) return "a while ago"; + + const diff = Math.max(0, now - last); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins === 1) return "1 minute ago"; + if (mins < 60) return `${mins} minutes ago`; + + const hours = Math.floor(mins / 60); + if (hours === 1) return "1 hour ago"; + if (hours < 24) return `${hours} hours ago`; + + const days = Math.floor(hours / 24); + if (days === 1) return "1 day ago"; + return `${days} days ago`; +} + +/** + * Tooltip / aria label for a presence dot, e.g. + * "Online — active now" + * "Away — idle" + * "Offline — last seen 2 hours ago" + */ +export function presenceLabel( + status: PresenceStatus, + lastSeenAt: string | null | undefined, + now: number, +): string { + switch (status) { + case "online": + return "Online — active now"; + case "away": + return "Away — idle"; + case "offline": + return `Offline — last seen ${formatLastSeen(lastSeenAt, now)}`; + } +} + +/** Roster header summary, e.g. for "3 online · 1 away · 1 offline". */ +export function summarize(statuses: PresenceStatus[]): { + online: number; + away: number; + offline: number; +} { + const counts = { online: 0, away: 0, offline: 0 }; + for (const s of statuses) counts[s] += 1; + return counts; +} diff --git a/supabase/migrations/024_member_presence.sql b/supabase/migrations/024_member_presence.sql new file mode 100644 index 0000000..78fa61f --- /dev/null +++ b/supabase/migrations/024_member_presence.sql @@ -0,0 +1,101 @@ +-- ============================================================ +-- 024_member_presence.sql — team member presence (online / away) +-- +-- Adds a lightweight presence layer so the Team members roster (and +-- the inbox Assign dropdown) can show who is actively using the +-- dashboard, idle, or gone. Implements wacrm#269. +-- +-- Design +-- +-- The active client heartbeats its own row through the +-- `touch_presence` RPC roughly every 30s, storing only 'online' +-- or 'away'. "Offline" is NOT stored — viewers derive it from +-- staleness (`now() - last_seen_at` beyond a threshold), so a +-- closed tab / logout resolves to offline automatically without +-- relying on an unreliable unload write. +-- +-- A dedicated table keeps the high-write heartbeat off the +-- otherwise-stable `profiles` row and scopes Realtime cleanly. +-- +-- Visibility +-- +-- Any account member can read presence for their account — the +-- same visibility as the read-only roster (`is_account_member`). +-- Writes go ONLY through the SECURITY DEFINER RPC, which derives +-- the account from the caller's profile (never client-supplied). +-- +-- Idempotent — safe to run multiple times. +-- ============================================================ + +-- ---- table ------------------------------------------------- +CREATE TABLE IF NOT EXISTS member_presence ( + user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'online' CHECK (status IN ('online', 'away')), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS member_presence_account_idx + ON member_presence(account_id); + +-- ---- RLS --------------------------------------------------- +ALTER TABLE member_presence ENABLE ROW LEVEL SECURITY; + +-- Account members may read every presence row for their account. +-- No client INSERT/UPDATE/DELETE policy exists: all writes flow +-- through touch_presence() below. +DROP POLICY IF EXISTS member_presence_select ON member_presence; +CREATE POLICY member_presence_select ON member_presence FOR SELECT + USING (is_account_member(account_id)); + +-- ---- heartbeat RPC ----------------------------------------- +-- Upserts the caller's presence row. SECURITY DEFINER so it can +-- write despite the absence of a client write policy; the account +-- is resolved from the caller's own profile, so a client can never +-- spoof which account it appears in. +CREATE OR REPLACE FUNCTION public.touch_presence( + p_status TEXT DEFAULT 'online' +) RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_account_id UUID; +BEGIN + IF auth.uid() IS NULL THEN + RAISE EXCEPTION 'Unauthorized' USING ERRCODE = '42501'; + END IF; + + IF p_status NOT IN ('online', 'away') THEN + RAISE EXCEPTION 'Invalid presence status: %', p_status + USING ERRCODE = '22023'; + END IF; + + SELECT account_id INTO v_account_id + FROM profiles + WHERE user_id = auth.uid(); + + IF v_account_id IS NULL THEN + RAISE EXCEPTION 'No account for caller' USING ERRCODE = '22023'; + END IF; + + INSERT INTO member_presence (user_id, account_id, status, last_seen_at) + VALUES (auth.uid(), v_account_id, p_status, now()) + ON CONFLICT (user_id) DO UPDATE + SET status = excluded.status, + last_seen_at = now(), + account_id = excluded.account_id; +END; +$$; + +-- ---- realtime ---------------------------------------------- +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' AND tablename = 'member_presence' + ) THEN + ALTER PUBLICATION supabase_realtime ADD TABLE member_presence; + END IF; +END $$;