288 lines
9.9 KiB
TypeScript
288 lines
9.9 KiB
TypeScript
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<string, any>();
|
|
|
|
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" } }
|
|
);
|
|
}
|
|
});
|