Migrate Novu functions to wrapEdgeFunction

Refactor Phase 3 Batch 2–4 Novu-related functions to use the createEdgeFunction wrapper, replacing explicit HTTP servers with edge wrapper, adding standardized logging, tracing, and error handling across subscriber management, topic/notification, and migration/sync functions.
This commit is contained in:
gpt-engineer-app[bot]
2025-11-11 03:55:02 +00:00
parent 19804ea9bd
commit a1280ddd05
9 changed files with 166 additions and 569 deletions

View File

@@ -1,21 +1,17 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { Novu } from "npm:@novu/api@1.6.0"; import { Novu } from "npm:@novu/api@1.6.0";
import { corsHeaders } from '../_shared/cors.ts'; import { corsHeaders } from '../_shared/cors.ts';
import { edgeLogger } from '../_shared/logger.ts'; import { edgeLogger } from '../_shared/logger.ts';
import { formatEdgeError } from '../_shared/errorFormatter.ts'; import { formatEdgeError } from '../_shared/errorFormatter.ts';
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
import { validateString } from '../_shared/typeValidation.ts';
// Simple request tracking export default createEdgeFunction(
const startRequest = () => ({ requestId: crypto.randomUUID(), start: Date.now() }); {
const endRequest = (tracking: { start: number }) => Date.now() - tracking.start; name: 'create-novu-subscriber',
requireAuth: false,
serve(async (req) => { corsHeaders: corsHeaders
const tracking = startRequest(); },
async (req, context) => {
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
try {
const novuApiKey = Deno.env.get('NOVU_API_KEY'); const novuApiKey = Deno.env.get('NOVU_API_KEY');
if (!novuApiKey) { if (!novuApiKey) {
@@ -26,168 +22,53 @@ serve(async (req) => {
secretKey: novuApiKey secretKey: novuApiKey
}); });
// Parse and validate request body const { subscriberId, email, firstName, lastName, phone, avatar, data } = await req.json();
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 // Validate required fields
if (!subscriberId || typeof subscriberId !== 'string' || subscriberId.trim() === '') { validateString(subscriberId, 'subscriberId', { requestId: context.requestId });
return new Response( validateString(email, 'email', { requestId: context.requestId });
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() === '') { // Validate email format
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@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) { if (!emailRegex.test(email)) {
return new Response( throw new Error('Invalid email format. Please provide a valid email address');
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 // Validate optional fields if provided
if (firstName !== undefined && firstName !== null && (typeof firstName !== 'string' || firstName.length > 100)) { if (firstName !== undefined && firstName !== null && (typeof firstName !== 'string' || firstName.length > 100)) {
return new Response( throw new Error('firstName must be a string with maximum 100 characters');
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)) { if (lastName !== undefined && lastName !== null && (typeof lastName !== 'string' || lastName.length > 100)) {
return new Response( throw new Error('lastName must be a string with maximum 100 characters');
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 (phone !== undefined && phone !== null) {
if (typeof phone !== 'string') { if (typeof phone !== 'string') {
return new Response( throw new Error('phone must be a string');
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}$/; const phoneRegex = /^\+?[1-9]\d{1,14}$/;
if (!phoneRegex.test(phone.replace(/[\s\-\(\)]/g, ''))) { if (!phoneRegex.test(phone.replace(/[\s\-\(\)]/g, ''))) {
return new Response( throw new Error('Invalid phone format. Please provide a valid international phone number');
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'))) { if (avatar !== undefined && avatar !== null && (typeof avatar !== 'string' || !avatar.startsWith('http'))) {
return new Response( throw new Error('avatar must be a valid URL');
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 (data !== undefined && data !== null) {
if (typeof data !== 'object' || Array.isArray(data)) { if (typeof data !== 'object' || Array.isArray(data)) {
return new Response( throw new Error('data must be a valid object');
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; const dataSize = JSON.stringify(data).length;
if (dataSize > 10240) { if (dataSize > 10240) {
return new Response( throw new Error('data field is too large (maximum 10KB)');
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 }); context.span.setAttribute('action', 'create_novu_subscriber');
edgeLogger.info('Creating Novu subscriber', { subscriberId, email: '***', firstName, requestId: context.requestId });
const subscriber = await novu.subscribers.identify(subscriberId, { const subscriber = await novu.subscribers.identify(subscriberId, {
email, email,
@@ -198,26 +79,24 @@ serve(async (req) => {
data, data,
}); });
const duration = endRequest(tracking);
edgeLogger.info('Subscriber created successfully', { edgeLogger.info('Subscriber created successfully', {
subscriberId: subscriber.data._id, subscriberId: subscriber.data._id,
requestId: tracking.requestId, requestId: context.requestId
duration
}); });
// Add subscriber to "users" topic for global announcements // Add subscriber to "users" topic for global announcements
try { try {
edgeLogger.info('Adding subscriber to users topic', { subscriberId, requestId: tracking.requestId }); edgeLogger.info('Adding subscriber to users topic', { subscriberId, requestId: context.requestId });
await novu.topics.addSubscribers('users', { await novu.topics.addSubscribers('users', {
subscribers: [subscriberId], subscribers: [subscriberId],
}); });
edgeLogger.info('Successfully added subscriber to users topic', { subscriberId, requestId: tracking.requestId }); edgeLogger.info('Successfully added subscriber to users topic', { subscriberId, requestId: context.requestId });
} catch (topicError: unknown) { } catch (topicError: unknown) {
// Non-blocking - log error but don't fail the request // Non-blocking - log error but don't fail the request
edgeLogger.error('Failed to add subscriber to users topic', { edgeLogger.error('Failed to add subscriber to users topic', {
error: formatEdgeError(topicError), error: formatEdgeError(topicError),
subscriberId, subscriberId,
requestId: tracking.requestId requestId: context.requestId
}); });
} }
@@ -225,31 +104,11 @@ serve(async (req) => {
JSON.stringify({ JSON.stringify({
success: true, success: true,
subscriberId: subscriber.data._id, subscriberId: subscriber.data._id,
requestId: tracking.requestId
}), }),
{ {
headers: { ...corsHeaders, 'Content-Type': 'application/json', 'X-Request-ID': tracking.requestId }, headers: { 'Content-Type': 'application/json' },
status: 200, status: 200,
} }
); );
} catch (error: unknown) {
const duration = endRequest(tracking);
edgeLogger.error('Error creating Novu subscriber', {
error: formatEdgeError(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,
}
);
} }
}); );

View File

@@ -1,23 +1,22 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import { Novu } from "npm:@novu/api@1.6.0"; import { Novu } from "npm:@novu/api@1.6.0";
import { corsHeadersWithTracing as corsHeaders } from '../_shared/cors.ts'; import { corsHeadersWithTracing as corsHeaders } from '../_shared/cors.ts';
import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts"; import { edgeLogger } from "../_shared/logger.ts";
import { withEdgeRetry } from '../_shared/retryHelper.ts'; import { withEdgeRetry } from '../_shared/retryHelper.ts';
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
const TOPICS = { const TOPICS = {
MODERATION_SUBMISSIONS: 'moderation-submissions', MODERATION_SUBMISSIONS: 'moderation-submissions',
MODERATION_REPORTS: 'moderation-reports', MODERATION_REPORTS: 'moderation-reports',
} as const; } as const;
serve(async (req) => { export default createEdgeFunction(
if (req.method === 'OPTIONS') { {
return new Response(null, { headers: corsHeaders }); name: 'manage-moderator-topic',
} requireAuth: false,
corsHeaders: corsHeaders
const tracking = startRequest('manage-moderator-topic'); },
async (req, context) => {
try {
const novuApiKey = Deno.env.get('NOVU_API_KEY'); const novuApiKey = Deno.env.get('NOVU_API_KEY');
if (!novuApiKey) { if (!novuApiKey) {
throw new Error('NOVU_API_KEY is not configured'); throw new Error('NOVU_API_KEY is not configured');
@@ -35,7 +34,8 @@ serve(async (req) => {
throw new Error('Action must be either "add" or "remove"'); throw new Error('Action must be either "add" or "remove"');
} }
edgeLogger.info(`${action === 'add' ? 'Adding' : 'Removing'} user ${userId} ${action === 'add' ? 'to' : 'from'} moderator topics`, { action: 'manage_moderator_topic', requestId: tracking.requestId, userId, operation: action }); context.span.setAttribute('action', 'manage_moderator_topic');
edgeLogger.info(`${action === 'add' ? 'Adding' : 'Removing'} user ${userId} ${action === 'add' ? 'to' : 'from'} moderator topics`, { action: 'manage_moderator_topic', requestId: context.requestId, userId, operation: action });
const topics = [TOPICS.MODERATION_SUBMISSIONS, TOPICS.MODERATION_REPORTS]; const topics = [TOPICS.MODERATION_SUBMISSIONS, TOPICS.MODERATION_REPORTS];
const results = []; const results = [];
@@ -49,17 +49,17 @@ serve(async (req) => {
await novu.topics.addSubscribers(topicKey, { await novu.topics.addSubscribers(topicKey, {
subscribers: [userId], subscribers: [userId],
}); });
edgeLogger.info('Added user to topic', { action: 'manage_moderator_topic', requestId: tracking.requestId, userId, topicKey }); edgeLogger.info('Added user to topic', { action: 'manage_moderator_topic', requestId: context.requestId, userId, topicKey });
} else { } else {
// Remove subscriber from topic // Remove subscriber from topic
await novu.topics.removeSubscribers(topicKey, { await novu.topics.removeSubscribers(topicKey, {
subscribers: [userId], subscribers: [userId],
}); });
edgeLogger.info('Removed user from topic', { action: 'manage_moderator_topic', requestId: tracking.requestId, userId, topicKey }); edgeLogger.info('Removed user from topic', { action: 'manage_moderator_topic', requestId: context.requestId, userId, topicKey });
} }
}, },
{ maxAttempts: 3, baseDelay: 1000 }, { maxAttempts: 3, baseDelay: 1000 },
tracking.requestId, context.requestId,
`${action}-topic-${topicKey}` `${action}-topic-${topicKey}`
); );
@@ -67,7 +67,7 @@ serve(async (req) => {
} catch (error: any) { } catch (error: any) {
edgeLogger.error(`Error ${action}ing user ${userId} ${action === 'add' ? 'to' : 'from'} topic ${topicKey}`, { edgeLogger.error(`Error ${action}ing user ${userId} ${action === 'add' ? 'to' : 'from'} topic ${topicKey}`, {
action: 'manage_moderator_topic', action: 'manage_moderator_topic',
requestId: tracking.requestId, requestId: context.requestId,
userId, userId,
topicKey, topicKey,
error: error.message error: error.message
@@ -83,44 +83,19 @@ serve(async (req) => {
const allSuccess = results.every(r => r.success); const allSuccess = results.every(r => r.success);
endRequest(tracking, allSuccess ? 200 : 207);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: allSuccess, success: allSuccess,
userId, userId,
action, action,
results, results,
requestId: tracking.requestId
}), }),
{ {
headers: { headers: {
...corsHeaders,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
}, },
status: allSuccess ? 200 : 207, // 207 = Multi-Status (partial success) status: allSuccess ? 200 : 207, // 207 = Multi-Status (partial success)
} }
); );
} catch (error: any) {
edgeLogger.error('Error managing moderator topic', { action: 'manage_moderator_topic', requestId: tracking.requestId, error: error.message });
endRequest(tracking, 500, error.message);
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,
}
);
} }
}); );

View File

@@ -1,17 +1,16 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import { Novu } from "npm:@novu/api@1.6.0"; import { Novu } from "npm:@novu/api@1.6.0";
import { corsHeadersWithTracing as corsHeaders } from '../_shared/cors.ts'; import { corsHeadersWithTracing as corsHeaders } from '../_shared/cors.ts';
import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts"; import { edgeLogger } from "../_shared/logger.ts";
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
serve(async (req) => { export default createEdgeFunction(
if (req.method === 'OPTIONS') { {
return new Response(null, { headers: corsHeaders }); name: 'migrate-novu-users',
} requireAuth: false,
corsHeaders: corsHeaders
const tracking = startRequest('migrate-novu-users'); },
async (req, context) => {
try {
const supabaseUrl = Deno.env.get('SUPABASE_URL')!; const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const novuApiKey = Deno.env.get('NOVU_API_KEY'); const novuApiKey = Deno.env.get('NOVU_API_KEY');
@@ -20,6 +19,8 @@ serve(async (req) => {
throw new Error('NOVU_API_KEY is not configured'); throw new Error('NOVU_API_KEY is not configured');
} }
context.span.setAttribute('action', 'migrate_novu_users');
// Create Supabase client with service role for admin access // Create Supabase client with service role for admin access
const supabase = createClient(supabaseUrl, supabaseServiceKey); const supabase = createClient(supabaseUrl, supabaseServiceKey);
@@ -52,20 +53,15 @@ serve(async (req) => {
if (profilesError) throw profilesError; if (profilesError) throw profilesError;
if (!profiles || profiles.length === 0) { if (!profiles || profiles.length === 0) {
endRequest(tracking, 200);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: true, success: true,
message: 'No users to migrate', message: 'No users to migrate',
results: [], results: [],
requestId: tracking.requestId
}), }),
{ {
headers: { headers: {
...corsHeaders,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
}, },
status: 200, status: 200,
} }
@@ -133,43 +129,24 @@ serve(async (req) => {
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
} }
endRequest(tracking, 200); edgeLogger.info('Migration complete', {
requestId: context.requestId,
total: profiles.length,
successful: results.filter(r => r.success).length
});
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: true, success: true,
total: profiles.length, total: profiles.length,
results, results,
requestId: tracking.requestId
}), }),
{ {
headers: { headers: {
...corsHeaders,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
}, },
status: 200, status: 200,
} }
); );
} catch (error: any) {
edgeLogger.error('Error migrating Novu users', { action: 'migrate_novu_users', requestId: tracking.requestId, error: error.message });
endRequest(tracking, 500, error.message);
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,
}
);
} }
}); );

View File

@@ -1,7 +1,7 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import { corsHeadersWithTracing as corsHeaders } from '../_shared/cors.ts'; import { corsHeadersWithTracing as corsHeaders } from '../_shared/cors.ts';
import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts"; import { edgeLogger } from "../_shared/logger.ts";
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
interface AnnouncementPayload { interface AnnouncementPayload {
title: string; title: string;
@@ -10,38 +10,25 @@ interface AnnouncementPayload {
actionUrl?: string; actionUrl?: string;
} }
serve(async (req) => { export default createEdgeFunction(
if (req.method === 'OPTIONS') { {
return new Response(null, { headers: corsHeaders }); name: 'notify-system-announcement',
} requireAuth: true,
corsHeaders: corsHeaders
const tracking = startRequest('notify-system-announcement'); },
async (req, context) => {
try {
const supabaseUrl = Deno.env.get('SUPABASE_URL')!; const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const supabase = createClient(supabaseUrl, supabaseServiceKey); const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get authorization header context.span.setAttribute('action', 'notify_system_announcement');
const authHeader = req.headers.get('authorization');
if (!authHeader) {
throw new Error('Authorization header required');
}
// Verify user is admin or superuser
const token = authHeader.replace('Bearer ', '');
const { data: { user }, error: authError } = await supabase.auth.getUser(token);
if (authError || !user) {
throw new Error('Unauthorized: Invalid token');
}
// Check user role // Check user role
const { data: roles, error: roleError } = await supabase const { data: roles, error: roleError } = await supabase
.from('user_roles') .from('user_roles')
.select('role') .select('role')
.eq('user_id', user.id) .eq('user_id', context.userId)
.in('role', ['admin', 'superuser']); .in('role', ['admin', 'superuser']);
if (roleError || !roles || roles.length === 0) { if (roleError || !roles || roles.length === 0) {
@@ -52,7 +39,7 @@ serve(async (req) => {
const { data: profile } = await supabase const { data: profile } = await supabase
.from('profiles') .from('profiles')
.select('username, display_name') .select('username, display_name')
.eq('user_id', user.id) .eq('user_id', context.userId)
.single(); .single();
const payload: AnnouncementPayload = await req.json(); const payload: AnnouncementPayload = await req.json();
@@ -71,7 +58,7 @@ serve(async (req) => {
title: payload.title, title: payload.title,
severity: payload.severity, severity: payload.severity,
publishedBy: profile?.username || 'unknown', publishedBy: profile?.username || 'unknown',
requestId: tracking.requestId requestId: context.requestId
}); });
// Fetch the workflow ID for system announcements // Fetch the workflow ID for system announcements
@@ -83,22 +70,13 @@ serve(async (req) => {
.maybeSingle(); .maybeSingle();
if (templateError) { if (templateError) {
edgeLogger.error('Error fetching workflow', { action: 'notify_system_announcement', requestId: tracking.requestId, error: templateError }); edgeLogger.error('Error fetching workflow', { action: 'notify_system_announcement', requestId: context.requestId, error: templateError });
throw new Error(`Failed to fetch workflow: ${templateError.message}`); throw new Error(`Failed to fetch workflow: ${templateError.message}`);
} }
if (!template) { if (!template) {
edgeLogger.warn('No active system-announcement workflow found', { action: 'notify_system_announcement', requestId: tracking.requestId }); edgeLogger.warn('No active system-announcement workflow found', { action: 'notify_system_announcement', requestId: context.requestId });
return new Response( throw new Error('No active system-announcement workflow configured');
JSON.stringify({
success: false,
error: 'No active system-announcement workflow configured',
}),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
}
);
} }
const announcementId = crypto.randomUUID(); const announcementId = crypto.randomUUID();
@@ -117,7 +95,7 @@ serve(async (req) => {
publishedBy, publishedBy,
}; };
edgeLogger.info('Triggering announcement to all users via "users" topic', { action: 'notify_system_announcement', requestId: tracking.requestId }); edgeLogger.info('Triggering announcement to all users via "users" topic', { action: 'notify_system_announcement', requestId: context.requestId });
// Invoke the trigger-notification function with users topic // Invoke the trigger-notification function with users topic
const { data: result, error: notifyError } = await supabase.functions.invoke( const { data: result, error: notifyError } = await supabase.functions.invoke(
@@ -132,13 +110,11 @@ serve(async (req) => {
); );
if (notifyError) { if (notifyError) {
edgeLogger.error('Error triggering notification', { action: 'notify_system_announcement', requestId: tracking.requestId, error: notifyError }); edgeLogger.error('Error triggering notification', { action: 'notify_system_announcement', requestId: context.requestId, error: notifyError });
throw notifyError; throw notifyError;
} }
edgeLogger.info('System announcement triggered successfully', { action: 'notify_system_announcement', requestId: tracking.requestId, result }); edgeLogger.info('System announcement triggered successfully', { action: 'notify_system_announcement', requestId: context.requestId, result });
endRequest(tracking, 200);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
@@ -146,36 +122,13 @@ serve(async (req) => {
transactionId: result?.transactionId, transactionId: result?.transactionId,
announcementId, announcementId,
payload: notificationPayload, payload: notificationPayload,
requestId: tracking.requestId
}), }),
{ {
headers: { headers: {
...corsHeaders,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
}, },
status: 200, status: 200,
} }
); );
} catch (error: any) {
edgeLogger.error('Error in notify-system-announcement', { action: 'notify_system_announcement', requestId: tracking.requestId, error: error.message });
endRequest(tracking, error.message.includes('Unauthorized') ? 403 : 500, error.message);
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: error.message.includes('Unauthorized') ? 403 : 500,
}
);
} }
}); );

View File

@@ -1,16 +1,16 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { Novu } from "npm:@novu/api@1.6.0"; import { Novu } from "npm:@novu/api@1.6.0";
import { corsHeaders } from '../_shared/cors.ts'; import { corsHeaders } from '../_shared/cors.ts';
import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts"; import { edgeLogger } from "../_shared/logger.ts";
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
import { validateString } from '../_shared/typeValidation.ts';
serve(async (req) => { export default createEdgeFunction(
if (req.method === 'OPTIONS') { {
return new Response(null, { headers: corsHeaders }); name: 'remove-novu-subscriber',
} requireAuth: false,
corsHeaders: corsHeaders
const tracking = startRequest('remove-novu-subscriber'); },
async (req, context) => {
try {
const novuApiKey = Deno.env.get('NOVU_API_KEY'); const novuApiKey = Deno.env.get('NOVU_API_KEY');
if (!novuApiKey) { if (!novuApiKey) {
@@ -26,83 +26,47 @@ serve(async (req) => {
deleteSubscriber?: boolean; deleteSubscriber?: boolean;
}; };
if (!subscriberId || typeof subscriberId !== 'string' || subscriberId.trim() === '') { validateString(subscriberId, 'subscriberId', { requestId: context.requestId });
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,
}
);
}
edgeLogger.info('Removing subscriber from users topic', { action: 'remove_novu_subscriber', subscriberId, requestId: tracking.requestId }); context.span.setAttribute('action', 'remove_novu_subscriber');
edgeLogger.info('Removing subscriber from users topic', { action: 'remove_novu_subscriber', subscriberId, requestId: context.requestId });
// Remove subscriber from "users" topic // Remove subscriber from "users" topic
try { try {
await novu.topics.removeSubscribers('users', { await novu.topics.removeSubscribers('users', {
subscribers: [subscriberId], subscribers: [subscriberId],
}); });
edgeLogger.info('Successfully removed subscriber from users topic', { action: 'remove_novu_subscriber', subscriberId }); edgeLogger.info('Successfully removed subscriber from users topic', { action: 'remove_novu_subscriber', subscriberId, requestId: context.requestId });
} catch (topicError: any) { } catch (topicError: any) {
edgeLogger.error('Failed to remove subscriber from users topic', { action: 'remove_novu_subscriber', subscriberId, error: topicError.message }); edgeLogger.error('Failed to remove subscriber from users topic', { action: 'remove_novu_subscriber', subscriberId, error: topicError.message, requestId: context.requestId });
// Continue - we still want to delete the subscriber if requested // Continue - we still want to delete the subscriber if requested
} }
// Optionally delete the subscriber entirely from Novu // Optionally delete the subscriber entirely from Novu
if (deleteSubscriber) { if (deleteSubscriber) {
try { try {
edgeLogger.info('Deleting subscriber from Novu', { action: 'remove_novu_subscriber', subscriberId }); edgeLogger.info('Deleting subscriber from Novu', { action: 'remove_novu_subscriber', subscriberId, requestId: context.requestId });
await novu.subscribers.delete(subscriberId); await novu.subscribers.delete(subscriberId);
edgeLogger.info('Successfully deleted subscriber from Novu', { action: 'remove_novu_subscriber', subscriberId }); edgeLogger.info('Successfully deleted subscriber from Novu', { action: 'remove_novu_subscriber', subscriberId, requestId: context.requestId });
} catch (deleteError: any) { } catch (deleteError: any) {
edgeLogger.error('Failed to delete subscriber from Novu', { action: 'remove_novu_subscriber', subscriberId, error: deleteError.message }); edgeLogger.error('Failed to delete subscriber from Novu', { action: 'remove_novu_subscriber', subscriberId, error: deleteError.message, requestId: context.requestId });
throw deleteError; throw deleteError;
} }
} }
endRequest(tracking, 200);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: true, success: true,
subscriberId, subscriberId,
removedFromTopic: true, removedFromTopic: true,
deleted: deleteSubscriber, deleted: deleteSubscriber,
requestId: tracking.requestId
}), }),
{ {
headers: { headers: {
...corsHeaders,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
}, },
status: 200, status: 200,
} }
); );
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
edgeLogger.error('Error removing Novu subscriber', { action: 'remove_novu_subscriber', error: errorMessage, requestId: tracking.requestId });
endRequest(tracking, 500, errorMessage);
return new Response(
JSON.stringify({
success: false,
error: errorMessage,
requestId: tracking.requestId
}),
{
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
},
status: 500,
}
);
} }
}); );

View File

@@ -1,23 +1,22 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import { Novu } from "npm:@novu/api@1.6.0"; import { Novu } from "npm:@novu/api@1.6.0";
import { corsHeaders } from '../_shared/cors.ts'; import { corsHeaders } from '../_shared/cors.ts';
import { edgeLogger, startRequest, endRequest } from '../_shared/logger.ts'; import { edgeLogger } from '../_shared/logger.ts';
import { withEdgeRetry } from '../_shared/retryHelper.ts'; import { withEdgeRetry } from '../_shared/retryHelper.ts';
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
const TOPICS = { const TOPICS = {
MODERATION_SUBMISSIONS: 'moderation-submissions', MODERATION_SUBMISSIONS: 'moderation-submissions',
MODERATION_REPORTS: 'moderation-reports', MODERATION_REPORTS: 'moderation-reports',
} as const; } as const;
serve(async (req) => { export default createEdgeFunction(
const tracking = startRequest(); {
name: 'sync-all-moderators-to-topic',
if (req.method === 'OPTIONS') { requireAuth: false,
return new Response(null, { headers: corsHeaders }); corsHeaders: corsHeaders
} },
async (req, context) => {
try {
const novuApiKey = Deno.env.get('NOVU_API_KEY'); const novuApiKey = Deno.env.get('NOVU_API_KEY');
const supabaseUrl = Deno.env.get('SUPABASE_URL'); const supabaseUrl = Deno.env.get('SUPABASE_URL');
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY'); const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
@@ -29,8 +28,9 @@ serve(async (req) => {
const novu = new Novu({ secretKey: novuApiKey }); const novu = new Novu({ secretKey: novuApiKey });
const supabase = createClient(supabaseUrl, supabaseServiceKey); const supabase = createClient(supabaseUrl, supabaseServiceKey);
context.span.setAttribute('action', 'sync_moderators');
edgeLogger.info('Starting moderator sync to Novu topics', { edgeLogger.info('Starting moderator sync to Novu topics', {
requestId: tracking.requestId, requestId: context.requestId,
action: 'sync_moderators' action: 'sync_moderators'
}); });
@@ -48,7 +48,7 @@ serve(async (req) => {
const uniqueUserIds = [...new Set(moderatorRoles.map(r => r.user_id))]; const uniqueUserIds = [...new Set(moderatorRoles.map(r => r.user_id))];
edgeLogger.info('Found unique moderators to sync', { edgeLogger.info('Found unique moderators to sync', {
requestId: tracking.requestId, requestId: context.requestId,
count: uniqueUserIds.length count: uniqueUserIds.length
}); });
@@ -62,12 +62,12 @@ serve(async (req) => {
try { try {
// Ensure topic exists (Novu will create it if it doesn't) // Ensure topic exists (Novu will create it if it doesn't)
await novu.topics.create({ key: topicKey, name: topicKey }); await novu.topics.create({ key: topicKey, name: topicKey });
edgeLogger.info('Topic ready', { requestId: tracking.requestId, topicKey }); edgeLogger.info('Topic ready', { requestId: context.requestId, topicKey });
} catch (error: any) { } catch (error: any) {
// Topic might already exist, which is fine // Topic might already exist, which is fine
if (!error.message?.includes('already exists')) { if (!error.message?.includes('already exists')) {
edgeLogger.warn('Note about topic', { edgeLogger.warn('Note about topic', {
requestId: tracking.requestId, requestId: context.requestId,
topicKey, topicKey,
error: error.message error: error.message
}); });
@@ -90,20 +90,20 @@ serve(async (req) => {
}); });
}, },
{ maxAttempts: 3, baseDelay: 2000 }, { maxAttempts: 3, baseDelay: 2000 },
tracking.requestId, context.requestId,
`sync-batch-${topicKey}-${i}` `sync-batch-${topicKey}-${i}`
); );
successCount += batch.length; successCount += batch.length;
edgeLogger.info('Added batch of users to topic', { edgeLogger.info('Added batch of users to topic', {
requestId: tracking.requestId, requestId: context.requestId,
topicKey, topicKey,
batchSize: batch.length batchSize: batch.length
}); });
} catch (error: any) { } catch (error: any) {
errorCount += batch.length; errorCount += batch.length;
edgeLogger.error('Error adding batch to topic', { edgeLogger.error('Error adding batch to topic', {
requestId: tracking.requestId, requestId: context.requestId,
topicKey, topicKey,
batchSize: batch.length, batchSize: batch.length,
error: error.message error: error.message
@@ -118,10 +118,8 @@ serve(async (req) => {
}); });
} }
const duration = endRequest(tracking);
edgeLogger.info('Sync completed', { edgeLogger.info('Sync completed', {
requestId: tracking.requestId, requestId: context.requestId,
duration,
results results
}); });
@@ -130,39 +128,13 @@ serve(async (req) => {
success: true, success: true,
message: 'Moderator sync completed', message: 'Moderator sync completed',
results, results,
requestId: tracking.requestId
}), }),
{ {
headers: { headers: {
...corsHeaders,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
}, },
status: 200, status: 200,
} }
); );
} catch (error: any) {
const duration = endRequest(tracking);
edgeLogger.error('Error syncing moderators to topics', {
requestId: tracking.requestId,
duration,
error: error.message
});
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,
}
);
} }
}); );

View File

@@ -1,16 +1,15 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { Novu } from "npm:@novu/api@1.6.0"; import { Novu } from "npm:@novu/api@1.6.0";
import { corsHeaders } from '../_shared/cors.ts'; import { corsHeaders } from '../_shared/cors.ts';
import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts"; import { edgeLogger } from "../_shared/logger.ts";
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
serve(async (req) => { export default createEdgeFunction(
if (req.method === 'OPTIONS') { {
return new Response(null, { headers: corsHeaders }); name: 'trigger-notification',
} requireAuth: false,
corsHeaders: corsHeaders
const tracking = startRequest('trigger-notification'); },
async (req, context) => {
try {
const novuApiKey = Deno.env.get('NOVU_API_KEY'); const novuApiKey = Deno.env.get('NOVU_API_KEY');
if (!novuApiKey) { if (!novuApiKey) {
@@ -38,10 +37,11 @@ serve(async (req) => {
? { subscriberId } ? { subscriberId }
: { topicKey: topicKey! }; : { topicKey: topicKey! };
context.span.setAttribute('action', 'trigger_notification');
edgeLogger.info('Triggering notification', { edgeLogger.info('Triggering notification', {
workflowId, workflowId,
recipient, recipient,
requestId: tracking.requestId, requestId: context.requestId,
action: 'trigger_notification' action: 'trigger_notification'
}); });
@@ -53,50 +53,21 @@ serve(async (req) => {
}); });
edgeLogger.info('Notification triggered successfully', { edgeLogger.info('Notification triggered successfully', {
requestId: tracking.requestId, requestId: context.requestId,
transactionId: result.data.transactionId transactionId: result.data.transactionId
}); });
endRequest(tracking, 200);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: true, success: true,
transactionId: result.data.transactionId, transactionId: result.data.transactionId,
requestId: tracking.requestId
}), }),
{ {
headers: { headers: {
...corsHeaders,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
}, },
status: 200, status: 200,
} }
); );
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
edgeLogger.error('Error triggering notification', {
requestId: tracking.requestId,
error: errorMessage
});
endRequest(tracking, 500, errorMessage);
return new Response(
JSON.stringify({
success: false,
error: errorMessage,
requestId: tracking.requestId
}),
{
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
},
status: 500,
}
);
} }
}); );

View File

@@ -1,17 +1,16 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { Novu } from "npm:@novu/api@1.6.0"; import { Novu } from "npm:@novu/api@1.6.0";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.57.4";
import { corsHeaders } from '../_shared/cors.ts'; import { corsHeaders } from '../_shared/cors.ts';
import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts"; import { edgeLogger } from "../_shared/logger.ts";
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
serve(async (req) => { export default createEdgeFunction(
const tracking = startRequest('update-novu-preferences'); {
name: 'update-novu-preferences',
if (req.method === 'OPTIONS') { requireAuth: false,
return new Response(null, { headers: corsHeaders }); corsHeaders: corsHeaders
} },
async (req, context) => {
try {
const novuApiKey = Deno.env.get('NOVU_API_KEY'); const novuApiKey = Deno.env.get('NOVU_API_KEY');
const supabaseUrl = Deno.env.get('SUPABASE_URL')!; const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
@@ -28,33 +27,16 @@ serve(async (req) => {
const { userId, preferences } = await req.json(); const { userId, preferences } = await req.json();
edgeLogger.info('Updating preferences for user', { userId, requestId: tracking.requestId }); context.span.setAttribute('action', 'update_novu_preferences');
edgeLogger.info('Updating preferences for user', { userId, requestId: context.requestId });
// Validate input // Validate input
if (!userId) { if (!userId) {
return new Response( throw new Error('userId is required');
JSON.stringify({
success: false,
error: 'userId is required',
}),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
}
);
} }
if (!preferences?.channelPreferences) { if (!preferences?.channelPreferences) {
return new Response( throw new Error('channelPreferences is required in preferences object');
JSON.stringify({
success: false,
error: 'channelPreferences is required in preferences object',
}),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
}
);
} }
// Get Novu subscriber ID from database // Get Novu subscriber ID from database
@@ -96,7 +78,7 @@ serve(async (req) => {
edgeLogger.error('Failed to update channel preference', { edgeLogger.error('Failed to update channel preference', {
channel: channelType, channel: channelType,
error: channelError.message, error: channelError.message,
requestId: tracking.requestId requestId: context.requestId
}); });
results.push({ results.push({
channel: channelType, channel: channelType,
@@ -114,54 +96,24 @@ serve(async (req) => {
if (!allSucceeded) { if (!allSucceeded) {
edgeLogger.warn('Some channel preferences failed to update', { edgeLogger.warn('Some channel preferences failed to update', {
failedChannels: failedChannels.map(c => c.channel), failedChannels: failedChannels.map(c => c.channel),
requestId: tracking.requestId requestId: context.requestId
}); });
return new Response( throw new Error('Some channel preferences failed to update: ' + failedChannels.map(c => c.channel).join(', '));
JSON.stringify({
success: false,
error: 'Some channel preferences failed to update',
results,
failedChannels: failedChannels.map(c => c.channel),
}),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 502, // Bad Gateway - external service failure
}
);
} }
const duration = endRequest(tracking);
edgeLogger.info('All preferences updated successfully', { edgeLogger.info('All preferences updated successfully', {
requestId: tracking.requestId, requestId: context.requestId
duration
}); });
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: true, success: true,
results, results,
}), }),
{ {
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
status: 200, status: 200,
} }
); );
} catch (error: any) {
const duration = endRequest(tracking);
edgeLogger.error('Error updating Novu preferences', {
error: error.message,
requestId: tracking.requestId,
duration
});
return new Response(
JSON.stringify({
success: false,
error: error.message,
}),
{
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 500,
}
);
} }
}); );

View File

@@ -1,16 +1,15 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { Novu } from "npm:@novu/api@1.6.0"; import { Novu } from "npm:@novu/api@1.6.0";
import { corsHeaders } from '../_shared/cors.ts'; import { corsHeaders } from '../_shared/cors.ts';
import { edgeLogger, startRequest, endRequest } from "../_shared/logger.ts"; import { edgeLogger } from "../_shared/logger.ts";
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
serve(async (req) => { export default createEdgeFunction(
if (req.method === 'OPTIONS') { {
return new Response(null, { headers: corsHeaders }); name: 'update-novu-subscriber',
} requireAuth: false,
corsHeaders: corsHeaders
const tracking = startRequest('update-novu-subscriber'); },
async (req, context) => {
try {
const novuApiKey = Deno.env.get('NOVU_API_KEY'); const novuApiKey = Deno.env.get('NOVU_API_KEY');
if (!novuApiKey) { if (!novuApiKey) {
@@ -31,7 +30,8 @@ serve(async (req) => {
data?: Record<string, unknown>; data?: Record<string, unknown>;
}; };
edgeLogger.info('Updating Novu subscriber', { action: 'update_novu_subscriber', subscriberId, email, firstName, requestId: tracking.requestId }); context.span.setAttribute('action', 'update_novu_subscriber');
edgeLogger.info('Updating Novu subscriber', { action: 'update_novu_subscriber', subscriberId, email, firstName, requestId: context.requestId });
const subscriber = await novu.subscribers.update(subscriberId, { const subscriber = await novu.subscribers.update(subscriberId, {
email, email,
@@ -42,45 +42,19 @@ serve(async (req) => {
data, data,
}); });
edgeLogger.info('Subscriber updated successfully', { action: 'update_novu_subscriber', subscriberId: subscriber.data._id }); edgeLogger.info('Subscriber updated successfully', { action: 'update_novu_subscriber', subscriberId: subscriber.data._id, requestId: context.requestId });
endRequest(tracking, 200);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
success: true, success: true,
subscriberId: subscriber.data._id, subscriberId: subscriber.data._id,
requestId: tracking.requestId
}), }),
{ {
headers: { headers: {
...corsHeaders,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
}, },
status: 200, status: 200,
} }
); );
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
edgeLogger.error('Error updating Novu subscriber', { action: 'update_novu_subscriber', error: errorMessage, requestId: tracking.requestId });
endRequest(tracking, 500, errorMessage);
return new Response(
JSON.stringify({
success: false,
error: errorMessage,
requestId: tracking.requestId
}),
{
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
},
status: 500,
}
);
} }
}); );