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