import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; import { Novu } from "npm:@novu/api@1.6.0"; import { edgeLogger } from '../_shared/logger.ts'; const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', }; // Simple request tracking const startRequest = () => ({ requestId: crypto.randomUUID(), start: Date.now() }); const endRequest = (tracking: { start: number }) => Date.now() - tracking.start; serve(async (req) => { const tracking = startRequest(); if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }); } try { const novuApiKey = Deno.env.get('NOVU_API_KEY'); if (!novuApiKey) { throw new Error('NOVU_API_KEY is not configured'); } const novu = new Novu({ secretKey: novuApiKey }); // Parse and validate request body let requestBody; try { requestBody = await req.json(); } catch (parseError) { return new Response( JSON.stringify({ success: false, error: 'Invalid JSON in request body', }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400, } ); } const { subscriberId, email, firstName, lastName, phone, avatar, data } = requestBody; // Validate required fields if (!subscriberId || typeof subscriberId !== 'string' || subscriberId.trim() === '') { return new Response( JSON.stringify({ success: false, error: 'subscriberId is required and must be a non-empty string', }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400, } ); } if (!email || typeof email !== 'string' || email.trim() === '') { return new Response( JSON.stringify({ success: false, error: 'email is required and must be a non-empty string', }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400, } ); } // Validate email format using regex const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { return new Response( JSON.stringify({ success: false, error: 'Invalid email format. Please provide a valid email address', }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400, } ); } // Validate optional fields if provided if (firstName !== undefined && firstName !== null && (typeof firstName !== 'string' || firstName.length > 100)) { return new Response( JSON.stringify({ success: false, error: 'firstName must be a string with maximum 100 characters', }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400, } ); } if (lastName !== undefined && lastName !== null && (typeof lastName !== 'string' || lastName.length > 100)) { return new Response( JSON.stringify({ success: false, error: 'lastName must be a string with maximum 100 characters', }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400, } ); } if (phone !== undefined && phone !== null) { if (typeof phone !== 'string') { return new Response( JSON.stringify({ success: false, error: 'phone must be a string', }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400, } ); } // Validate phone format (basic validation for international numbers) const phoneRegex = /^\+?[1-9]\d{1,14}$/; if (!phoneRegex.test(phone.replace(/[\s\-\(\)]/g, ''))) { return new Response( JSON.stringify({ success: false, error: 'Invalid phone format. Please provide a valid international phone number', }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400, } ); } } if (avatar !== undefined && avatar !== null && (typeof avatar !== 'string' || !avatar.startsWith('http'))) { return new Response( JSON.stringify({ success: false, error: 'avatar must be a valid URL', }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400, } ); } // Validate data field if provided if (data !== undefined && data !== null) { if (typeof data !== 'object' || Array.isArray(data)) { return new Response( JSON.stringify({ success: false, error: 'data must be a valid object', }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400, } ); } // Check data size (limit to 10KB serialized) const dataSize = JSON.stringify(data).length; if (dataSize > 10240) { return new Response( JSON.stringify({ success: false, error: 'data field is too large (maximum 10KB)', }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400, } ); } } edgeLogger.info('Creating Novu subscriber', { subscriberId, email: '***', firstName, requestId: tracking.requestId }); const subscriber = await novu.subscribers.identify(subscriberId, { email, firstName, lastName, phone, avatar, data, }); const duration = endRequest(tracking); edgeLogger.info('Subscriber created successfully', { subscriberId: subscriber.data._id, requestId: tracking.requestId, duration }); // Add subscriber to "users" topic for global announcements try { edgeLogger.info('Adding subscriber to users topic', { subscriberId, requestId: tracking.requestId }); await novu.topics.addSubscribers('users', { subscribers: [subscriberId], }); edgeLogger.info('Successfully added subscriber to users topic', { subscriberId, requestId: tracking.requestId }); } catch (topicError: unknown) { // Non-blocking - log error but don't fail the request edgeLogger.error('Failed to add subscriber to users topic', { error: topicError instanceof Error ? topicError.message : String(topicError), subscriberId, requestId: tracking.requestId }); } return new Response( JSON.stringify({ success: true, subscriberId: subscriber.data._id, requestId: tracking.requestId }), { headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }, status: 200, } ); } catch (error: unknown) { const duration = endRequest(tracking); edgeLogger.error('Error creating Novu subscriber', { error: error instanceof Error ? error.message : String(error), requestId: tracking.requestId, duration }); return new Response( JSON.stringify({ success: false, error: error.message, requestId: tracking.requestId }), { headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }, status: 500, } ); } });