import type { APIRoute } from 'astro'; import { getUserIdFromToken, users } from './auth'; import { groups } from './groups'; /* ── In-memory messages store ──────────────────────────────── */ type Message = { id: string; groupId: string; senderId: string; senderName: string; text: string; ts: number; }; const messages: Message[] = []; // Seed messages let _seedId = 0; function seedMsg(groupId: string, senderId: string, text: string, minutesAgo: number) { const u = users.get(senderId); messages.push({ id: 's' + (++_seedId), groupId, senderId, senderName: u?.name || senderId, text, ts: Date.now() - minutesAgo * 60000, }); } seedMsg('family', 'mikyung', 'Did everyone eat?', 120); seedMsg('family', 'junwon', 'Yes, had bibimbap', 115); seedMsg('family', 'soojin', 'I had pasta!', 110); seedMsg('family', 'sungho', 'Good. Stay healthy everyone.', 105); seedMsg('family', 'mikyung', 'Junwon, call me when you have time', 60); seedMsg('family', 'junwon', 'Will do!', 55); seedMsg('parents', 'sungho', 'Have you checked the heating?', 90); seedMsg('parents', 'mikyung', 'Yes, it is working fine now.', 85); seedMsg('parents', 'sungho', 'Good. The bill was high last month.', 80); seedMsg('siblings', 'soojin', 'Are you coming home for Chuseok?', 200); seedMsg('siblings', 'junwon', 'Trying to! Need to check flights', 195); seedMsg('siblings', 'soojin', 'Let me know, I will pick you up', 190); seedMsg('siblings', 'junwon', 'Thanks!', 185); /* ── SSE pub/sub ───────────────────────────────────────────── */ type SSEWriter = { controller: ReadableStreamDefaultController; userId: string; groupId: string; }; const sseClients: Set = new Set(); function broadcast(groupId: string, event: string, data: any, excludeUserId?: string) { const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; const encoder = new TextEncoder(); for (const client of sseClients) { if (client.groupId === groupId && client.userId !== excludeUserId) { try { client.controller.enqueue(encoder.encode(payload)); } catch { sseClients.delete(client); } } } } // Broadcast to a specific user in a group (for WebRTC signaling) function sendToUser(groupId: string, targetUserId: string, event: string, data: any) { const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; const encoder = new TextEncoder(); for (const client of sseClients) { if (client.groupId === groupId && client.userId === targetUserId) { try { client.controller.enqueue(encoder.encode(payload)); } catch { sseClients.delete(client); } } } } /* ── GET /family/api/messages ──────────────────────────────── */ export const GET: APIRoute = async ({ url }) => { const token = url.searchParams.get('token'); const groupId = url.searchParams.get('group'); const stream = url.searchParams.get('stream'); const userId = getUserIdFromToken(token); if (!userId) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } if (!groupId) { return new Response(JSON.stringify({ error: 'group is required' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } /* ── SSE stream mode ─────────────────────────────────────── */ if (stream === 'true') { let writer: SSEWriter; const readable = new ReadableStream({ start(controller) { writer = { controller, userId, groupId }; sseClients.add(writer); // Send a ping so the client knows it connected const encoder = new TextEncoder(); controller.enqueue(encoder.encode(`event: connected\ndata: ${JSON.stringify({ userId })}\n\n`)); }, cancel() { sseClients.delete(writer); }, }); return new Response(readable, { status: 200, headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', }, }); } /* ── Normal GET — return message history ─────────────────── */ const groupMessages = messages .filter((m) => m.groupId === groupId) .map((m) => ({ id: m.id, senderId: m.senderId, senderName: m.senderName, text: m.text, ts: m.ts })); return new Response(JSON.stringify(groupMessages), { status: 200, headers: { 'Content-Type': 'application/json' }, }); }; /* ── POST /family/api/messages ─────────────────────────────── */ export const POST: APIRoute = async ({ request }) => { const body = await request.json(); const { token, groupId, text, type, targetUserId, sdp, candidate } = body; const userId = getUserIdFromToken(token); if (!userId) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } /* ── WebRTC signaling messages ───────────────────────────── */ if (type === 'call-start') { broadcast(groupId, 'call-incoming', { fromUserId: userId, groupId }, userId); return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } }); } if (type === 'call-join') { broadcast(groupId, 'call-peer-joined', { userId, groupId }, userId); return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } }); } if (type === 'call-leave') { broadcast(groupId, 'call-peer-left', { userId, groupId }, userId); return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } }); } if (type === 'offer' && targetUserId && sdp) { sendToUser(groupId, targetUserId, 'offer', { fromUserId: userId, sdp, groupId }); return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } }); } if (type === 'answer' && targetUserId && sdp) { sendToUser(groupId, targetUserId, 'answer', { fromUserId: userId, sdp, groupId }); return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } }); } if (type === 'ice-candidate' && targetUserId && candidate) { sendToUser(groupId, targetUserId, 'ice-candidate', { fromUserId: userId, candidate, groupId }); return new Response(JSON.stringify({ ok: true }), { headers: { 'Content-Type': 'application/json' } }); } /* ── Regular chat message ────────────────────────────────── */ if (!groupId || !text) { return new Response(JSON.stringify({ error: 'groupId and text are required' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } const group = groups.get(groupId); if (!group || !group.members.has(userId)) { return new Response(JSON.stringify({ error: 'Not a member of this group' }), { status: 403, headers: { 'Content-Type': 'application/json' }, }); } const user = users.get(userId); const msg: Message = { id: 'm' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5), groupId, senderId: userId, senderName: user?.name || userId, text, ts: Date.now(), }; messages.push(msg); // Broadcast to all SSE clients in the group broadcast(groupId, 'message', { id: msg.id, senderId: msg.senderId, senderName: msg.senderName, text: msg.text, ts: msg.ts, }); return new Response(JSON.stringify({ ok: true, message: msg }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); };