import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; import { Novu } from "npm:@novu/api@1.6.0"; 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, } ); } } console.log('Creating Novu subscriber:', { subscriberId, email, firstName }); const subscriber = await novu.subscribers.identify(subscriberId, { email, firstName, lastName, phone, avatar, data, }); const duration = endRequest(tracking); console.log('Subscriber created successfully:', subscriber.data, { requestId: tracking.requestId, duration }); // Add subscriber to "users" topic for global announcements try { console.log('Adding subscriber to "users" topic...', { subscriberId }); await novu.topics.addSubscribers('users', { subscribers: [subscriberId], }); console.log('Successfully added subscriber to "users" topic', { subscriberId }); } catch (topicError: any) { // Non-blocking - log error but don't fail the request console.error('Failed to add subscriber to "users" topic:', topicError.message, { subscriberId }); } 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: any) { const duration = endRequest(tracking); console.error('Error creating Novu subscriber:', 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, } ); } });