import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; import { createSupabaseClient, createServiceClient } from "../_shared/supabase-client.ts"; import { trySignR2Url } from "../_shared/r2_signer.ts"; interface Profile { id: string; handle: string; display_name: string; avatar_url: string | null; harmony_tier: string | null; } interface SearchUser { id: string; username: string; display_name: string; avatar_url: string | null; harmony_tier: string; } interface SearchTag { tag: string; count: number; } interface SearchPost { id: string; body: string; author_id: string; author_handle: string; author_display_name: string; created_at: string; image_url: string | null; visibility?: string; } function extractHashtags(text: string): string[] { const matches = text.match(/#\w+/g) || []; return [...new Set(matches.map((t) => t.replace("#", "").toLowerCase()))].filter((t) => t.length > 0); } function stripHashtags(text: string): string { return text.replace(/#\w+/g, " ").replace(/\s+/g, " ").trim(); } serve(async (req: Request) => { // 1. Handle CORS Preflight if (req.method === "OPTIONS") { return new Response(null, { headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST", "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", }, }); } try { // 2. Auth & Input Parsing const authHeader = req.headers.get("Authorization"); if (!authHeader) throw new Error("Missing authorization header"); let query: string | null = null; if (req.method === "POST") { try { const body = await req.json(); query = body.query; } catch { /* ignore parsing error */ } } if (!query) { const url = new URL(req.url); query = url.searchParams.get("query"); } // Return empty if no query if (!query || query.trim().length === 0) { return new Response( JSON.stringify({ users: [], tags: [], posts: [] }), { status: 200, headers: { "Content-Type": "application/json" } } ); } const rawQuery = query.trim(); const isHashtagSearch = rawQuery.startsWith("#"); const cleanTag = isHashtagSearch ? rawQuery.replace("#", "").toLowerCase() : ""; const hashtagFilters = extractHashtags(rawQuery); const ftsQuery = stripHashtags(rawQuery).replace(/[|&!]/g, " ").trim(); const hasFts = ftsQuery.length > 0; const safeQuery = rawQuery.toLowerCase().replace(/[,()]/g, ""); console.log("Search query:", rawQuery, "isHashtagSearch:", isHashtagSearch, "cleanTag:", cleanTag); const supabase = createSupabaseClient(authHeader); const serviceClient = createServiceClient(); const { data: { user }, error: authError } = await supabase.auth.getUser(); if (authError || !user) throw new Error("Unauthorized"); // 3. Prepare Exclusion Lists (Blocked Users) const { data: blockedUsers } = await serviceClient .from("blocks") .select("blocked_id") .eq("blocker_id", user.id); const blockedIds = (blockedUsers?.map((b) => b.blocked_id) || []); const excludeIds = [...blockedIds, user.id]; // Exclude self from user search const postExcludeIds = blockedIds; // Allow own posts in search // 4. Parallel Execution (Fastest Approach) // Users + tags first so tag matches can inform post search. const [usersResult, tagsResult] = await Promise.all([ // A. Search Users (async () => { let dbQuery = serviceClient .from("profiles") .select("id, handle, display_name, avatar_url") .or(`handle.ilike.%${safeQuery}%,display_name.ilike.%${safeQuery}%`) .limit(5); if (excludeIds.length > 0) { dbQuery = dbQuery.not("id", "in", `(${excludeIds.join(",")})`); } return await dbQuery; })(), // B. Search Tags (Using the View for performance) (async () => { // NOTE: Ensure you have run the SQL to create 'view_searchable_tags' // If view missing, this returns error, handle gracefully return await serviceClient .from("view_searchable_tags") .select("tag, count") .ilike("tag", `%${isHashtagSearch ? cleanTag : safeQuery}%`) .order("count", { ascending: false }) .limit(5); })(), ]); const matchedTags = (tagsResult.data || []) .map((t: any) => String(t.tag).toLowerCase()) .filter((t: string) => t.length > 0); const tagCandidates = matchedTags.length > 0 ? matchedTags : (isHashtagSearch && cleanTag.length > 0 ? [cleanTag] : []); // C. Search Posts (tag-first for hashtag queries; hybrid for others) const postsResult = await (async () => { const postsMap = new Map(); if (isHashtagSearch) { if (cleanTag.length > 0) { let exactTagQuery = serviceClient .from("posts") .select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)") .contains("tags", [cleanTag]) .order("created_at", { ascending: false }) .limit(20); if (excludeIds.length > 0) { exactTagQuery = exactTagQuery.not("author_id", "in", `(${excludeIds.join(",")})`); } const exactTagResult = await exactTagQuery; (exactTagResult.data || []).forEach((p: any) => postsMap.set(p.id, p)); } if (tagCandidates.length > 0) { let tagQuery = serviceClient .from("posts") .select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)") .overlaps("tags", tagCandidates) .order("created_at", { ascending: false }) .limit(20); if (postExcludeIds.length > 0) { tagQuery = tagQuery.not("author_id", "in", `(${postExcludeIds.join(",")})`); } const tagResult = await tagQuery; (tagResult.data || []).forEach((p: any) => postsMap.set(p.id, p)); } return { data: Array.from(postsMap.values()).slice(0, 20), error: null }; } if (hasFts) { let ftsDbQuery = serviceClient .from("posts") .select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)") .order("created_at", { ascending: false }) .limit(20); if (postExcludeIds.length > 0) { ftsDbQuery = ftsDbQuery.not("author_id", "in", `(${postExcludeIds.join(",")})`); } ftsDbQuery = ftsDbQuery.textSearch("fts", ftsQuery, { type: "websearch", config: "english", }); const ftsResult = await ftsDbQuery; (ftsResult.data || []).forEach((p: any) => postsMap.set(p.id, p)); } if (tagCandidates.length > 0) { let tagOverlapQuery = serviceClient .from("posts") .select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)") .overlaps("tags", tagCandidates) .order("created_at", { ascending: false }) .limit(20); if (postExcludeIds.length > 0) { tagOverlapQuery = tagOverlapQuery.not("author_id", "in", `(${postExcludeIds.join(",")})`); } const tagOverlapResult = await tagOverlapQuery; (tagOverlapResult.data || []).forEach((p: any) => postsMap.set(p.id, p)); } if (hashtagFilters.length > 0) { let tagOverlapQuery = serviceClient .from("posts") .select("id, body, tags, created_at, author_id, image_url, visibility, profiles!posts_author_id_fkey(handle, display_name)") .overlaps("tags", hashtagFilters) .order("created_at", { ascending: false }) .limit(20); if (postExcludeIds.length > 0) { tagOverlapQuery = tagOverlapQuery.not("author_id", "in", `(${postExcludeIds.join(",")})`); } const tagOverlapResult = await tagOverlapQuery; (tagOverlapResult.data || []).forEach((p: any) => postsMap.set(p.id, p)); } return { data: Array.from(postsMap.values()).slice(0, 20), error: null }; })(); // 5. Process Users (Get Harmony Tiers) const profiles = usersResult.data || []; let users: SearchUser[] = []; if (profiles.length > 0) { const { data: trustStates } = await serviceClient .from("trust_state") .select("user_id, tier") .in("user_id", profiles.map(p => p.id)); const trustMap = new Map(trustStates?.map(t => [t.user_id, t.tier]) || []); users = profiles.map((p: any) => ({ id: p.id, username: p.handle, display_name: p.display_name || p.handle, avatar_url: p.avatar_url, harmony_tier: trustMap.get(p.id) || "new", })); } // 6. Process Tags const tags: SearchTag[] = (tagsResult.data || []).map((t: any) => ({ tag: t.tag, count: t.count })); // 7. Process Posts const searchPosts: SearchPost[] = await Promise.all( (postsResult.data || []).map(async (p: any) => ({ id: p.id, body: p.body, author_id: p.author_id, author_handle: p.profiles?.handle || "unknown", author_display_name: p.profiles?.display_name || "Unknown User", created_at: p.created_at, image_url: p.image_url ? await trySignR2Url(p.image_url) : null, visibility: p.visibility, })) ); // 8. Return Result return new Response(JSON.stringify({ users, tags, posts: searchPosts }), { status: 200, headers: { "Content-Type": "application/json" }, }); } catch (error: any) { console.error("Search error:", error); return new Response( JSON.stringify({ error: error.message || "Internal server error" }), { status: 500, headers: { "Content-Type": "application/json" } } ); } });