/** * demand-radar live repositories(radar/crm)。 * * 這裡刻意不做任何降級:後端尚未實作的能力回 501010,`apiRequest` 會丟 ApiError, * 頁面必須顯示錯誤而不是空清單。新頁樣式與文案不在此檔。 */ import type { Contact, ContactDetail, ContactStageCount, ContactTouch, CrmStats, FollowUp, Opportunity, RadarSweep, RadarToday, RadarWatch, ReplyVariant, ServiceProfile, WatchTermSuggestion, } from "../../domain/types"; import type { CrmRepo, RadarRepo } from "../repos"; import { apiRequest } from "./http"; type Raw = Record; function str(v: unknown): string { return typeof v === "string" ? v : v == null ? "" : String(v); } function optStr(v: unknown): string | undefined { const s = str(v); return s === "" ? undefined : s; } function num(v: unknown): number { return Number(v ?? 0) || 0; } function optNum(v: unknown): number | undefined { if (v == null) return undefined; const n = Number(v); return Number.isFinite(n) && n !== 0 ? n : undefined; } function strList(v: unknown): string[] { return Array.isArray(v) ? v.map(str) : []; } function rawList(v: unknown): Raw[] { return Array.isArray(v) ? (v as Raw[]) : []; } function total(raw: Raw): number { const pagination = (raw.pagination ?? {}) as Raw; return num(pagination.total); } function query(params: Record): string { const parts: string[] = []; for (const [k, v] of Object.entries(params)) { if (v === undefined || v === "") continue; parts.push(`${k}=${encodeURIComponent(String(v))}`); } return parts.length ? `?${parts.join("&")}` : ""; } function mapServiceProfile(raw: Raw): ServiceProfile { return { exists: Boolean(raw.exists), services: rawList(raw.services).map((s) => ({ name: str(s.name), price_min: optNum(s.price_min), price_max: optNum(s.price_max), currency: optStr(s.currency), })), cases: rawList(raw.cases).map((c) => ({ title: str(c.title), summary: optStr(c.summary), link: optStr(c.link), })), forbidden: strList(raw.forbidden), faq: rawList(raw.faq).map((f) => ({ question: str(f.question), answer: str(f.answer) })), service_areas: strList(raw.service_areas), remote_ok: Boolean(raw.remote_ok), availability: optStr(raw.availability), tone_note: optStr(raw.tone_note), updated_at: optNum(raw.updated_at), }; } function mapWatch(raw: Raw): RadarWatch { return { id: str(raw.id), terms: strList(raw.terms), exclude_terms: strList(raw.exclude_terms), regions: strList(raw.regions), status: (str(raw.status) || "active") as RadarWatch["status"], last_swept_at: optNum(raw.last_swept_at), created_at: num(raw.created_at), updated_at: num(raw.updated_at), first_sweep_triggered: Boolean(raw.first_sweep_triggered), }; } function mapReply(raw: Raw): ReplyVariant { return { id: str(raw.id), opportunity_id: str(raw.opportunity_id), variant: str(raw.variant) as ReplyVariant["variant"], text: str(raw.text), used_at: optNum(raw.used_at), sent_channel: optStr(raw.sent_channel) as ReplyVariant["sent_channel"], created_at: num(raw.created_at), }; } function mapOpportunity(raw: Raw): Opportunity { const override = raw.override as Raw | null | undefined; const defaultReply = raw.default_reply as Raw | null | undefined; return { id: str(raw.id), watch_id: optStr(raw.watch_id), source: (str(raw.source) || "threads") as Opportunity["source"], source_scout_post_id: optStr(raw.source_scout_post_id), external_id: str(raw.external_id), permalink: str(raw.permalink), author_handle: str(raw.author_handle), text: str(raw.text), posted_at: num(raw.posted_at), status: str(raw.status) as Opportunity["status"], intent_score: num(raw.intent_score), intent_band: (str(raw.intent_band) || "low") as Opportunity["intent_band"], // reasons 缺省時給空陣列,避免卡片展開判定理由時 .map 炸頁。 reasons: rawList(raw.reasons).map((r) => ({ dimension: str(r.dimension) as Opportunity["reasons"][number]["dimension"], score: num(r.score), reason: str(r.reason), })), region_detected: optStr(raw.region_detected), region_match: (str(raw.region_match) || "unknown") as Opportunity["region_match"], freshness_hours: num(raw.freshness_hours), matched_service: optStr(raw.matched_service), matched_terms: strList(raw.matched_terms), reject_reason: optStr(raw.reject_reason), override: override ? { from_band: optStr(override.from_band), to_band: optStr(override.to_band), from_status: optStr(override.from_status), to_status: optStr(override.to_status), actor_uid: num(override.actor_uid), at: num(override.at), } : undefined, contact_id: optStr(raw.contact_id), default_reply: defaultReply ? mapReply(defaultReply) : undefined, created_at: num(raw.created_at), }; } function mapSweep(raw: Raw): RadarSweep { return { id: str(raw.id), watch_id: str(raw.watch_id), job_id: optStr(raw.job_id), path: (str(raw.path) || "api") as RadarSweep["path"], hit_count: num(raw.hit_count), judged_count: num(raw.judged_count), created_count: num(raw.created_count), truncated_count: num(raw.truncated_count), failed_reason: optStr(raw.failed_reason), credits_used: num(raw.credits_used), started_at: num(raw.started_at), ended_at: optNum(raw.ended_at), }; } function mapContact(raw: Raw): Contact { return { id: str(raw.id), source_platform: str(raw.source_platform), author_handle: str(raw.author_handle), display_name: optStr(raw.display_name), stage: str(raw.stage) as Contact["stage"], needs_follow_up: Boolean(raw.needs_follow_up), follow_up_days: num(raw.follow_up_days), last_touch_at: optNum(raw.last_touch_at), opportunity_ids: strList(raw.opportunity_ids), opportunity_count: num(raw.opportunity_count), merged_from: Array.isArray(raw.merged_from) ? strList(raw.merged_from) : undefined, top_intent_band: optStr(raw.top_intent_band) as Contact["top_intent_band"], top_intent_score: optNum(raw.top_intent_score), created_at: num(raw.created_at), updated_at: num(raw.updated_at), }; } function mapTouch(raw: Raw): ContactTouch { return { id: str(raw.id), contact_id: str(raw.contact_id), type: str(raw.type) as ContactTouch["type"], from_stage: optStr(raw.from_stage) as ContactTouch["from_stage"], to_stage: optStr(raw.to_stage) as ContactTouch["to_stage"], body: optStr(raw.body), actor_uid: num(raw.actor_uid), created_at: num(raw.created_at), }; } function mapFollowUp(raw: Raw): FollowUp { const contact = (raw.contact ?? {}) as Raw; return { id: str(raw.id), contact_id: str(raw.contact_id), due_at: num(raw.due_at), status: str(raw.status) as FollowUp["status"], notified_count: num(raw.notified_count), contact: { id: str(contact.id), source_platform: str(contact.source_platform), author_handle: str(contact.author_handle), display_name: optStr(contact.display_name), stage: str(contact.stage) as Contact["stage"], }, created_at: num(raw.created_at), }; } const RADAR_BASE = "/api/v1/radar"; const CRM_BASE = "/api/v1/crm"; export function createLiveRadarRepo(): RadarRepo { return { async getServiceProfile() { return mapServiceProfile(await apiRequest(`${RADAR_BASE}/service-profile`)); }, async saveServiceProfile(patch) { const raw = await apiRequest(`${RADAR_BASE}/service-profile`, { method: "PUT", body: patch, }); return mapServiceProfile(raw); }, async listWatches(page = 1, pageSize = 20, status) { const raw = await apiRequest( `${RADAR_BASE}/watches${query({ page, pageSize, status })}`, ); return { list: rawList(raw.list).map(mapWatch), total: total(raw), active_count: num(raw.active_count), max_active: num(raw.max_active), profile_exists: Boolean(raw.profile_exists), }; }, async createWatch(input) { return mapWatch(await apiRequest(`${RADAR_BASE}/watches`, { method: "POST", body: input })); }, async updateWatch(id, patch) { const raw = await apiRequest(`${RADAR_BASE}/watches/${encodeURIComponent(id)}`, { method: "PUT", body: patch, }); return mapWatch(raw); }, async pauseWatch(id) { const raw = await apiRequest(`${RADAR_BASE}/watches/${encodeURIComponent(id)}/pause`, { method: "POST", body: {}, }); return mapWatch(raw); }, async resumeWatch(id) { const raw = await apiRequest(`${RADAR_BASE}/watches/${encodeURIComponent(id)}/resume`, { method: "POST", body: {}, }); return mapWatch(raw); }, async archiveWatch(id) { await apiRequest(`${RADAR_BASE}/watches/${encodeURIComponent(id)}`, { method: "DELETE" }); }, async suggestWatchTerms(limit) { const raw = await apiRequest(`${RADAR_BASE}/watches/suggest`, { method: "POST", body: limit ? { limit } : {}, }); return rawList(raw.list).map( (s): WatchTermSuggestion => ({ term: str(s.term), reason: str(s.reason), usage: str(s.usage) === "exclude" ? "exclude" : "include", }), ); }, async triggerWatchSweep(id) { const raw = await apiRequest(`${RADAR_BASE}/watches/${encodeURIComponent(id)}/sweep`, { method: "POST", body: {}, }); return { job_id: str(raw.job_id), sweep_id: optStr(raw.sweep_id) }; }, async getToday() { const raw = await apiRequest(`${RADAR_BASE}/today`); const stats = (raw.stats ?? {}) as Raw; return { stats: { total: num(stats.total), high: num(stats.high), mid: num(stats.mid), low: num(stats.low), }, high: rawList(raw.high).map(mapOpportunity), mid: rawList(raw.mid).map(mapOpportunity), low: rawList(raw.low).map(mapOpportunity), truncated_count: num(raw.truncated_count), last_swept_at: optNum(raw.last_swept_at), empty_reason: optStr(raw.empty_reason) as RadarToday["empty_reason"], empty_hint: optStr(raw.empty_hint), }; }, async listOpportunities(filter = {}) { const raw = await apiRequest( `${RADAR_BASE}/opportunities${query({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 20, band: filter.band, status: filter.status, watch_id: filter.watch_id, from: filter.from, to: filter.to, })}`, ); return { list: rawList(raw.list).map(mapOpportunity), total: total(raw) }; }, async getOpportunity(id) { return mapOpportunity( await apiRequest(`${RADAR_BASE}/opportunities/${encodeURIComponent(id)}`), ); }, async acceptOpportunity(id) { const raw = await apiRequest( `${RADAR_BASE}/opportunities/${encodeURIComponent(id)}/accept`, { method: "POST", body: {} }, ); return { opportunity_id: str(raw.opportunity_id), contact_id: optStr(raw.contact_id), status: str(raw.status), }; }, async dismissOpportunity(id, reason) { const raw = await apiRequest( `${RADAR_BASE}/opportunities/${encodeURIComponent(id)}/dismiss`, { method: "POST", body: reason ? { reason } : {} }, ); return mapOpportunity(raw); }, async overrideOpportunity(id, patch) { const raw = await apiRequest( `${RADAR_BASE}/opportunities/${encodeURIComponent(id)}/override`, { method: "POST", body: patch }, ); return mapOpportunity(raw); }, async listReplies(opportunityId) { const raw = await apiRequest( `${RADAR_BASE}/opportunities/${encodeURIComponent(opportunityId)}/replies`, ); return rawList(raw.list).map(mapReply); }, async createReply(opportunityId, variant) { const raw = await apiRequest( `${RADAR_BASE}/opportunities/${encodeURIComponent(opportunityId)}/replies`, { method: "POST", body: { variant } }, ); return mapReply(raw); }, async markReplyUsed(opportunityId, replyId, channel) { const raw = await apiRequest( `${RADAR_BASE}/opportunities/${encodeURIComponent(opportunityId)}/replies/${encodeURIComponent(replyId)}/mark-used`, { method: "POST", body: { channel } }, ); const replyRaw = (raw.reply ?? raw) as Raw; return { reply: mapReply(replyRaw), health_advice: optStr(raw.health_advice), }; }, async listSweeps(page = 1, pageSize = 20, watchId) { const raw = await apiRequest( `${RADAR_BASE}/sweeps${query({ page, pageSize, watch_id: watchId })}`, ); return { list: rawList(raw.list).map(mapSweep), total: total(raw) }; }, }; } export function createLiveCrmRepo(): CrmRepo { return { async listContacts(filter = {}) { const raw = await apiRequest( `${CRM_BASE}/contacts${query({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 20, stage: filter.stage, follow_up: filter.follow_up === undefined ? undefined : String(filter.follow_up), band: filter.band, sort: filter.sort, })}`, ); return { list: rawList(raw.list).map(mapContact), total: total(raw), stage_counts: rawList(raw.stage_counts).map( (s): ContactStageCount => ({ stage: str(s.stage) as ContactStageCount["stage"], count: num(s.count), }), ), }; }, async getContact(id, page = 1, pageSize = 20) { const raw = await apiRequest( `${CRM_BASE}/contacts/${encodeURIComponent(id)}${query({ page, pageSize })}`, ); return { contact: mapContact((raw.contact ?? {}) as Raw), touches: rawList(raw.touches).map(mapTouch), opportunities: rawList(raw.opportunities).map((o) => ({ id: str(o.id), permalink: str(o.permalink), text: str(o.text), intent_score: num(o.intent_score), intent_band: (str(o.intent_band) || "low") as ContactDetail["opportunities"][number]["intent_band"], created_at: num(o.created_at), })), }; }, async updateStage(id, stage, note) { const raw = await apiRequest(`${CRM_BASE}/contacts/${encodeURIComponent(id)}/stage`, { method: "POST", body: note ? { stage, note } : { stage }, }); return mapContact(raw); }, async setFollowUp(id, needsFollowUp, days) { const raw = await apiRequest( `${CRM_BASE}/contacts/${encodeURIComponent(id)}/follow-up`, { method: "POST", body: days ? { needs_follow_up: needsFollowUp, days } : { needs_follow_up: needsFollowUp } }, ); return mapContact(raw); }, async addNote(id, body) { const raw = await apiRequest(`${CRM_BASE}/contacts/${encodeURIComponent(id)}/notes`, { method: "POST", body: { body }, }); return mapTouch(raw); }, async mergeContact(id, sourceContactId) { const raw = await apiRequest(`${CRM_BASE}/contacts/${encodeURIComponent(id)}/merge`, { method: "POST", body: { source_contact_id: sourceContactId }, }); return mapContact(raw); }, async unmergeContact(id, mergedContactId) { const raw = await apiRequest(`${CRM_BASE}/contacts/${encodeURIComponent(id)}/unmerge`, { method: "POST", body: { merged_contact_id: mergedContactId }, }); return mapContact(raw); }, async reportConversion(id, input) { const raw = await apiRequest( `${CRM_BASE}/contacts/${encodeURIComponent(id)}/conversion`, { method: "POST", body: input }, ); return { contact_id: str(raw.contact_id), outcome_id: optStr(raw.outcome_id), stage: str(raw.stage) }; }, async updateConversion(id, input) { const raw = await apiRequest( `${CRM_BASE}/contacts/${encodeURIComponent(id)}/conversion`, { method: "PUT", body: input }, ); return { contact_id: str(raw.contact_id), outcome_id: optStr(raw.outcome_id), stage: str(raw.stage) }; }, async deleteConversion(id) { await apiRequest(`${CRM_BASE}/contacts/${encodeURIComponent(id)}/conversion`, { method: "DELETE", }); }, async listFollowUps(page = 1, pageSize = 20, status) { const raw = await apiRequest(`${CRM_BASE}/followups${query({ page, pageSize, status })}`); return { list: rawList(raw.list).map(mapFollowUp), total: total(raw) }; }, async doneFollowUp(id) { const raw = await apiRequest(`${CRM_BASE}/followups/${encodeURIComponent(id)}/done`, { method: "POST", body: {}, }); return mapFollowUp(raw); }, async snoozeFollowUp(id, days) { const raw = await apiRequest(`${CRM_BASE}/followups/${encodeURIComponent(id)}/snooze`, { method: "POST", body: { days }, }); return mapFollowUp(raw); }, async generateFollowUpMessage(id) { const raw = await apiRequest(`${CRM_BASE}/followups/${encodeURIComponent(id)}/message`, { method: "POST", body: {}, }); return str(raw.text); }, async getStats(from, to) { const raw = await apiRequest(`${CRM_BASE}/stats${query({ from, to })}`); return { terms: rawList(raw.terms).map((t) => ({ term: str(t.term), accepted: num(t.accepted), replied: num(t.replied), won: num(t.won), conversion_rate: optNum(t.conversion_rate), insufficient_sample: Boolean(t.insufficient_sample), })), variants: rawList(raw.variants).map((v) => ({ variant: str(v.variant) as CrmStats["variants"][number]["variant"], used: num(v.used), replied: num(v.replied), won: num(v.won), success_rate: optNum(v.success_rate), insufficient_sample: Boolean(v.insufficient_sample), })), sources: rawList(raw.sources).map((s) => ({ source: str(s.source) as CrmStats["sources"][number]["source"], won: num(s.won), insufficient_sample: Boolean(s.insufficient_sample), })), }; }, }; }