630 lines
19 KiB
TypeScript
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' },
|
|
});
|
|
}
|
|
});
|