Spaces:
Running
Running
| import { supabaseAdmin } from "../config/supabase"; | |
| import type { ProfileAnswer } from "../types/domain"; | |
| import { HttpError } from "../utils/httpError"; | |
| import { getQuestionsDocument, isKnownQuestionId } from "../utils/questions"; | |
| import { createEmbedding } from "./embeddingService"; | |
| import { summarizeProfile } from "./openClawService"; | |
| interface UpsertProfileInput { | |
| displayName: string; | |
| age: number; | |
| location?: string; | |
| gender?: string; | |
| interestedIn?: string[]; | |
| } | |
| function answerText(answer: ProfileAnswer): string { | |
| const raw = Array.isArray(answer.answer) ? answer.answer.join(", ") : answer.answer; | |
| return [raw, answer.followup].filter(Boolean).join(" "); | |
| } | |
| function normalizeSignal(value: string): string { | |
| return value | |
| .toLowerCase() | |
| .replace(/[^a-z0-9\s-]/g, " ") | |
| .replace(/\s+/g, " ") | |
| .trim() | |
| .slice(0, 80); | |
| } | |
| function collectSignals(answers: Record<string, ProfileAnswer>, tag: "disgust" | "captivating_traits"): string[] { | |
| const questions = getQuestionsDocument().questions.filter((question) => question.tags.includes(tag)); | |
| const signals = new Set<string>(); | |
| for (const question of questions) { | |
| const answer = answers[question.id]; | |
| if (!answer) { | |
| continue; | |
| } | |
| const values = Array.isArray(answer.answer) | |
| ? answer.answer | |
| : answer.answer.split(/[.;,\n]/).map((item) => item.trim()); | |
| for (const value of values) { | |
| const normalized = normalizeSignal(value); | |
| if (normalized.length >= 3) { | |
| signals.add(normalized); | |
| } | |
| } | |
| } | |
| return [...signals].slice(0, 32); | |
| } | |
| function profileTextForEmbedding(answers: Record<string, ProfileAnswer>, narrativeSummary: string): string { | |
| const questions = getQuestionsDocument().questions; | |
| const lines = questions | |
| .map((question) => { | |
| const answer = answers[question.id]; | |
| return answer ? `${question.prompt}\n${answerText(answer)}` : ""; | |
| }) | |
| .filter(Boolean); | |
| return [narrativeSummary, ...lines].join("\n\n"); | |
| } | |
| export async function getMyProfile(userId: string) { | |
| const { data, error } = await supabaseAdmin | |
| .from("profiles") | |
| .select("*") | |
| .eq("user_id", userId) | |
| .maybeSingle(); | |
| if (error) { | |
| throw error; | |
| } | |
| return data; | |
| } | |
| export async function upsertMyProfile(userId: string, input: UpsertProfileInput) { | |
| const { data, error } = await supabaseAdmin | |
| .from("profiles") | |
| .upsert( | |
| { | |
| user_id: userId, | |
| display_name: input.displayName, | |
| age: input.age, | |
| location: input.location ?? null, | |
| gender: input.gender ?? null, | |
| interested_in: input.interestedIn ?? [] | |
| }, | |
| { onConflict: "user_id" } | |
| ) | |
| .select("*") | |
| .single(); | |
| if (error) { | |
| throw error; | |
| } | |
| return data; | |
| } | |
| export async function saveProfileAnswer(userId: string, questionId: string, input: Omit<ProfileAnswer, "questionId" | "updatedAt">) { | |
| if (!isKnownQuestionId(questionId)) { | |
| throw new HttpError(400, "unknown_question", `Unknown question id: ${questionId}`); | |
| } | |
| const profile = await getMyProfile(userId); | |
| if (!profile) { | |
| throw new HttpError(409, "profile_required", "Create the basic profile before saving answers"); | |
| } | |
| const answers = (profile.answers ?? {}) as Record<string, ProfileAnswer>; | |
| answers[questionId] = { | |
| questionId, | |
| answer: input.answer, | |
| followup: input.followup, | |
| dealbreakerSeverity: input.dealbreakerSeverity, | |
| updatedAt: new Date().toISOString() | |
| }; | |
| const { data, error } = await supabaseAdmin | |
| .from("profiles") | |
| .update({ answers, profile_complete: false }) | |
| .eq("user_id", userId) | |
| .select("*") | |
| .single(); | |
| if (error) { | |
| throw error; | |
| } | |
| return data; | |
| } | |
| export async function completeMyProfile(userId: string) { | |
| const profile = await getMyProfile(userId); | |
| if (!profile) { | |
| throw new HttpError(409, "profile_required", "Create the basic profile before completing the profile"); | |
| } | |
| const answers = (profile.answers ?? {}) as Record<string, ProfileAnswer>; | |
| const requiredQuestionIds = getQuestionsDocument().questions.map((question) => question.id); | |
| const missing = requiredQuestionIds.filter((questionId) => !answers[questionId]); | |
| if (missing.length > 0) { | |
| throw new HttpError(422, "profile_incomplete", `Missing required answers: ${missing.join(", ")}`); | |
| } | |
| const narrativeSummary = await summarizeProfile(answers); | |
| const embedding = await createEmbedding(profileTextForEmbedding(answers, narrativeSummary)); | |
| const disgustTriggers = collectSignals(answers, "disgust"); | |
| const captivatingTraits = collectSignals(answers, "captivating_traits"); | |
| const { data, error } = await supabaseAdmin | |
| .from("profiles") | |
| .update({ | |
| narrative_summary: narrativeSummary, | |
| embedding, | |
| disgust_triggers: disgustTriggers, | |
| captivating_traits: captivatingTraits, | |
| profile_complete: true | |
| }) | |
| .eq("user_id", userId) | |
| .select("*") | |
| .single(); | |
| if (error) { | |
| throw error; | |
| } | |
| return data; | |
| } | |
| export async function markUserPaid(userId: string, stripeCustomerId?: string | null, sessionId?: string | null) { | |
| const paidAt = new Date().toISOString(); | |
| const { error: entitlementError } = await supabaseAdmin | |
| .from("user_entitlements") | |
| .upsert( | |
| { | |
| user_id: userId, | |
| entry_paid: true, | |
| stripe_customer_id: stripeCustomerId, | |
| stripe_checkout_session_id: sessionId, | |
| paid_at: paidAt | |
| }, | |
| { onConflict: "user_id" } | |
| ); | |
| if (entitlementError) { | |
| throw entitlementError; | |
| } | |
| const { error: profileError } = await supabaseAdmin | |
| .from("profiles") | |
| .update({ paid_entry: true }) | |
| .eq("user_id", userId); | |
| if (profileError) { | |
| throw profileError; | |
| } | |
| } | |