// server/supabase/functions/send-push/index.ts // Expo Push Notification 발송 — 서버에서 push_tokens 조회 후 Expo API 호출. // 호출 예시: // POST /functions/v1/send-push // { user_id: "...", title: "회의록 준비 완료", body: "..." } import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' import { createServiceRoleClient } from '../_shared/quota.ts' interface PushRequest { user_id: string title: string body: string data?: Record } interface ExpoPushMessage { to: string sound?: 'default' | null title: string body: string data?: Record } // @ts-expect-error — Deno 런타임 전역 Deno.serve(async (req: Request) => { const preflight = handleCorsPreflightRequest(req) if (preflight) return preflight if (req.method !== 'POST') { return new Response(JSON.stringify({ error: 'Method not allowed' }), { status: 405, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) } try { // 호출자 인증 (현재 로그인된 user만 본인 또는 같은 팀 유저에게 발송 가능) const caller = await requireUser(req) const body = (await req.json()) as PushRequest if (!body.user_id || !body.title || !body.body) { return new Response( JSON.stringify({ error: 'user_id, title, body가 필요합니다' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } const serviceClient = createServiceRoleClient() // 권한 체크: 자기 자신에게 보내거나 같은 팀 멤버에게만 if (body.user_id !== caller.id) { const { data: sharedTeams } = await serviceClient .from('team_members') .select('team_id') .eq('user_id', caller.id) const callerTeamIds = (sharedTeams ?? []).map((r: { team_id: string }) => r.team_id) if (callerTeamIds.length > 0) { const { data: targetMembership } = await serviceClient .from('team_members') .select('team_id') .eq('user_id', body.user_id) .in('team_id', callerTeamIds) if (!targetMembership || targetMembership.length === 0) { return new Response( JSON.stringify({ error: 'forbidden', message: '같은 팀 멤버가 아닙니다.' }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } } else { return new Response( JSON.stringify({ error: 'forbidden' }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } } // 대상 유저의 push tokens 조회 const { data: tokens, error: tokenErr } = await serviceClient .from('push_tokens') .select('token, platform') .eq('user_id', body.user_id) if (tokenErr) { throw new Error(`토큰 조회 실패: ${tokenErr.message}`) } if (!tokens || tokens.length === 0) { return new Response( JSON.stringify({ sent: 0, message: 'push token 없음' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } // Expo Push API 호출 — 토큰 여러 개 배치로 const messages: ExpoPushMessage[] = tokens.map((t: { token: string }) => ({ to: t.token, sound: 'default', title: body.title, body: body.body, data: body.data ?? {} })) const expoResp = await fetch('https://exp.host/--/api/v2/push/send', { method: 'POST', headers: { Accept: 'application/json', 'Accept-encoding': 'gzip, deflate', 'Content-Type': 'application/json' }, body: JSON.stringify(messages) }) if (!expoResp.ok) { const errText = await expoResp.text() throw new Error(`Expo Push API 실패: ${expoResp.status} ${errText}`) } const result = (await expoResp.json()) as { data: Array<{ status: string; id?: string }> } return new Response( JSON.stringify({ sent: messages.length, results: result.data }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } catch (err) { if (err && typeof err === 'object' && 'status' in err && 'message' in err) { return authErrorResponse(err as AuthError, corsHeaders) } const message = err instanceof Error ? err.message : 'Unknown error' return new Response(JSON.stringify({ error: message }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) } })