sojorn/_legacy/supabase/functions/manage-post/index.ts
2026-02-15 00:33:24 -06:00

319 lines
14 KiB
TypeScript

/// <reference types="https://deno.land/x/deno@v1.28.0/cli/dts/lib.deno.ts" />
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<ToneResult> {
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' } });
}
});