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

630 lines
19 KiB
TypeScript

/**
* 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' },
});
}
});