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

549 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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),
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<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),
})),
};
},
};
}