/// import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'; import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts'; const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || 'https://sojorn.net'; const corsHeaders = { 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', }; // ============================================================================ // TONE CHECK (inline version to avoid external call) // ============================================================================ interface ToneResult { tone: 'positive' | 'neutral' | 'mixed' | 'negative' | 'hostile' | 'hate'; cis: number; flags: string[]; reason: string; } function basicModeration(text: string): ToneResult { const lowerText = text.toLowerCase(); const flags: string[] = []; // Slur patterns const slurPatterns = [/\bn+[i1]+g+[aegr]+/i, /\bf+[a4]+g+[s$o0]+t/i, /\br+[e3]+t+[a4]+r+d/i]; for (const pattern of slurPatterns) { if (pattern.test(text)) { return { tone: 'hate', cis: 0.0, flags: ['hate-speech'], reason: 'Hate speech detected.' }; } } // Attack patterns const attackPatterns = [/\b(fuck|screw|damn)\s+(you|u|your|ur)/i, /\b(kill|hurt|attack)\s+(you|yourself)/i]; for (const pattern of attackPatterns) { if (pattern.test(text)) { return { tone: 'hostile', cis: 0.2, flags: ['hostile'], reason: 'Personal attack detected.' }; } } // Positive indicators const positiveWords = ['thank', 'appreciate', 'love', 'support', 'grateful']; if (positiveWords.some(word => lowerText.includes(word))) { return { tone: 'positive', cis: 1.0, flags: [], reason: 'Positive content' }; } return { tone: 'neutral', cis: 0.8, flags: [], reason: 'Content approved' }; } async function checkTone(text: string): Promise { const openAiKey = Deno.env.get('OPEN_AI'); if (openAiKey) { try { const response = await fetch('https://api.openai.com/v1/moderations', { method: 'POST', headers: { 'Authorization': `Bearer ${openAiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ input: text, model: 'text-moderation-latest' }), }); if (response.ok) { const data = await response.json(); const results = data.results?.[0]; if (results) { if (results.flagged) { if (results.categories?.hate) return { tone: 'hate', cis: 0.0, flags: ['hate'], reason: 'Hate speech detected.' }; if (results.categories?.harassment) return { tone: 'hostile', cis: 0.2, flags: ['harassment'], reason: 'Harassment detected.' }; return { tone: 'mixed', cis: 0.5, flags: ['flagged'], reason: 'Content flagged.' }; } // Not flagged - return neutral with CIS based on scores const maxScore = Math.max(results.category_scores?.harassment || 0, results.category_scores?.hate || 0); if (maxScore > 0.5) return { tone: 'mixed', cis: 0.6, flags: [], reason: 'Content approved (caution)' }; return { tone: 'neutral', cis: 0.8, flags: [], reason: 'Content approved' }; } } } catch (error) { console.error('OpenAI moderation error:', error); } } return basicModeration(text); } // ============================================================================ // MAIN HANDLER // ============================================================================ interface RequestBody { action: 'edit' | 'delete' | 'update_privacy' | 'bulk_update_privacy' | 'pin' | 'unpin'; post_id?: string; content?: string; // For edit action visibility?: 'public' | 'followers' | 'private'; } serve(async (req: Request) => { if (req.method === 'OPTIONS') { return new Response('ok', { headers: { ...corsHeaders, 'Access-Control-Allow-Methods': 'POST OPTIONS' } }); } try { const authHeader = req.headers.get('Authorization'); if (!authHeader) { return new Response(JSON.stringify({ error: 'Missing authorization' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } const supabase = createSupabaseClient(authHeader); const serviceClient = createServiceClient(); const { data: { user }, error: authError } = await supabase.auth.getUser(); if (authError || !user) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } const { action, post_id, content, visibility } = await req.json() as RequestBody; if (!action) { return new Response(JSON.stringify({ error: 'Missing required fields' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } const allowedVisibilities = ['public', 'followers', 'private']; if ((action === 'update_privacy' || action === 'bulk_update_privacy') && (!visibility || !allowedVisibilities.includes(visibility))) { return new Response(JSON.stringify({ error: 'Invalid visibility' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } if (action === 'bulk_update_privacy') { const { error: bulkError } = await serviceClient .from('posts') .update({ visibility }) .eq('author_id', user.id); if (bulkError) { console.error('Bulk privacy update error:', bulkError); return new Response(JSON.stringify({ error: 'Failed to update post privacy' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify({ success: true, message: 'Post privacy updated' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } if (!post_id) { return new Response(JSON.stringify({ error: 'Missing post_id' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } // Fetch the post const { data: post, error: postError } = await serviceClient .from('posts') .select('id, author_id, body, tone_label, cis_score, created_at, is_edited') .eq('id', post_id) .single(); if (postError || !post) { return new Response(JSON.stringify({ error: 'Post not found' }), { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } // Verify ownership if (post.author_id !== user.id) { return new Response(JSON.stringify({ error: 'You can only edit or delete your own posts' }), { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } // ======================================================================== // ACTION: PIN // ======================================================================== if (action === 'pin') { const pinnedAt = new Date().toISOString(); const { error: clearError } = await serviceClient .from('posts') .update({ pinned_at: null }) .eq('author_id', user.id); if (clearError) { console.error('Clear pinned post error:', clearError); return new Response(JSON.stringify({ error: 'Failed to pin post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } const { error: pinError } = await serviceClient .from('posts') .update({ pinned_at: pinnedAt }) .eq('id', post_id); if (pinError) { console.error('Pin post error:', pinError); return new Response(JSON.stringify({ error: 'Failed to pin post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify({ success: true, message: 'Post pinned' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } // ======================================================================== // ACTION: UNPIN // ======================================================================== if (action === 'unpin') { const { error: unpinError } = await serviceClient .from('posts') .update({ pinned_at: null }) .eq('id', post_id); if (unpinError) { console.error('Unpin post error:', unpinError); return new Response(JSON.stringify({ error: 'Failed to unpin post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify({ success: true, message: 'Post unpinned' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } // ======================================================================== // ACTION: UPDATE PRIVACY // ======================================================================== if (action === 'update_privacy') { const { error: privacyError } = await serviceClient .from('posts') .update({ visibility }) .eq('id', post_id); if (privacyError) { console.error('Privacy update error:', privacyError); return new Response(JSON.stringify({ error: 'Failed to update post privacy' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify({ success: true, message: 'Post privacy updated' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } // ======================================================================== // ACTION: DELETE (Hard Delete) // ======================================================================== if (action === 'delete') { const { error: deleteError } = await serviceClient .from('posts') .delete() .eq('id', post_id); if (deleteError) { console.error('Delete error:', deleteError); return new Response(JSON.stringify({ error: 'Failed to delete post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify({ success: true, message: 'Post deleted successfully' }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } // ======================================================================== // ACTION: EDIT // ======================================================================== if (action === 'edit') { if (!content || content.trim().length === 0) { return new Response(JSON.stringify({ error: 'Content is required for edits' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } // 1. Time Check: 2-minute window const createdAt = new Date(post.created_at); const now = new Date(); const twoMinutesAgo = new Date(now.getTime() - 2 * 60 * 1000); if (createdAt < twoMinutesAgo) { return new Response(JSON.stringify({ error: 'Edit window expired. Posts can only be edited within 2 minutes of creation.' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } // 2. Check if already edited (one edit only) if (post.is_edited) { return new Response(JSON.stringify({ error: 'Post has already been edited' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } // 3. Moderation Check: Run new content through tone check const moderation = await checkTone(content); if (moderation.tone === 'hate' || moderation.tone === 'hostile') { return new Response(JSON.stringify({ error: 'Edit rejected: Content does not meet community guidelines.', details: moderation.reason }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } // 4. Archive: Save current content to post_versions const { error: archiveError } = await serviceClient .from('post_versions') .insert({ post_id: post_id, content: post.body, tone_label: post.tone_label, cis_score: post.cis_score, }); if (archiveError) { console.error('Archive error:', archiveError); return new Response(JSON.stringify({ error: 'Failed to save edit history' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } // 5. Update: Apply new content const { error: updateError } = await serviceClient .from('posts') .update({ body: content, tone_label: moderation.tone, cis_score: moderation.cis, is_edited: true, edited_at: now.toISOString(), }) .eq('id', post_id); if (updateError) { console.error('Update error:', updateError); return new Response(JSON.stringify({ error: 'Failed to update post' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify({ success: true, message: 'Post updated successfully', moderation: { tone: moderation.tone, cis: moderation.cis } }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } return new Response(JSON.stringify({ error: 'Invalid action' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } catch (error) { console.error('Unexpected error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }); } });