Merge pull request #284 from ArnasDon/feat/team-member-presence

feat(team): member presence (online / away / offline) on the roster
This commit is contained in:
Arnas Donauskas 2026-06-22 14:43:27 +03:00 committed by GitHub
commit 1bacaf6372
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 723 additions and 14 deletions

View File

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { AuthProvider, useAuth } from "@/hooks/use-auth"; import { AuthProvider, useAuth } from "@/hooks/use-auth";
import { Sidebar } from "@/components/layout/sidebar"; import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
import { PresenceHeartbeat } from "@/components/presence/presence-heartbeat";
// Auth-gated dashboard shell. Extracted from the layout so the layout // Auth-gated dashboard shell. Extracted from the layout so the layout
// itself can stay a server component and export metadata (noindex) — // itself can stay a server component and export metadata (noindex) —
@ -40,6 +41,9 @@ function DashboardShellInner({ children }: { children: React.ReactNode }) {
return ( return (
<div className="flex h-screen overflow-hidden bg-background"> <div className="flex h-screen overflow-hidden bg-background">
{/* Reports this tab's online/away presence once we know a user is
signed in. Headless — renders nothing. */}
<PresenceHeartbeat />
<Sidebar open={sidebarOpen} onClose={closeSidebar} /> <Sidebar open={sidebarOpen} onClose={closeSidebar} />
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<Header onOpenSidebar={() => setSidebarOpen(true)} /> <Header onOpenSidebar={() => setSidebarOpen(true)} />

View File

@ -3,6 +3,9 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { useAuth } from "@/hooks/use-auth"; 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 { cn } from "@/lib/utils";
import type { import type {
Conversation, Conversation,
@ -163,6 +166,7 @@ export function MessageThread({
onToggleContactPanel, onToggleContactPanel,
}: MessageThreadProps) { }: MessageThreadProps) {
const { user } = useAuth(); const { user } = useAuth();
const { getPresence, getRow, now } = usePresence();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [templateModalOpen, setTemplateModalOpen] = useState(false); const [templateModalOpen, setTemplateModalOpen] = useState(false);
@ -949,6 +953,7 @@ export function MessageThread({
) : ( ) : (
profiles.map((p) => { profiles.map((p) => {
const isSelected = p.user_id === assignedAgentId; const isSelected = p.user_id === assignedAgentId;
const presence = getPresence(p.user_id);
return ( return (
<DropdownMenuItem <DropdownMenuItem
key={p.id} key={p.id}
@ -958,6 +963,15 @@ export function MessageThread({
isSelected ? "text-primary" : "text-popover-foreground" isSelected ? "text-primary" : "text-popover-foreground"
)} )}
> >
<PresenceDot
status={presence}
label={presenceLabel(
presence,
getRow(p.user_id)?.last_seen_at ?? null,
now
)}
className="mr-2"
/>
<span className="flex-1"> <span className="flex-1">
{p.full_name} {p.full_name}
{p.user_id === user?.id ? " (me)" : ""} {p.user_id === user?.id ? " (me)" : ""}

View File

@ -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<PresenceStatus, string> = {
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 (
<span
role={label ? "img" : undefined}
aria-label={label}
title={label}
className={cn(
"inline-block size-2 shrink-0 rounded-full",
PRESENCE_DOT_CLASS[status],
className,
)}
/>
);
}

View File

@ -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<number>(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;
}

View File

@ -33,8 +33,18 @@ import {
UsersRound, UsersRound,
} from 'lucide-react'; } 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 { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { import {
@ -54,7 +64,13 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { RequireRole } from '@/components/auth/require-role'; import { RequireRole } from '@/components/auth/require-role';
import { useAuth } from '@/hooks/use-auth'; import { useAuth } from '@/hooks/use-auth';
import { usePresence } from '@/hooks/use-presence';
import type { AccountRole } from '@/lib/auth/roles'; 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 { InviteMemberDialog } from './invite-member-dialog';
import { SettingsPanelHead } from './settings-panel-head'; import { SettingsPanelHead } from './settings-panel-head';
import { ROLE_META } from './role-meta'; import { ROLE_META } from './role-meta';
@ -110,6 +126,7 @@ function fmtExpiresIn(iso: string): string {
export function MembersTab() { export function MembersTab() {
const { user, canManageMembers } = useAuth(); const { user, canManageMembers } = useAuth();
const { getPresence, getRow, now } = usePresence();
const [members, setMembers] = useState<Member[]>([]); const [members, setMembers] = useState<Member[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]); const [invitations, setInvitations] = useState<Invitation[]>([]);
@ -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 (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<PresenceDot status="online" />
{counts.online} online
</span>
<span className="inline-flex items-center gap-1.5">
<PresenceDot status="away" />
{counts.away} away
</span>
<span className="inline-flex items-center gap-1.5">
<PresenceDot status="offline" />
{counts.offline} offline
</span>
<span className="text-muted-foreground/70">
· {members.length} member{members.length === 1 ? '' : 's'}
</span>
</div>
);
})()}
{/* Roster */} {/* Roster */}
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
@ -286,6 +329,13 @@ export function MembersTab() {
const isSelf = member.user_id === user?.id; const isSelf = member.user_id === user?.id;
const isOwnerRow = member.role === 'owner'; const isOwnerRow = member.role === 'owner';
const isBusy = pendingMemberAction === member.user_id; 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 ( return (
<li <li
@ -298,6 +348,9 @@ export function MembersTab() {
className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:gap-4" className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:gap-4"
> >
<div className="flex min-w-0 flex-1 items-center gap-4"> <div className="flex min-w-0 flex-1 items-center gap-4">
<Tooltip>
<TooltipTrigger
render={
<Avatar className="size-9 shrink-0"> <Avatar className="size-9 shrink-0">
{member.avatar_url ? ( {member.avatar_url ? (
<AvatarImage <AvatarImage
@ -310,7 +363,20 @@ export function MembersTab() {
.charAt(0) .charAt(0)
.toUpperCase()} .toUpperCase()}
</AvatarFallback> </AvatarFallback>
{/* role+label so screen readers announce
presence — the hover tooltip alone isn't
reachable by keyboard/AT on a non-focusable
avatar. */}
<AvatarBadge
role="img"
aria-label={presenceText}
className={PRESENCE_DOT_CLASS[presence]}
/>
</Avatar> </Avatar>
}
/>
<TooltipContent>{presenceText}</TooltipContent>
</Tooltip>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

171
src/hooks/use-presence.ts Normal file
View File

@ -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<string, PresenceRow>;
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<PresenceMap>(() => 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 };
}

87
src/lib/presence.test.ts Normal file
View File

@ -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 });
});
});

121
src/lib/presence.ts Normal file
View File

@ -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;
}

View File

@ -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 $$;