thread-master/apps/web/src/data/live/radarRepos.ts

548 lines
18 KiB
TypeScript
Raw Normal View History

2026-08-03 05:52:02 +00:00
/**
* demand-radar live repositoriesradarcrm
*
* 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<string, unknown>;
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, string | number | boolean | undefined>): 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),
};
}
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<Raw>(`${RADAR_BASE}/service-profile`));
},
async saveServiceProfile(patch) {
const raw = await apiRequest<Raw>(`${RADAR_BASE}/service-profile`, {
method: "PUT",
body: patch,
});
return mapServiceProfile(raw);
},
async listWatches(page = 1, pageSize = 20, status) {
const raw = await apiRequest<Raw>(
`${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<Raw>(`${RADAR_BASE}/watches`, { method: "POST", body: input }));
},
async updateWatch(id, patch) {
const raw = await apiRequest<Raw>(`${RADAR_BASE}/watches/${encodeURIComponent(id)}`, {
method: "PUT",
body: patch,
});
return mapWatch(raw);
},
async pauseWatch(id) {
const raw = await apiRequest<Raw>(`${RADAR_BASE}/watches/${encodeURIComponent(id)}/pause`, {
method: "POST",
body: {},
});
return mapWatch(raw);
},
async resumeWatch(id) {
const raw = await apiRequest<Raw>(`${RADAR_BASE}/watches/${encodeURIComponent(id)}/resume`, {
method: "POST",
body: {},
});
return mapWatch(raw);
},
async archiveWatch(id) {
await apiRequest<Raw>(`${RADAR_BASE}/watches/${encodeURIComponent(id)}`, { method: "DELETE" });
},
async suggestWatchTerms(limit) {
const raw = await apiRequest<Raw>(`${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<Raw>(`${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<Raw>(`${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<Raw>(
`${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<Raw>(`${RADAR_BASE}/opportunities/${encodeURIComponent(id)}`),
);
},
async acceptOpportunity(id) {
const raw = await apiRequest<Raw>(
`${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<Raw>(
`${RADAR_BASE}/opportunities/${encodeURIComponent(id)}/dismiss`,
{ method: "POST", body: reason ? { reason } : {} },
);
return mapOpportunity(raw);
},
async overrideOpportunity(id, patch) {
const raw = await apiRequest<Raw>(
`${RADAR_BASE}/opportunities/${encodeURIComponent(id)}/override`,
{ method: "POST", body: patch },
);
return mapOpportunity(raw);
},
async listReplies(opportunityId) {
const raw = await apiRequest<Raw>(
`${RADAR_BASE}/opportunities/${encodeURIComponent(opportunityId)}/replies`,
);
return rawList(raw.list).map(mapReply);
},
async createReply(opportunityId, variant) {
const raw = await apiRequest<Raw>(
`${RADAR_BASE}/opportunities/${encodeURIComponent(opportunityId)}/replies`,
{ method: "POST", body: { variant } },
);
return mapReply(raw);
},
async markReplyUsed(opportunityId, replyId, channel) {
const raw = await apiRequest<Raw>(
`${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<Raw>(
`${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<Raw>(
`${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<Raw>(
`${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<Raw>(`${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<Raw>(
`${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<Raw>(`${CRM_BASE}/contacts/${encodeURIComponent(id)}/notes`, {
method: "POST",
body: { body },
});
return mapTouch(raw);
},
async mergeContact(id, sourceContactId) {
const raw = await apiRequest<Raw>(`${CRM_BASE}/contacts/${encodeURIComponent(id)}/merge`, {
method: "POST",
body: { source_contact_id: sourceContactId },
});
return mapContact(raw);
},
async unmergeContact(id, mergedContactId) {
const raw = await apiRequest<Raw>(`${CRM_BASE}/contacts/${encodeURIComponent(id)}/unmerge`, {
method: "POST",
body: { merged_contact_id: mergedContactId },
});
return mapContact(raw);
},
async reportConversion(id, input) {
const raw = await apiRequest<Raw>(
`${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<Raw>(
`${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<Raw>(`${CRM_BASE}/contacts/${encodeURIComponent(id)}/conversion`, {
method: "DELETE",
});
},
async listFollowUps(page = 1, pageSize = 20, status) {
const raw = await apiRequest<Raw>(`${CRM_BASE}/followups${query({ page, pageSize, status })}`);
return { list: rawList(raw.list).map(mapFollowUp), total: total(raw) };
},
async doneFollowUp(id) {
const raw = await apiRequest<Raw>(`${CRM_BASE}/followups/${encodeURIComponent(id)}/done`, {
method: "POST",
body: {},
});
return mapFollowUp(raw);
},
async snoozeFollowUp(id, days) {
const raw = await apiRequest<Raw>(`${CRM_BASE}/followups/${encodeURIComponent(id)}/snooze`, {
method: "POST",
body: { days },
});
return mapFollowUp(raw);
},
async generateFollowUpMessage(id) {
const raw = await apiRequest<Raw>(`${CRM_BASE}/followups/${encodeURIComponent(id)}/message`, {
method: "POST",
body: {},
});
return str(raw.text);
},
async getStats(from, to) {
const raw = await apiRequest<Raw>(`${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),
})),
};
},
};
}