/** * POST /publish-post * * Features: * - Hashtag extraction and storage * - AI tone analysis * - Rate limiting via trust state * - Beacon support (location-based alerts) */ import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'; import { createSupabaseClient, createServiceClient } from '../_shared/supabase-client.ts'; import { validatePostBody, validateUUID, ValidationError } from '../_shared/validation.ts'; import { trySignR2Url } from '../_shared/r2_signer.ts'; interface PublishPostRequest { category_id?: string | null; body: string; body_format?: 'plain' | 'markdown'; allow_chain?: boolean; chain_parent_id?: string | null; chain_parent_id?: string | null; image_url?: string | null; thumbnail_url?: string | null; ttl_hours?: number | null; user_warned?: boolean; // Beacon fields (optional) is_beacon?: boolean; beacon_type?: 'police' | 'checkpoint' | 'taskForce' | 'hazard' | 'safety' | 'community'; beacon_lat?: number; beacon_long?: number; } interface AnalysisResult { flagged: boolean; category?: 'bigotry' | 'nsfw' | 'violence'; flags: string[]; rejectReason?: string; } const ALLOWED_ORIGIN = Deno.env.get('ALLOWED_ORIGIN') || '*'; const CORS_HEADERS = { 'Access-Control-Allow-Origin': ALLOWED_ORIGIN, 'Access-Control-Allow-Methods': 'POST', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', }; type ModerationStatus = 'approved' | 'flagged_bigotry' | 'flagged_nsfw' | 'rejected'; function getModerationStatus(category?: AnalysisResult['category']): ModerationStatus { switch (category) { case 'bigotry': return 'flagged_bigotry'; case 'nsfw': return 'flagged_nsfw'; case 'violence': return 'rejected'; default: return 'approved'; } } /** * Extract hashtags from post body using regex * Returns array of lowercase tags without the # prefix */ function extractHashtags(body: string): string[] { const hashtagRegex = /#\w+/g; const matches = body.match(hashtagRegex); if (!matches) return []; // Remove # prefix and lowercase all tags return [...new Set(matches.map(tag => tag.substring(1).toLowerCase()))]; } serve(async (req: Request) => { // CORS preflight if (req.method === 'OPTIONS') { return new Response(null, { headers: CORS_HEADERS }); } try { // 1. Validate auth const authHeader = req.headers.get('Authorization'); if (!authHeader) { return new Response(JSON.stringify({ error: 'Missing authorization header' }), { status: 401, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, }); } const supabase = createSupabaseClient(authHeader); const { data: { user }, error: authError, } = await supabase.auth.getUser(); if (authError || !user) { console.error('Auth error details:', { error: authError, errorMessage: authError?.message, errorStatus: authError?.status, user: user, authHeader: authHeader ? 'present' : 'missing', }); return new Response(JSON.stringify({ error: 'Unauthorized', details: authError?.message, }), { status: 401, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, }); } const { data: profileRows, error: profileError } = await supabase .from('profiles') .select('id') .eq('id', user.id) .limit(1); if (profileError) { console.error('Error checking profile:', profileError); return new Response(JSON.stringify({ error: 'Failed to verify profile' }), { status: 500, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, }); } if (!profileRows || profileRows.length === 0) { return new Response( JSON.stringify({ error: 'Profile not found', message: 'Please complete your profile before posting.', }), { status: 400, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } // 2. Parse request const { category_id, body, body_format, allow_chain, chain_parent_id, image_url, thumbnail_url, ttl_hours, user_warned, is_beacon, beacon_type, beacon_lat, beacon_long, } = (await req.json()) as PublishPostRequest; const requestedCategoryId = category_id ?? null; // 3. Validate inputs // For beacons, category_id is ignored and replaced by "Beacon Alerts" internally validatePostBody(body); if (is_beacon !== true && category_id) { validateUUID(category_id, 'category_id'); } if (chain_parent_id) { validateUUID(chain_parent_id, 'chain_parent_id'); } let ttlHours: number | null | undefined = undefined; if (ttl_hours !== undefined && ttl_hours !== null) { const parsedTtl = Number(ttl_hours); if (!Number.isFinite(parsedTtl) || !Number.isInteger(parsedTtl) || parsedTtl < 0) { return new Response( JSON.stringify({ error: 'Validation error', message: 'ttl_hours must be a non-negative integer', }), { status: 400, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } ttlHours = parsedTtl; } // Validate beacon fields if provided if (is_beacon === true) { const latMissing = beacon_lat === undefined || beacon_lat === null || Number.isNaN(beacon_lat); const longMissing = beacon_long === undefined || beacon_long === null || Number.isNaN(beacon_long); if (latMissing || longMissing) { return new Response( JSON.stringify({ error: 'Validation error', message: 'beacon_lat and beacon_long are required for beacon posts', }), { status: 400, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } const validBeaconTypes = ['police', 'checkpoint', 'taskForce', 'hazard', 'safety', 'community']; if (beacon_type && !validBeaconTypes.includes(beacon_type)) { return new Response( JSON.stringify({ error: 'Validation error', message: 'Invalid beacon_type. Must be: police, checkpoint, taskForce, hazard, safety, or community', }), { status: 400, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } } // 4. Check if user can post (rate limiting via trust state) const serviceClient = createServiceClient(); const { data: canPostData, error: canPostError } = await serviceClient.rpc('can_post', { p_user_id: user.id, }); if (canPostError) { console.error('Error checking post eligibility:', canPostError); return new Response(JSON.stringify({ error: 'Failed to check posting eligibility' }), { status: 500, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, }); } if (!canPostData) { const { data: limitData } = await serviceClient.rpc('get_post_rate_limit', { p_user_id: user.id, }); return new Response( JSON.stringify({ error: 'Rate limit reached', message: `You have reached your posting limit for today (${limitData} posts).`, suggestion: 'Take a moment. Your influence grows with patience.', }), { status: 429, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } // 5. Validate chain parent (if any) if (chain_parent_id) { const { data: parentPost, error: parentError } = await supabase .from('posts') .select('id, allow_chain, status') .eq('id', chain_parent_id) .single(); if (parentError || !parentPost) { return new Response( JSON.stringify({ error: 'Chain unavailable', message: 'This post is not available for chaining.', }), { status: 400, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } if (!parentPost.allow_chain || parentPost.status !== 'active') { return new Response( JSON.stringify({ error: 'Chain unavailable', message: 'Chaining has been disabled for this post.', }), { status: 400, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } } // 6. Call tone-check function for AI moderation let analysis: AnalysisResult = { flagged: false, category: undefined, flags: [], rejectReason: undefined, }; try { const supabaseUrl = Deno.env.get('SUPABASE_URL') || ''; const supabaseKey = Deno.env.get('SUPABASE_ANON_KEY') || ''; console.log('Calling tone-check function...'); const moderationResponse = await fetch( `${supabaseUrl}/functions/v1/tone-check`, { method: 'POST', headers: { 'Authorization': `Bearer ${authHeader}`, 'Content-Type': 'application/json', 'apikey': supabaseKey, }, body: JSON.stringify({ text: body, imageUrl: image_url || undefined, }), } ); console.log('tone-check response status:', moderationResponse.status); if (moderationResponse.ok) { const data = await moderationResponse.json(); console.log('tone-check response:', JSON.stringify(data)); const flagged = Boolean(data.flagged); const category = data.category as AnalysisResult['category'] | undefined; analysis = { flagged, category, flags: data.flags || [], rejectReason: data.reason, }; console.log('Analysis result:', JSON.stringify(analysis)); } else { console.error('tone-check failed:', await moderationResponse.text()); } } catch (e) { console.error('Tone check error:', e); // Fail CLOSED: Reject post if moderation is unavailable await serviceClient.rpc('log_audit_event', { p_actor_id: user.id, p_event_type: 'post_rejected_moderation_error', p_payload: { category_id: requestedCategoryId, error: e.toString(), }, }); return new Response( JSON.stringify({ error: 'Moderation unavailable', message: 'Content moderation is temporarily unavailable. Please try again later.', }), { status: 503, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } // 7. Reject hostile or hateful content if (analysis.flagged) { const moderationStatus = getModerationStatus(analysis.category); if (user_warned === true) { const { data: profileRow } = await serviceClient .from('profiles') .select('strikes') .eq('id', user.id) .maybeSingle(); const currentStrikes = typeof profileRow?.strikes === 'number' ? profileRow!.strikes : 0; await serviceClient .from('profiles') .update({ strikes: currentStrikes + 1 }) .eq('id', user.id); } await serviceClient.rpc('log_audit_event', { p_actor_id: user.id, p_event_type: 'post_rejected', p_payload: { category_id: requestedCategoryId, moderation_category: analysis.category, flags: analysis.flags, reason: analysis.rejectReason, moderation_status: moderationStatus, user_warned: user_warned === true, }, }); return new Response( JSON.stringify({ error: 'Content rejected', message: analysis.rejectReason || 'This content was rejected by moderation.', category: analysis.category, moderation_status: moderationStatus, }), { status: 400, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } // 8. Determine post status based on tone const status = 'active'; const moderationStatus: ModerationStatus = 'approved'; const toneLabel = 'neutral'; const cisScore = 0.8; // 9. Extract hashtags from body (skip for beacons - they use description as body) const tags = is_beacon === true ? [] : extractHashtags(body); console.log(`Extracted ${tags.length} tags:`, tags); // 10. Handle beacon category and data let postCategoryId = requestedCategoryId; let beaconData: any = null; if (is_beacon === true) { // Get or create "Beacon Alerts" category for beacons const { data: beaconCategory } = await serviceClient .from('categories') .select('id') .eq('name', 'Beacon Alerts') .single(); if (beaconCategory) { postCategoryId = beaconCategory.id; } else { // Create the beacon category if it doesn't exist const { data: newCategory } = await serviceClient .from('categories') .insert({ name: 'Beacon Alerts', description: 'Community safety and alert posts' }) .select('id') .single(); if (newCategory) { postCategoryId = newCategory.id; } } // Get user's trust score for initial confidence const { data: profile } = await serviceClient .from('profiles') .select('trust_state(harmony_score)') .eq('id', user.id) .single(); const trustScore = profile?.trust_state?.harmony_score ?? 0.5; const initialConfidence = 0.5 + (trustScore * 0.3); // Store beacon data to be included in post beaconData = { is_beacon: true, beacon_type: beacon_type ?? 'community', location: `SRID=4326;POINT(${beacon_long} ${beacon_lat})`, confidence_score: Math.min(1.0, Math.max(0.0, initialConfidence)), is_active_beacon: true, allow_chain: false, // Beacons don't allow chaining }; } // 11. Resolve post expiration let expiresAt: string | null = null; if (ttlHours !== undefined) { if (ttlHours > 0) { expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000).toISOString(); } else { expiresAt = null; } } else { const { data: settingsRow, error: settingsError } = await supabase .from('user_settings') .select('default_post_ttl') .eq('user_id', user.id) .maybeSingle(); if (settingsError) { console.error('Error fetching user settings:', settingsError); } else { const defaultTtl = settingsRow?.default_post_ttl; if (typeof defaultTtl === 'number' && defaultTtl > 0) { expiresAt = new Date(Date.now() + defaultTtl * 60 * 60 * 1000).toISOString(); } } } // 12. Create post with tags and beacon data let postVisibility = 'public'; const { data: privacyRow, error: privacyError } = await supabase .from('profile_privacy_settings') .select('posts_visibility') .eq('user_id', user.id) .maybeSingle(); if (privacyError) { console.error('Error fetching privacy settings:', privacyError); } else if (privacyRow?.posts_visibility) { postVisibility = privacyRow.posts_visibility; } const insertData: any = { author_id: user.id, category_id: postCategoryId ?? null, body, body_format: body_format ?? 'plain', tone_label: toneLabel, cis_score: cisScore, status, moderation_status: moderationStatus, allow_chain: beaconData?.allow_chain ?? (allow_chain ?? true), chain_parent_id: chain_parent_id ?? null, image_url: image_url ?? null, thumbnail_url: thumbnail_url ?? null, tags: tags, expires_at: expiresAt, visibility: postVisibility, }; // Add beacon fields if this is a beacon if (beaconData) { insertData.is_beacon = beaconData.is_beacon; insertData.beacon_type = beaconData.beacon_type; insertData.location = beaconData.location; insertData.confidence_score = beaconData.confidence_score; insertData.is_active_beacon = beaconData.is_active_beacon; } // Use service client for INSERT to bypass RLS issues for private users // The user authentication has already been validated above const { data: post, error: postError } = await serviceClient .from('posts') .insert(insertData) .select() .single(); if (postError) { console.error('Error creating post:', JSON.stringify({ code: postError.code, message: postError.message, details: postError.details, hint: postError.hint, user_id: user.id, })); return new Response(JSON.stringify({ error: 'Failed to create post', details: postError.message }), { status: 500, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, }); } // 13. Log successful post await serviceClient.rpc('log_audit_event', { p_actor_id: user.id, p_event_type: 'post_created', p_payload: { post_id: post.id, category_id: postCategoryId, tone: toneLabel, cis: cisScore, chain_parent_id: chain_parent_id ?? null, tags_count: tags.length, is_beacon: is_beacon ?? false, }, }); // 14. Return post with metadata - FLATTENED STRUCTURE let message = 'Your post is ready.'; if (toneLabel === 'negative' || toneLabel === 'mixed') { message = 'This post may have limited reach based on its tone.'; } // Create a flattened post object that merges post data with location data const flattenedPost: any = { ...post, // Add location fields at the top level for beacons ...(beaconData && { latitude: beacon_lat, longitude: beacon_long, }), }; if (flattenedPost.image_url) { flattenedPost.image_url = await trySignR2Url(flattenedPost.image_url); } if (flattenedPost.thumbnail_url) { flattenedPost.thumbnail_url = await trySignR2Url(flattenedPost.thumbnail_url); } const response: any = { post: flattenedPost, tone_analysis: { tone: toneLabel, cis: cisScore, message, }, tags, }; return new Response( JSON.stringify(response), { status: 201, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } catch (error) { if (error instanceof ValidationError) { return new Response( JSON.stringify({ error: 'Validation error', message: error.message, field: error.field, }), { status: 400, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, } ); } console.error('Unexpected error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { ...CORS_HEADERS, 'Content-Type': 'application/json' }, }); } });