export interface CaptchaResponse { captchaId: string; imageBase64: string; } export interface TenantInfo { tenantId: number; tenantCode: string; tenantName: string; } export interface TokenResponse { accessToken: string; refreshToken: string; accessExpiresInMinutes: number; refreshExpiresInDays: number; availableTenants?: TenantInfo[]; } export interface LoginPayload { username: string; password: string; tenantCode?: string; captchaId?: string; captchaCode?: string; } export interface UserProfile { userId: number; tenantId: number; username: string; displayName: string; email?: string; phone?: string; status?: number; isAdmin?: boolean; isPlatformAdmin?: boolean; pwdResetRequired?: number; roleCodes?: string[]; roles?: UserRole[]; } export interface UserRole { roleId?: number; roleCode?: string; roleName?: string; } export interface PlatformConfig { projectName?: string; logoUrl?: string; iconUrl?: string; loginBgUrl?: string; icpInfo?: string; copyrightInfo?: string; systemDescription?: string; } export interface WecomExchangePayload { ticket: string; } export interface WecomJsSdkConfig { corpId: string; timestamp: number; nonceStr: string; signature: string; } export interface DashboardStat { name: string; value?: number; metricKey: string; } export interface DashboardTodo { id: string; title?: string; bizType?: string; bizId?: string; priority?: string; status?: string; dueDate?: string; createdAt?: string; updatedAt?: string; } export interface DashboardActivity { id: number; bizType?: string; bizId?: number; actionType?: string; title?: string; content?: string; operatorUserId?: number; operatorName?: string; createdAt?: string; timeText?: string; } export interface DashboardHome { userId?: number; realName?: string; jobTitle?: string; deptName?: string; onboardingDays?: number; stats?: DashboardStat[]; todos?: DashboardTodo[]; activities?: DashboardActivity[]; } export interface ProfileOverview { userId?: number; monthlyOpportunityCount?: number; monthlyExpansionCount?: number; averageScore?: number; onboardingDays?: number; realName?: string; jobTitle?: string; deptName?: string; accountStatus?: string; } export interface UpdateCurrentUserProfilePayload { userId?: number; username?: string; displayName?: string; email?: string; phone?: string; pwdResetRequired?: number; isPlatformAdmin?: boolean; orgId?: number; } export interface UpdateCurrentUserPasswordPayload { oldPassword: string; newPassword: string; } export interface WorkCheckIn { id: number; date?: string; time?: string; locationText?: string; remark?: string; status?: string; longitude?: number; latitude?: number; photoUrls?: string[]; bizType?: "sales" | "channel" | "opportunity"; bizId?: number; bizName?: string; userName?: string; deptName?: string; } export interface WorkDailyReport { id: number; date?: string; submitTime?: string; workContent?: string; lineItems?: WorkReportLineItem[]; tomorrowPlan?: string; planItems?: WorkTomorrowPlanItem[]; sourceType?: string; status?: string; score?: number; comment?: string; } export interface WorkHistoryItem { id: number; type?: string; date?: string; time?: string; content?: string; status?: string; score?: number; comment?: string; photoUrls?: string[]; } export interface WorkCheckInExportRow { checkinDate?: string; checkinTime?: string; userName?: string; deptName?: string; bizType?: "sales" | "channel" | "opportunity" | string; bizName?: string; locationText?: string; longitude?: number; latitude?: number; photoUrls?: string[]; remark?: string; status?: string; createdAt?: string; updatedAt?: string; } export interface WorkDailyReportExportRow { reportDate?: string; submitTime?: string; userName?: string; deptName?: string; sourceType?: string; status?: string; workContent?: string; tomorrowPlan?: string; score?: number; comment?: string; reviewerName?: string; reviewedAt?: string; createdAt?: string; updatedAt?: string; lineItems?: WorkReportLineItem[]; planItems?: WorkTomorrowPlanItem[]; } export interface WorkOverview { todayCheckIn?: WorkCheckIn; todayReport?: WorkDailyReport; suggestedWorkContent?: string; history?: WorkHistoryItem[]; } export interface WorkHistoryPage { items?: WorkHistoryItem[]; hasMore?: boolean; page?: number; size?: number; } export interface WorkExportQuery { startDate?: string; endDate?: string; keyword?: string; deptName?: string; bizType?: string; status?: string; } export interface CreateWorkCheckInPayload { locationText: string; remark?: string; longitude?: number; latitude?: number; photoUrls?: string[]; bizType?: "sales" | "channel" | "opportunity"; bizId?: number; bizName?: string; userName?: string; deptName?: string; } export interface WorkReportLineItem { workDate: string; bizType: "sales" | "channel" | "opportunity"; bizId: number; bizName?: string; editorText?: string; content: string; visitStartTime?: string; evaluationContent?: string; nextPlan?: string; latestProgress?: string; communicationTime?: string; communicationContent?: string; } export interface WorkTomorrowPlanItem { content: string; } export interface CreateWorkDailyReportPayload { workContent: string; lineItems: WorkReportLineItem[]; planItems: WorkTomorrowPlanItem[]; tomorrowPlan: string; sourceType?: string; } export interface OpportunityFollowUp { id: number; opportunityId?: number; date?: string; type?: string; content?: string; latestProgress?: string; communicationTime?: string; communicationContent?: string; nextAction?: string; user?: string; } export interface OpportunityItem { id: number; ownerUserId?: number; code?: string; name?: string; client?: string; owner?: string; updatedAt?: string; projectLocation?: string; operatorCode?: string; operatorName?: string; amount?: number; date?: string; confidence?: string; stageCode?: string; stage?: string; type?: string; archived?: boolean; pushedToOms?: boolean; product?: string; source?: string; salesExpansionId?: number; salesExpansionName?: string; channelExpansionId?: number; channelExpansionName?: string; preSalesId?: number; preSalesName?: string; competitorName?: string; latestProgress?: string; nextPlan?: string; notes?: string; followUps?: OpportunityFollowUp[]; } export interface OpportunityOverview { items?: OpportunityItem[]; } export interface OpportunityDictOption { label?: string; value?: string; } export interface OpportunityMeta { stageOptions?: OpportunityDictOption[]; operatorOptions?: OpportunityDictOption[]; projectLocationOptions?: OpportunityDictOption[]; opportunityTypeOptions?: OpportunityDictOption[]; confidenceOptions?: OpportunityDictOption[]; } export interface OmsPreSalesOption { userId: number; loginName?: string; userName?: string; } export interface CreateOpportunityPayload { opportunityName: string; customerName: string; projectLocation?: string; operatorName?: string; amount: number; expectedCloseDate: string; confidencePct: string; stage?: string; opportunityType?: string; productType?: string; source?: string; salesExpansionId?: number; channelExpansionId?: number; competitorName?: string; pushedToOms?: boolean; description?: string; } export interface PushOpportunityToOmsPayload { preSalesId?: number; preSalesName?: string; } export interface CreateOpportunityFollowUpPayload { followUpType: string; content: string; nextAction?: string; followUpTime: string; } export interface ExpansionFollowUp { id: number; bizId?: number; bizType?: string; date?: string; type?: string; content?: string; user?: string; visitStartTime?: string; evaluationContent?: string; nextPlan?: string; } export interface SalesExpansionItem { id: number; ownerUserId?: number; owner?: string; type: "sales"; employeeNo?: string; name?: string; officeCode?: string; officeName?: string; phone?: string; email?: string; targetDept?: string; dept?: string; industryCode?: string; industry?: string; title?: string; intentLevel?: string; intent?: string; stageCode?: string; stage?: string; hasExp?: boolean; inProgress?: boolean; active?: boolean; employmentStatus?: string; expectedJoinDate?: string; updatedAt?: string; notes?: string; relatedProjects?: RelatedProjectSummary[]; followUps?: ExpansionFollowUp[]; } export interface RelatedProjectSummary { opportunityId: number; opportunityCode?: string; opportunityName?: string; amount?: number; } export interface ChannelExpansionItem { id: number; ownerUserId?: number; owner?: string; type: "channel"; channelCode?: string; name?: string; provinceCode?: string; province?: string; cityCode?: string; city?: string; officeAddress?: string; channelIndustryCode?: string; channelIndustry?: string; certificationLevel?: string; annualRevenue?: string; revenue?: string; size?: number; primaryContactName?: string; primaryContactTitle?: string; primaryContactMobile?: string; establishedDate?: string; intentLevel?: string; intent?: string; hasDesktopExp?: boolean; channelAttributeCode?: string; channelAttribute?: string; internalAttributeCode?: string; internalAttribute?: string; stageCode?: string; stage?: string; landed?: boolean; expectedSignDate?: string; updatedAt?: string; notes?: string; contacts?: ChannelExpansionContact[]; relatedProjects?: ChannelRelatedProjectSummary[]; followUps?: ExpansionFollowUp[]; } export interface ChannelExpansionContact { id?: number; name?: string; mobile?: string; title?: string; } export interface ChannelRelatedProjectSummary { opportunityId: number; opportunityCode?: string; opportunityName?: string; amount?: number; } export interface ExpansionOverview { salesItems?: SalesExpansionItem[]; channelItems?: ChannelExpansionItem[]; } export interface ExpansionDictOption { label?: string; value?: string; } export interface ExpansionMeta { officeOptions?: ExpansionDictOption[]; industryOptions?: ExpansionDictOption[]; provinceOptions?: ExpansionDictOption[]; certificationLevelOptions?: ExpansionDictOption[]; channelAttributeOptions?: ExpansionDictOption[]; internalAttributeOptions?: ExpansionDictOption[]; nextChannelCode?: string; } export interface ExpansionDuplicateCheck { duplicated?: boolean; message?: string; } export interface CreateSalesExpansionPayload { employeeNo: string; candidateName: string; officeName?: string; mobile?: string; email?: string; targetDept?: string; industry?: string; title?: string; intentLevel?: string; stage?: string; hasDesktopExp?: boolean; inProgress?: boolean; employmentStatus?: string; expectedJoinDate?: string; remark?: string; } export interface CreateChannelExpansionPayload { channelCode?: string; officeAddress?: string; channelIndustry?: string[]; channelName: string; province?: string; city?: string; certificationLevel?: string; annualRevenue?: number; staffSize?: number; contactEstablishedDate?: string; intentLevel?: string; hasDesktopExp?: boolean; channelAttribute?: string[]; channelAttributeCustom?: string; internalAttribute?: string[]; stage?: string; remark?: string; contacts?: ChannelExpansionContact[]; } export interface UpdateSalesExpansionPayload extends CreateSalesExpansionPayload {} export interface UpdateChannelExpansionPayload extends CreateChannelExpansionPayload {} const EXPANSION_MULTI_VALUE_CUSTOM_PREFIX = "__custom__:"; function normalizeExpansionMultiValues(values?: string[]) { return Array.from(new Set((values ?? []).map((value) => value?.trim()).filter((value): value is string => Boolean(value)))); } export function encodeExpansionMultiValue(values?: string[], customText?: string) { const normalizedValues = normalizeExpansionMultiValues(values); const normalizedCustomText = customText?.trim(); if (normalizedCustomText) { normalizedValues.push(`${EXPANSION_MULTI_VALUE_CUSTOM_PREFIX}${encodeURIComponent(normalizedCustomText)}`); } return normalizedValues.join(","); } export function decodeExpansionMultiValue(rawValue?: string) { const result = { values: [] as string[], customText: "", }; if (!rawValue?.trim()) { return result; } rawValue .split(",") .map((item) => item.trim()) .filter(Boolean) .forEach((item) => { if (item.startsWith(EXPANSION_MULTI_VALUE_CUSTOM_PREFIX)) { result.customText = decodeURIComponent(item.slice(EXPANSION_MULTI_VALUE_CUSTOM_PREFIX.length)); return; } result.values.push(item); }); result.values = Array.from(new Set(result.values)); return result; } function serializeChannelExpansionPayload(payload: CreateChannelExpansionPayload) { const { channelAttributeCustom, ...rest } = payload; return { ...rest, city: payload.city, certificationLevel: payload.certificationLevel, channelIndustry: encodeExpansionMultiValue(payload.channelIndustry), channelAttribute: encodeExpansionMultiValue(payload.channelAttribute, channelAttributeCustom), internalAttribute: encodeExpansionMultiValue(payload.internalAttribute), }; } export interface CreateExpansionFollowUpPayload { followUpType: string; content: string; nextAction?: string; followUpTime: string; visitStartTime?: string; evaluationContent?: string; nextPlan?: string; } interface ApiEnvelope { code: string | number; msg: string; data: T; } interface ApiErrorBody { code?: string | number; msg?: string; message?: string; } const LOGIN_PATH = "/login"; const USER_CONTEXT_CACHE_TTL_MS = 2 * 60 * 1000; const CURRENT_USER_CACHE_KEY = "auth-cache:current-user"; const PROFILE_OVERVIEW_CACHE_KEY = "auth-cache:profile-overview"; const memoryRequestCache = new Map(); const inFlightRequestCache = new Map>(); function isSuccessCode(code: unknown) { return code === "0" || code === "200" || code === 0 || code === 200; } function applyAuthHeaders(headers: Headers) { const token = localStorage.getItem("accessToken"); if (token) { headers.set("Authorization", `Bearer ${token}`); } const userId = getStoredUserId(); if (userId !== undefined) { headers.set("X-User-Id", String(userId)); } return headers; } function handleUnauthorizedResponse() { clearAuth(); window.location.href = `${LOGIN_PATH}?timeout=1`; } function tryParseApiBody(rawText: string, contentType?: string | null): (ApiEnvelope & ApiErrorBody) | null { const normalizedText = rawText.trim(); const normalizedContentType = (contentType || "").toLowerCase(); const looksLikeJson = normalizedContentType.includes("application/json") || normalizedContentType.includes("+json") || normalizedText.startsWith("{") || normalizedText.startsWith("["); if (!normalizedText || !looksLikeJson) { return null; } try { return JSON.parse(normalizedText) as ApiEnvelope & ApiErrorBody; } catch { return null; } } function extractHtmlErrorMessage(rawText: string) { const titleMatch = rawText.match(/]*>([^<]+)<\/title>/i); if (titleMatch?.[1]?.trim()) { return titleMatch[1].trim(); } const headingMatch = rawText.match(/]*>([^<]+)<\/h[1-6]>/i); if (headingMatch?.[1]?.trim()) { return headingMatch[1].trim(); } const textOnly = rawText .replace(//gi, " ") .replace(//gi, " ") .replace(/<[^>]+>/g, " ") .replace(/\s+/g, " ") .trim(); return textOnly || null; } function buildApiErrorMessage(rawText: string, status: number, body?: ApiErrorBody | null) { const directMessage = body?.msg?.trim() || body?.message?.trim(); if (directMessage) { return directMessage; } const normalizedText = rawText.trim(); if (!normalizedText) { return `请求失败(${status})`; } if (normalizedText.startsWith("<")) { return extractHtmlErrorMessage(normalizedText) || `请求失败(${status})`; } return normalizedText.length > 300 ? normalizedText.slice(0, 300) : normalizedText; } async function request(input: string, init?: RequestInit, withAuth = false): Promise { const headers = new Headers(init?.headers); if (!headers.has("Content-Type") && init?.body && !(init.body instanceof FormData)) { headers.set("Content-Type", "application/json"); } if (withAuth) { applyAuthHeaders(headers); } const response = await fetch(input, { ...init, headers, }); if (response.status === 401 || response.status === 403) { handleUnauthorizedResponse(); throw new Error("登录已失效,请重新登录"); } const rawText = await response.text(); const body = tryParseApiBody(rawText, response.headers.get("content-type")); if (!response.ok) { throw new Error(buildApiErrorMessage(rawText, response.status, body)); } if (!body) { throw new Error(rawText.trim() ? buildApiErrorMessage(rawText, response.status, null) : "接口返回为空"); } if (!isSuccessCode(body.code)) { throw new Error(buildApiErrorMessage(rawText, response.status, body)); } return body.data; } export async function fetchWithAuth(input: string, init?: RequestInit) { const response = await fetch(input, { ...init, headers: applyAuthHeaders(new Headers(init?.headers)), }); if (response.status === 401 || response.status === 403) { handleUnauthorizedResponse(); throw new Error("登录已失效,请重新登录"); } return response; } export function isAuthed() { return Boolean(localStorage.getItem("accessToken")); } export function clearAuth() { localStorage.removeItem("accessToken"); localStorage.removeItem("refreshToken"); localStorage.removeItem("username"); localStorage.removeItem("availableTenants"); localStorage.removeItem("activeTenantId"); sessionStorage.removeItem("userProfile"); clearCachedAuthContext(); } export function persistLogin(payload: TokenResponse, username: string) { clearCachedAuthContext(); localStorage.setItem("accessToken", payload.accessToken); localStorage.setItem("refreshToken", payload.refreshToken); localStorage.setItem("username", username); if (payload.availableTenants) { localStorage.setItem("availableTenants", JSON.stringify(payload.availableTenants)); try { const tokenPayload = JSON.parse(atob(payload.accessToken.split(".")[1])); if (tokenPayload?.tenantId !== undefined) { localStorage.setItem("activeTenantId", String(tokenPayload.tenantId)); } } catch { localStorage.removeItem("activeTenantId"); } } } function getStoredUserId() { try { const rawProfile = sessionStorage.getItem("userProfile"); if (rawProfile) { const profile = JSON.parse(rawProfile) as Partial; if (typeof profile.userId === "number" && Number.isFinite(profile.userId)) { return profile.userId; } } } catch { // Ignore invalid session cache and fall back to token parsing. } try { const token = localStorage.getItem("accessToken"); if (!token) { return undefined; } const tokenPayload = JSON.parse(atob(token.split(".")[1])) as { userId?: number }; if (typeof tokenPayload.userId === "number" && Number.isFinite(tokenPayload.userId)) { return tokenPayload.userId; } } catch { return undefined; } return undefined; } export function getStoredCurrentUserId() { return getStoredUserId(); } export async function fetchCaptcha() { return request("/api/sys/auth/captcha"); } export async function login(payload: LoginPayload) { return request("/api/sys/auth/login", { method: "POST", body: JSON.stringify(payload), }); } export async function exchangeWecomTicket(payload: WecomExchangePayload) { return request("/api/wecom/sso/exchange", { method: "POST", body: JSON.stringify(payload), }); } export async function getWecomJsSdkConfig(url: string) { const params = new URLSearchParams({ url }); return request(`/api/wecom/sso/js-sdk-config?${params.toString()}`, undefined, true); } export async function getSystemParamValue(key: string, defaultValue?: string) { const params = new URLSearchParams({ key }); if (defaultValue !== undefined) { params.set("defaultValue", defaultValue); } return request(`/api/sys/api/params/value?${params.toString()}`); } export async function getOpenPlatformConfig() { return request("/api/sys/api/open/platform/config"); } export async function getCurrentUser() { return getCachedAuthedRequest( CURRENT_USER_CACHE_KEY, async () => { const profile = await request("/api/sys/api/users/me", undefined, true); persistStoredUserProfile(profile); return profile; }, ); } export async function refreshCurrentUser() { clearCachedAuthContext(); const profile = await request("/api/sys/api/users/me", undefined, true); persistStoredUserProfile(profile); return profile; } export async function getDashboardHome() { return request("/api/dashboard/home", undefined, true); } export async function completeDashboardTodo(todoId: string) { return request(`/api/dashboard/todos/${todoId}/complete`, { method: "POST", }, true); } export async function getProfileOverview() { return getCachedAuthedRequest( PROFILE_OVERVIEW_CACHE_KEY, () => request("/api/profile/overview", undefined, true), ); } export async function updateCurrentUserProfile(payload: UpdateCurrentUserProfilePayload) { const result = await request("/api/sys/api/users/profile", { method: "PUT", body: JSON.stringify(payload), }, true); clearCachedAuthContext(); return result; } export async function updateCurrentUserPassword(payload: UpdateCurrentUserPasswordPayload) { return request("/api/sys/api/users/password", { method: "PUT", body: JSON.stringify(payload), }, true); } export async function updateUserProfileById(userId: number, payload: UpdateCurrentUserProfilePayload) { return request(`/api/sys/api/users/${userId}`, { method: "PUT", body: JSON.stringify(payload), }, true); } export async function getWorkOverview() { return request("/api/work/overview", undefined, true); } export async function getWorkHistory(type: "checkin" | "report", page = 1, size = 8) { const params = new URLSearchParams({ type, page: String(page), size: String(size), }); return request(`/api/work/history?${params.toString()}`, undefined, true); } function buildWorkExportQuery(params?: WorkExportQuery) { const searchParams = new URLSearchParams(); Object.entries(params ?? {}).forEach(([key, value]) => { const normalized = value?.trim(); if (normalized) { searchParams.set(key, normalized); } }); const query = searchParams.toString(); return query ? `?${query}` : ""; } export async function getWorkCheckInExportData(params?: WorkExportQuery) { return request(`/api/work/checkins/export-data${buildWorkExportQuery(params)}`, undefined, true); } export async function getWorkDailyReportExportData(params?: WorkExportQuery) { return request(`/api/work/daily-reports/export-data${buildWorkExportQuery(params)}`, undefined, true); } export async function reverseWorkGeocode(latitude: number, longitude: number) { return request(`/api/work/reverse-geocode?lat=${latitude}&lon=${longitude}`, undefined, true); } export async function saveWorkCheckIn(payload: CreateWorkCheckInPayload) { return request("/api/work/checkins", { method: "POST", body: JSON.stringify(payload), }, true); } export async function uploadWorkCheckInPhoto(file: File) { const formData = new FormData(); formData.append("file", file); return request("/api/work/checkin-photos", { method: "POST", body: formData, }, true); } export async function saveWorkDailyReport(payload: CreateWorkDailyReportPayload) { return request("/api/work/daily-reports", { method: "POST", body: JSON.stringify(payload), }, true); } export async function getOpportunityOverview(keyword?: string, stage?: string) { const params = new URLSearchParams(); if (keyword && keyword.trim()) { params.set("keyword", keyword.trim()); } if (stage && stage.trim() && stage !== "全部") { params.set("stage", stage.trim()); } const query = params.toString(); return request(`/api/opportunities/overview${query ? `?${query}` : ""}`, undefined, true); } export async function getOpportunityMeta() { return request("/api/opportunities/meta", undefined, true); } export async function getOpportunityOmsPreSalesOptions() { return request("/api/opportunities/oms/pre-sales", undefined, true); } export async function createOpportunity(payload: CreateOpportunityPayload) { return request("/api/opportunities", { method: "POST", body: JSON.stringify(payload), }, true); } export async function updateOpportunity(opportunityId: number, payload: CreateOpportunityPayload) { return request(`/api/opportunities/${opportunityId}`, { method: "PUT", body: JSON.stringify(payload), }, true); } export async function pushOpportunityToOms(opportunityId: number, payload?: PushOpportunityToOmsPayload) { return request(`/api/opportunities/${opportunityId}/push-oms`, { method: "POST", body: JSON.stringify(payload ?? {}), }, true); } export async function createOpportunityFollowUp(opportunityId: number, payload: CreateOpportunityFollowUpPayload) { return request(`/api/opportunities/${opportunityId}/followups`, { method: "POST", body: JSON.stringify(payload), }, true); } export async function getExpansionOverview(keyword?: string) { const params = new URLSearchParams(); if (keyword && keyword.trim()) { params.set("keyword", keyword.trim()); } const query = params.toString(); return request(`/api/expansion/overview${query ? `?${query}` : ""}`, undefined, true); } export async function getExpansionMeta() { return request("/api/expansion/meta", undefined, true); } export async function getExpansionCityOptions(provinceName: string) { const params = new URLSearchParams({ provinceName }); return request(`/api/expansion/areas/cities?${params.toString()}`, undefined, true); } export async function checkSalesExpansionDuplicate(employeeNo: string, excludeId?: number) { const params = new URLSearchParams({ employeeNo }); if (excludeId) { params.set("excludeId", String(excludeId)); } return request(`/api/expansion/sales/duplicate-check?${params.toString()}`, undefined, true); } export async function checkChannelExpansionDuplicate(channelName: string, excludeId?: number) { const params = new URLSearchParams({ channelName }); if (excludeId) { params.set("excludeId", String(excludeId)); } return request(`/api/expansion/channel/duplicate-check?${params.toString()}`, undefined, true); } export async function createSalesExpansion(payload: CreateSalesExpansionPayload) { return request("/api/expansion/sales", { method: "POST", body: JSON.stringify(payload), }, true); } export async function createChannelExpansion(payload: CreateChannelExpansionPayload) { return request("/api/expansion/channel", { method: "POST", body: JSON.stringify(serializeChannelExpansionPayload(payload)), }, true); } export async function updateSalesExpansion(id: number, payload: UpdateSalesExpansionPayload) { return request(`/api/expansion/sales/${id}`, { method: "PUT", body: JSON.stringify(payload), }, true); } export async function updateChannelExpansion(id: number, payload: UpdateChannelExpansionPayload) { return request(`/api/expansion/channel/${id}`, { method: "PUT", body: JSON.stringify(serializeChannelExpansionPayload(payload)), }, true); } export async function createExpansionFollowUp( bizType: "sales" | "channel", bizId: number, payload: CreateExpansionFollowUpPayload, ) { return request(`/api/expansion/${bizType}/${bizId}/followups`, { method: "POST", body: JSON.stringify(payload), }, true); } function readCachedValue(cacheKey: string) { const memoryValue = memoryRequestCache.get(cacheKey); if (memoryValue && memoryValue.expiresAt > Date.now()) { return memoryValue.value as T; } memoryRequestCache.delete(cacheKey); try { const raw = sessionStorage.getItem(cacheKey); if (!raw) { return null; } const parsed = JSON.parse(raw) as { expiresAt?: number; value?: T }; if (!parsed.expiresAt || parsed.expiresAt <= Date.now()) { sessionStorage.removeItem(cacheKey); return null; } memoryRequestCache.set(cacheKey, { expiresAt: parsed.expiresAt, value: parsed.value }); return parsed.value ?? null; } catch { sessionStorage.removeItem(cacheKey); return null; } } function writeCachedValue(cacheKey: string, value: T, ttlMs = USER_CONTEXT_CACHE_TTL_MS) { const payload = { expiresAt: Date.now() + ttlMs, value, }; memoryRequestCache.set(cacheKey, payload); try { sessionStorage.setItem(cacheKey, JSON.stringify(payload)); } catch { // Ignore session cache failures and keep in-memory cache only. } } function clearCachedAuthContext() { [CURRENT_USER_CACHE_KEY, PROFILE_OVERVIEW_CACHE_KEY].forEach((cacheKey) => { memoryRequestCache.delete(cacheKey); inFlightRequestCache.delete(cacheKey); try { sessionStorage.removeItem(cacheKey); } catch { // Ignore session cache cleanup failures. } }); } function persistStoredUserProfile(profile: UserProfile) { try { sessionStorage.setItem("userProfile", JSON.stringify(profile)); } catch { // Ignore session storage failures and keep request cache only. } } async function getCachedAuthedRequest( cacheKey: string, fetcher: () => Promise, ttlMs = USER_CONTEXT_CACHE_TTL_MS, ) { const cachedValue = readCachedValue(cacheKey); if (cachedValue) { return cachedValue; } const inFlight = inFlightRequestCache.get(cacheKey); if (inFlight) { return inFlight as Promise; } const requestPromise = fetcher() .then((result) => { writeCachedValue(cacheKey, result, ttlMs); inFlightRequestCache.delete(cacheKey); return result; }) .catch((error) => { inFlightRequestCache.delete(cacheKey); throw error; }); inFlightRequestCache.set(cacheKey, requestPromise); return requestPromise; }