diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx index 92a557cae..58717a7c4 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx @@ -4,7 +4,6 @@ import { useEffect } from 'react'; import { useMarkSessionRead } from '@/hooks/useMarkSessionRead'; import { useRecentSessions } from '@/hooks/useRecentSessions'; -import { useSessionPresence } from '@/hooks/useSessionPresence'; import { useTelemetry } from '@/hooks/useTelemetry'; export function SessionReadTracker({ sessionId }: { sessionId: string }) { @@ -12,7 +11,6 @@ export function SessionReadTracker({ sessionId }: { sessionId: string }) { const { capture } = useTelemetry(); useMarkSessionRead(sessionId); - useSessionPresence(sessionId); useEffect(() => { recordVisit(sessionId); diff --git a/apps/web/src/app/api/sessions/[sessionId]/presence/route.test.ts b/apps/web/src/app/api/sessions/[sessionId]/presence/route.test.ts deleted file mode 100644 index fa968cd83..000000000 --- a/apps/web/src/app/api/sessions/[sessionId]/presence/route.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { NextRequest } from 'next/server'; - -const { - authorizeMock, - disconnectSessionPresenceMock, - findAccessibleSessionMock, - refreshSessionPresenceMock, -} = vi.hoisted(() => ({ - authorizeMock: vi.fn(), - disconnectSessionPresenceMock: vi.fn(), - findAccessibleSessionMock: vi.fn(), - refreshSessionPresenceMock: vi.fn(), -})); - -vi.mock('@/lib/server/auth-context', () => ({ authorize: authorizeMock })); -vi.mock('@/lib/server/sessions', () => ({ - findAccessibleSession: findAccessibleSessionMock, -})); -vi.mock('@roomote/redis', () => ({ - disconnectSessionPresence: disconnectSessionPresenceMock, - refreshSessionPresence: refreshSessionPresenceMock, -})); - -import { DELETE, POST } from './route'; - -const SESSION_ID = '6a1f8f1e-0000-4000-8000-000000000006'; -const CLIENT_ID = '6a1f8f1e-0000-4000-8000-000000000007'; -const USER_ID = '6a1f8f1e-0000-4000-8000-000000000008'; - -function request(method: 'POST' | 'DELETE', clientId = CLIENT_ID) { - return new NextRequest( - `http://localhost/api/sessions/${SESSION_ID}/presence`, - { - method, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ clientId }), - }, - ); -} - -const props = { params: Promise.resolve({ sessionId: SESSION_ID }) }; - -describe('/api/sessions/[sessionId]/presence', () => { - beforeEach(() => { - vi.clearAllMocks(); - authorizeMock.mockResolvedValue({ - success: true, - userId: USER_ID, - isAdmin: false, - }); - findAccessibleSessionMock.mockResolvedValue({ id: SESSION_ID }); - refreshSessionPresenceMock.mockResolvedValue({ expiresAt: 31_000 }); - }); - - it('activates presence for the authenticated user and accessible Session', async () => { - const response = await POST(request('POST'), props); - - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ expiresAt: 31_000 }); - expect(refreshSessionPresenceMock).toHaveBeenCalledWith({ - sessionId: SESSION_ID, - userId: USER_ID, - clientId: CLIENT_ID, - }); - }); - - it('disconnects the authenticated tab lease', async () => { - const response = await DELETE(request('DELETE'), props); - - expect(response.status).toBe(204); - expect(disconnectSessionPresenceMock).toHaveBeenCalledWith({ - sessionId: SESSION_ID, - userId: USER_ID, - clientId: CLIENT_ID, - }); - }); - - it('rejects unauthenticated requests', async () => { - authorizeMock.mockResolvedValue({ success: false }); - - const response = await POST(request('POST'), props); - - expect(response.status).toBe(401); - expect(findAccessibleSessionMock).not.toHaveBeenCalled(); - expect(refreshSessionPresenceMock).not.toHaveBeenCalled(); - }); - - it('does not create presence for an inaccessible Session', async () => { - findAccessibleSessionMock.mockResolvedValue(null); - - const response = await POST(request('POST'), props); - - expect(response.status).toBe(404); - expect(refreshSessionPresenceMock).not.toHaveBeenCalled(); - }); - - it('rejects malformed client identifiers', async () => { - const response = await POST(request('POST', 'not-a-uuid'), props); - - expect(response.status).toBe(400); - expect(findAccessibleSessionMock).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/app/api/sessions/[sessionId]/presence/route.ts b/apps/web/src/app/api/sessions/[sessionId]/presence/route.ts deleted file mode 100644 index 3266f399c..000000000 --- a/apps/web/src/app/api/sessions/[sessionId]/presence/route.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server'; -import { z } from 'zod'; - -import { - disconnectSessionPresence, - refreshSessionPresence, -} from '@roomote/redis'; - -import { authorize } from '@/lib/server/auth-context'; -import { findAccessibleSession } from '@/lib/server/sessions'; - -export const runtime = 'nodejs'; - -const paramsSchema = z.object({ sessionId: z.string().uuid() }); -const bodySchema = z.object({ clientId: z.string().uuid() }); - -async function authorizePresenceRequest( - request: NextRequest, - props: { params: Promise<{ sessionId: string }> }, -) { - const auth = await authorize(); - if (!auth.success) { - return { - success: false as const, - response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), - }; - } - - const params = paramsSchema.safeParse(await props.params); - const body = bodySchema.safeParse(await request.json().catch(() => null)); - if (!params.success || !body.success) { - return { - success: false as const, - response: NextResponse.json( - { error: 'Invalid request' }, - { status: 400 }, - ), - }; - } - - const session = await findAccessibleSession(auth, params.data.sessionId); - if (!session) { - return { - success: false as const, - response: NextResponse.json({ error: 'Not Found' }, { status: 404 }), - }; - } - - return { - success: true as const, - auth, - clientId: body.data.clientId, - sessionId: session.id, - }; -} - -export async function POST( - request: NextRequest, - props: { params: Promise<{ sessionId: string }> }, -) { - const context = await authorizePresenceRequest(request, props); - if (!context.success) return context.response; - - const lease = await refreshSessionPresence({ - sessionId: context.sessionId, - userId: context.auth.userId, - clientId: context.clientId, - }); - return NextResponse.json(lease); -} - -export async function DELETE( - request: NextRequest, - props: { params: Promise<{ sessionId: string }> }, -) { - const context = await authorizePresenceRequest(request, props); - if (!context.success) return context.response; - - await disconnectSessionPresence({ - sessionId: context.sessionId, - userId: context.auth.userId, - clientId: context.clientId, - }); - return new NextResponse(null, { status: 204 }); -} diff --git a/apps/web/src/hooks/useSessionPresence.client.test.tsx b/apps/web/src/hooks/useSessionPresence.client.test.tsx deleted file mode 100644 index 68fac26df..000000000 --- a/apps/web/src/hooks/useSessionPresence.client.test.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; - -import { useSessionPresence } from './useSessionPresence'; - -const SESSION_ID = '6a1f8f1e-0000-4000-8000-000000000006'; -const CLIENT_ID = '6a1f8f1e-0000-4000-8000-000000000007'; - -describe('useSessionPresence', () => { - const fetchMock = vi.fn().mockResolvedValue(new Response()); - let focused = true; - let visibilityState: DocumentVisibilityState = 'visible'; - - beforeEach(() => { - vi.useFakeTimers(); - vi.stubGlobal('fetch', fetchMock); - vi.spyOn(crypto, 'randomUUID').mockReturnValue(CLIENT_ID); - vi.spyOn(document, 'hasFocus').mockImplementation(() => focused); - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: () => visibilityState, - }); - focused = true; - visibilityState = 'visible'; - fetchMock.mockClear(); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it('activates immediately and refreshes while visible and focused', () => { - renderHook(() => useSessionPresence(SESSION_ID)); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenLastCalledWith( - `/api/sessions/${SESSION_ID}/presence`, - expect.objectContaining({ method: 'POST' }), - ); - - act(() => vi.advanceTimersByTime(20_000)); - expect(fetchMock).toHaveBeenCalledTimes(3); - }); - - it('disconnects when attention leaves and starts a fresh heartbeat cycle on return', () => { - renderHook(() => useSessionPresence(SESSION_ID)); - - focused = false; - act(() => window.dispatchEvent(new Event('blur'))); - expect(fetchMock).toHaveBeenLastCalledWith( - `/api/sessions/${SESSION_ID}/presence`, - expect.objectContaining({ method: 'DELETE' }), - ); - - act(() => vi.advanceTimersByTime(20_000)); - expect(fetchMock).toHaveBeenCalledTimes(2); - - focused = true; - act(() => window.dispatchEvent(new Event('focus'))); - expect(fetchMock).toHaveBeenLastCalledWith( - `/api/sessions/${SESSION_ID}/presence`, - expect.objectContaining({ method: 'POST' }), - ); - }); - - it('disconnects on page hide and unmount', () => { - const { unmount } = renderHook(() => useSessionPresence(SESSION_ID)); - - act(() => window.dispatchEvent(new Event('pagehide'))); - expect(fetchMock).toHaveBeenCalledTimes(2); - - focused = true; - act(() => window.dispatchEvent(new Event('focus'))); - unmount(); - - expect(fetchMock).toHaveBeenCalledTimes(4); - expect(fetchMock).toHaveBeenLastCalledWith( - `/api/sessions/${SESSION_ID}/presence`, - expect.objectContaining({ method: 'DELETE' }), - ); - }); - - it('restores presence when returning from the back-forward cache', () => { - renderHook(() => useSessionPresence(SESSION_ID)); - - act(() => window.dispatchEvent(new Event('pagehide'))); - expect(fetchMock).toHaveBeenLastCalledWith( - `/api/sessions/${SESSION_ID}/presence`, - expect.objectContaining({ method: 'DELETE' }), - ); - - act(() => window.dispatchEvent(new Event('pageshow'))); - expect(fetchMock).toHaveBeenLastCalledWith( - `/api/sessions/${SESSION_ID}/presence`, - expect.objectContaining({ method: 'POST' }), - ); - - act(() => vi.advanceTimersByTime(10_000)); - expect(fetchMock).toHaveBeenCalledTimes(4); - }); - - it('does not activate from a hidden tab', () => { - visibilityState = 'hidden'; - - renderHook(() => useSessionPresence(SESSION_ID)); - - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/hooks/useSessionPresence.ts b/apps/web/src/hooks/useSessionPresence.ts deleted file mode 100644 index 54258f657..000000000 --- a/apps/web/src/hooks/useSessionPresence.ts +++ /dev/null @@ -1,62 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; - -const HEARTBEAT_INTERVAL_MS = 10_000; - -export function useSessionPresence(sessionId: string) { - useEffect(() => { - const clientId = crypto.randomUUID(); - const url = `/api/sessions/${sessionId}/presence`; - let heartbeatInterval: ReturnType | undefined; - let active = false; - - const send = (method: 'POST' | 'DELETE') => { - void fetch(url, { - method, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ clientId }), - keepalive: true, - }).catch(() => undefined); - }; - const disconnect = () => { - if (!active) return; - active = false; - if (heartbeatInterval) clearInterval(heartbeatInterval); - heartbeatInterval = undefined; - send('DELETE'); - }; - const syncPresence = () => { - const viewing = - document.visibilityState === 'visible' && document.hasFocus(); - if (!viewing) { - disconnect(); - return; - } - if (active) return; - - active = true; - send('POST'); - heartbeatInterval = setInterval( - () => send('POST'), - HEARTBEAT_INTERVAL_MS, - ); - }; - - syncPresence(); - window.addEventListener('focus', syncPresence); - window.addEventListener('pageshow', syncPresence); - window.addEventListener('blur', disconnect); - window.addEventListener('pagehide', disconnect); - document.addEventListener('visibilitychange', syncPresence); - - return () => { - window.removeEventListener('focus', syncPresence); - window.removeEventListener('pageshow', syncPresence); - window.removeEventListener('blur', disconnect); - window.removeEventListener('pagehide', disconnect); - document.removeEventListener('visibilitychange', syncPresence); - disconnect(); - }; - }, [sessionId]); -} diff --git a/packages/redis/src/__tests__/session-presence.test.ts b/packages/redis/src/__tests__/session-presence.test.ts deleted file mode 100644 index 18817d79d..000000000 --- a/packages/redis/src/__tests__/session-presence.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { Redis } from 'ioredis'; - -import { - disconnectSessionPresence, - isSessionUserPresent, - refreshSessionPresence, - SESSION_PRESENCE_LEASE_MS, -} from '../session-presence'; - -class PresenceRedis { - private readonly sets = new Map>(); - - multi() { - const operations: Array<() => void> = []; - const chain = { - zadd: (key: string, score: number, member: string) => { - operations.push(() => this.zadd(key, score, member)); - return chain; - }, - zremrangebyscore: (key: string, min: string, max: number) => { - operations.push(() => this.zremrangebyscore(key, min, max)); - return chain; - }, - pexpire: () => chain, - exec: async () => { - operations.forEach((operation) => operation()); - return []; - }, - }; - return chain; - } - - zadd(key: string, score: number, member: string) { - const set = this.sets.get(key) ?? new Map(); - set.set(member, score); - this.sets.set(key, set); - return 1; - } - - async zrem(key: string, member: string) { - return this.sets.get(key)?.delete(member) ? 1 : 0; - } - - async zremrangebyscore(key: string, _min: string, max: number) { - const set = this.sets.get(key); - if (!set) return 0; - let removed = 0; - for (const [member, score] of set) { - if (score <= max) { - set.delete(member); - removed += 1; - } - } - return removed; - } - - async zcard(key: string) { - return this.sets.get(key)?.size ?? 0; - } -} - -const identity = { sessionId: 'session-1', userId: 'user-1' }; - -describe('Session presence leases', () => { - let redis: Redis; - - beforeEach(() => { - redis = new PresenceRedis() as unknown as Redis; - }); - - it('activates presence and expires it after the lease deadline', async () => { - await refreshSessionPresence( - { ...identity, clientId: 'tab-1' }, - { now: 1_000, redis }, - ); - - await expect( - isSessionUserPresent(identity, { now: 1_000, redis }), - ).resolves.toBe(true); - await expect( - isSessionUserPresent(identity, { - now: 1_000 + SESSION_PRESENCE_LEASE_MS, - redis, - }), - ).resolves.toBe(false); - }); - - it('refreshes a tab lease from the latest heartbeat', async () => { - await refreshSessionPresence( - { ...identity, clientId: 'tab-1' }, - { now: 1_000, redis }, - ); - await refreshSessionPresence( - { ...identity, clientId: 'tab-1' }, - { now: 20_000, redis }, - ); - - await expect( - isSessionUserPresent(identity, { now: 35_000, redis }), - ).resolves.toBe(true); - await expect( - isSessionUserPresent(identity, { now: 50_000, redis }), - ).resolves.toBe(false); - }); - - it('disconnects only the specified tab', async () => { - await refreshSessionPresence( - { ...identity, clientId: 'tab-1' }, - { now: 1_000, redis }, - ); - await refreshSessionPresence( - { ...identity, clientId: 'tab-2' }, - { now: 1_000, redis }, - ); - - await disconnectSessionPresence( - { ...identity, clientId: 'tab-1' }, - { redis }, - ); - await expect( - isSessionUserPresent(identity, { now: 1_000, redis }), - ).resolves.toBe(true); - - await disconnectSessionPresence( - { ...identity, clientId: 'tab-2' }, - { redis }, - ); - await expect( - isSessionUserPresent(identity, { now: 1_000, redis }), - ).resolves.toBe(false); - }); - - it('isolates leases between users and Sessions', async () => { - await refreshSessionPresence( - { ...identity, clientId: 'tab-1' }, - { now: 1_000, redis }, - ); - - await expect( - isSessionUserPresent( - { sessionId: 'session-2', userId: identity.userId }, - { now: 1_000, redis }, - ), - ).resolves.toBe(false); - await expect( - isSessionUserPresent( - { sessionId: identity.sessionId, userId: 'user-2' }, - { now: 1_000, redis }, - ), - ).resolves.toBe(false); - }); -}); diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index 24b63d795..d6548dc54 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -2,12 +2,6 @@ import type { Redis } from 'ioredis'; export type { Redis } from 'ioredis'; export { getRedis } from './client'; -export { - disconnectSessionPresence, - isSessionUserPresent, - refreshSessionPresence, - SESSION_PRESENCE_LEASE_MS, -} from './session-presence'; export const REDIS_KEYS = { MENTIONED_THREADS: 'slack:mentioned_threads', diff --git a/packages/redis/src/session-presence.ts b/packages/redis/src/session-presence.ts deleted file mode 100644 index 38e5bade2..000000000 --- a/packages/redis/src/session-presence.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { Redis } from 'ioredis'; - -import { getRedis } from './client'; - -export const SESSION_PRESENCE_LEASE_MS = 30_000; - -type SessionPresenceIdentity = { - sessionId: string; - userId: string; -}; - -type SessionPresenceLease = SessionPresenceIdentity & { - clientId: string; -}; - -type SessionPresenceOptions = { - now?: number; - redis?: Redis; -}; - -function sessionPresenceKey({ sessionId, userId }: SessionPresenceIdentity) { - return `session:presence:${sessionId}:${userId}`; -} - -/** Refreshes one browser tab's short-lived presence lease. */ -export async function refreshSessionPresence( - lease: SessionPresenceLease, - options: SessionPresenceOptions = {}, -): Promise<{ expiresAt: number }> { - const now = options.now ?? Date.now(); - const expiresAt = now + SESSION_PRESENCE_LEASE_MS; - const redis = options.redis ?? getRedis(); - const key = sessionPresenceKey(lease); - - await redis - .multi() - .zadd(key, expiresAt, lease.clientId) - .zremrangebyscore(key, '-inf', now) - .pexpire(key, SESSION_PRESENCE_LEASE_MS * 2) - .exec(); - - return { expiresAt }; -} - -/** Best-effort immediate release; lease expiry remains the disconnect fallback. */ -export async function disconnectSessionPresence( - lease: SessionPresenceLease, - options: Pick = {}, -): Promise { - const redis = options.redis ?? getRedis(); - await redis.zrem(sessionPresenceKey(lease), lease.clientId); -} - -/** Returns whether the user has any unexpired browser-tab lease for a Session. */ -export async function isSessionUserPresent( - identity: SessionPresenceIdentity, - options: SessionPresenceOptions = {}, -): Promise { - const now = options.now ?? Date.now(); - const redis = options.redis ?? getRedis(); - const key = sessionPresenceKey(identity); - - await redis.zremrangebyscore(key, '-inf', now); - return (await redis.zcard(key)) > 0; -}