549 lines
18 KiB
TypeScript
549 lines
18 KiB
TypeScript
/**
|
||
* 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<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),
|
||
})),
|
||
};
|
||
},
|
||
};
|
||
}
|