forked from sagnik/Velocity-OS
Initial commit: Velocity-OS migration
This commit is contained in:
128
webos/src/shared/lib/api.ts
Normal file
128
webos/src/shared/lib/api.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { buildVelocityHeaders } from '@/lib/velocitySession';
|
||||
|
||||
const rawApiBase = import.meta.env.VITE_API_URL?.trim();
|
||||
const DEPLOYED_BACKEND_ORIGIN = 'https://velocity.desineuron.in';
|
||||
|
||||
function getBrowserOrigin() {
|
||||
if (typeof window !== 'undefined' && window.location?.origin) {
|
||||
return window.location.origin;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export const API_URL = (
|
||||
rawApiBase && rawApiBase.length > 0
|
||||
? rawApiBase
|
||||
: import.meta.env.DEV
|
||||
? getBrowserOrigin()
|
||||
: DEPLOYED_BACKEND_ORIGIN || getBrowserOrigin()
|
||||
).replace(/\/$/, '');
|
||||
|
||||
export const WS_URL = API_URL.replace(/^http/, 'ws');
|
||||
|
||||
export interface ScatterDataPoint {
|
||||
id: string;
|
||||
name: string;
|
||||
sentiment_score: number;
|
||||
response_time_ms: number;
|
||||
score: number;
|
||||
qualification: string;
|
||||
kanban_status: string;
|
||||
}
|
||||
|
||||
export interface LeadRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
source: string;
|
||||
notes: string;
|
||||
qualification: string;
|
||||
score: number;
|
||||
kanban_status: string;
|
||||
stage: string;
|
||||
budget: string;
|
||||
unit_interest: string;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface LeadDemographics {
|
||||
by_source: Array<{ source: string; lead_count: number; avg_score: number }>;
|
||||
by_qualification: Array<{ qualification: string; lead_count: number }>;
|
||||
}
|
||||
|
||||
export interface ChatLogRecord {
|
||||
id: string;
|
||||
lead_id: string;
|
||||
sender: string;
|
||||
channel: string;
|
||||
content: string;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface MarketingCampaignSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: 'meta' | 'google';
|
||||
status: 'active' | 'paused' | 'completed';
|
||||
budget: number;
|
||||
spent: number;
|
||||
impressions: number;
|
||||
clicks: number;
|
||||
conversions: number;
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
headers: buildVelocityHeaders(undefined, false),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
typeof body?.detail === 'string'
|
||||
? body.detail
|
||||
: typeof body?.message === 'string'
|
||||
? body.message
|
||||
: `Request failed: ${response.status}`,
|
||||
);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function requestWrappedData<T>(path: string): Promise<T> {
|
||||
const payload = await requestJson<{ data: T }>(path);
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
export async function getSentimentScatter(): Promise<ScatterDataPoint[]> {
|
||||
return requestJson<ScatterDataPoint[]>('/api/analytics/sentiment-scatter');
|
||||
}
|
||||
|
||||
export async function getCatalystCampaigns(): Promise<MarketingCampaignSummary[]> {
|
||||
return requestWrappedData<MarketingCampaignSummary[]>('/api/catalyst/campaigns');
|
||||
}
|
||||
|
||||
export async function getLeads(): Promise<LeadRecord[]> {
|
||||
const payload = await requestJson<{ data: LeadRecord[] }>('/api/leads');
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
export async function getLead(leadId: string): Promise<LeadRecord> {
|
||||
return requestWrappedData<LeadRecord>(`/api/leads/${leadId}`);
|
||||
}
|
||||
|
||||
export async function getKanbanBoard() {
|
||||
return requestWrappedData<Array<{ status: string; stage: string; count: number; items: LeadRecord[] }>>('/api/kanban/board');
|
||||
}
|
||||
|
||||
export async function getChatLogs(leadId?: string): Promise<ChatLogRecord[]> {
|
||||
const suffix = leadId ? `?lead_id=${encodeURIComponent(leadId)}` : '';
|
||||
return requestWrappedData<ChatLogRecord[]>(`/api/chat-logs${suffix}`);
|
||||
}
|
||||
|
||||
export async function getLeadDemographics(): Promise<LeadDemographics> {
|
||||
return requestWrappedData<LeadDemographics>('/api/leads/demographics');
|
||||
}
|
||||
52
webos/src/shared/lib/apiClient.ts
Normal file
52
webos/src/shared/lib/apiClient.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Velocity-OS API Client
|
||||
* Thin wrapper around fetch. Injects JWT auth header automatically.
|
||||
* All requests go to /api (proxied by Traefik to core-api:8443).
|
||||
*/
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
const BASE_URL = '/api';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
async function apiFetch<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const token = useAuthStore.getState().token;
|
||||
|
||||
const response = await fetch(`${BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
// Token expired — clear session and let AuthGuard redirect
|
||||
useAuthStore.getState().clearSession();
|
||||
throw new ApiError(401, 'Session expired');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '');
|
||||
throw new ApiError(response.status, body || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) return undefined as T;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => apiFetch<T>(path),
|
||||
post: <T>(path: string, body: unknown) => apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) => apiFetch<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
|
||||
};
|
||||
89
webos/src/shared/lib/commsApi.ts
Normal file
89
webos/src/shared/lib/commsApi.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { getVelocityToken } from './velocityPlatformClient';
|
||||
import type {
|
||||
CommsThread,
|
||||
CommsSettings,
|
||||
CommsProviderTestResult,
|
||||
SendMessagePayload,
|
||||
CommsThreadListResponse,
|
||||
CommsMessageListResponse,
|
||||
ThreadLinkPayload,
|
||||
} from '@/types/commsTypes';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
|
||||
|
||||
async function commsFetch(path: string, options?: RequestInit) {
|
||||
const token = getVelocityToken();
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function toQuery(params?: Record<string, string | number | undefined>) {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params ?? {}).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') query.set(key, String(value));
|
||||
});
|
||||
const serialized = query.toString();
|
||||
return serialized ? `?${serialized}` : '';
|
||||
}
|
||||
|
||||
export const fetchCommsThreads = (params?: { status?: string; search?: string; limit?: number; offset?: number }) =>
|
||||
commsFetch(`/api/comms/threads${toQuery(params)}`) as Promise<CommsThreadListResponse>;
|
||||
|
||||
export const fetchCommsThread = (threadId: string) =>
|
||||
commsFetch(`/api/comms/threads/${threadId}`) as Promise<CommsThread>;
|
||||
|
||||
export const fetchCommsMessages = (threadId: string, params?: { limit?: number; offset?: number }) =>
|
||||
commsFetch(`/api/comms/threads/${threadId}/messages${toQuery(params)}`) as Promise<CommsMessageListResponse>;
|
||||
|
||||
export const sendCommsMessage = (threadId: string, payload: SendMessagePayload) =>
|
||||
commsFetch(`/api/comms/threads/${threadId}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
export const linkCommsThreadToPerson = (threadId: string, payload: ThreadLinkPayload) =>
|
||||
commsFetch(`/api/comms/threads/${threadId}/link-person`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
export const addCommsThreadNote = (threadId: string, body: { content: string }) =>
|
||||
commsFetch(`/api/comms/threads/${threadId}/notes`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
export const addCommsThreadTask = (threadId: string, body: { title: string; dueAt?: string }) =>
|
||||
commsFetch(`/api/comms/threads/${threadId}/tasks`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
export const fetchCommsSettings = () =>
|
||||
commsFetch(`/api/comms/settings`) as Promise<CommsSettings>;
|
||||
|
||||
export const updateCommsSettings = (payload: Partial<CommsSettings>) =>
|
||||
commsFetch(`/api/comms/settings`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
export const testCommsProviderConnection = () =>
|
||||
commsFetch(`/api/comms/provider/test`, { method: 'POST' }) as Promise<CommsProviderTestResult>;
|
||||
|
||||
export const transcribeCommsRecording = (callId: string) =>
|
||||
commsFetch(`/api/comms/recordings/transcribe`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ callId }),
|
||||
});
|
||||
310
webos/src/shared/lib/crmApi.ts
Normal file
310
webos/src/shared/lib/crmApi.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
// app/src/lib/crmApi.ts
|
||||
// CRM API client — canonical CRM routes
|
||||
// Implements the frontend adapter layer from Doc 10 (TypeScript Module Spec)
|
||||
|
||||
import type {
|
||||
CrmContactListItem,
|
||||
CrmPerson,
|
||||
Client360Snapshot,
|
||||
CrmOpportunityCard,
|
||||
CrmTask,
|
||||
CrmLeadStageUpdate,
|
||||
KanbanColumn,
|
||||
ImportBatchSummary,
|
||||
ImportProposal,
|
||||
ImportReviewDecision,
|
||||
QdScoreEntry,
|
||||
OracleClientDataListItem,
|
||||
OracleClientDataDetail,
|
||||
OracleClientTimelineItem,
|
||||
} from '@/types/crmTypes';
|
||||
import { buildVelocityHeaders } from '@/lib/velocitySession';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '';
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
return Object.fromEntries(buildVelocityHeaders(undefined, false).entries());
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getAuthHeaders(),
|
||||
...(options?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail ?? `API error ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ── Contact List ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchContacts(params: {
|
||||
search?: string;
|
||||
buyer_type?: string;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ contacts: CrmContactListItem[]; total: number; limit: number; offset: number }> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set('search', params.search);
|
||||
if (params.buyer_type) qs.set('buyer_type', params.buyer_type);
|
||||
if (params.status) qs.set('status', params.status);
|
||||
if (params.limit != null) qs.set('limit', String(params.limit));
|
||||
if (params.offset != null) qs.set('offset', String(params.offset));
|
||||
const res = await apiFetch<{ status: string; data: { contacts: CrmContactListItem[]; total: number; limit: number; offset: number } }>(
|
||||
`/api/crm/contacts?${qs}`
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function fetchContact(personId: string): Promise<CrmPerson> {
|
||||
const res = await apiFetch<{ status: string; data: CrmPerson }>(`/api/crm/contacts/${personId}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// ── Client 360 ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchClient360(personId: string): Promise<Client360Snapshot> {
|
||||
const res = await apiFetch<{ status: string; data: Client360Snapshot }>(`/api/crm/client-360/${personId}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// ── Opportunities ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchOpportunities(params?: {
|
||||
stage?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<CrmOpportunityCard[]> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.stage) qs.set('stage', params.stage);
|
||||
if (params?.limit != null) qs.set('limit', String(params.limit));
|
||||
if (params?.offset != null) qs.set('offset', String(params.offset));
|
||||
const res = await apiFetch<{ status: string; data: CrmOpportunityCard[] }>(`/api/crm/opportunities?${qs}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateOpportunity(body: {
|
||||
opportunity_id: string;
|
||||
stage?: string;
|
||||
value?: number | null;
|
||||
probability?: number | null;
|
||||
expected_close_date?: string | null;
|
||||
next_action?: string | null;
|
||||
notes?: string | null;
|
||||
}): Promise<CrmOpportunityCard> {
|
||||
const { opportunity_id, ...payload } = body;
|
||||
const res = await apiFetch<{ status: string; data: CrmOpportunityCard }>(`/api/crm/opportunities/${opportunity_id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// ── Tasks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchTasks(params?: {
|
||||
status?: string;
|
||||
assigned_to?: string;
|
||||
limit?: number;
|
||||
}): Promise<CrmTask[]> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.status) qs.set('status', params.status);
|
||||
if (params?.assigned_to) qs.set('assigned_to', params.assigned_to);
|
||||
if (params?.limit != null) qs.set('limit', String(params.limit));
|
||||
const res = await apiFetch<{ status: string; data: CrmTask[] }>(`/api/crm/tasks?${qs}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createTask(body: {
|
||||
person_id: string;
|
||||
lead_id?: string;
|
||||
reminder_type?: string;
|
||||
title: string;
|
||||
notes?: string;
|
||||
due_at?: string;
|
||||
priority?: string;
|
||||
}): Promise<{ reminder_id: string; title: string }> {
|
||||
const res = await apiFetch<{ status: string; data: { reminder_id: string; title: string } }>('/api/crm/tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateTask(body: {
|
||||
reminder_id: string;
|
||||
status: 'pending' | 'done' | 'snoozed' | 'cancelled';
|
||||
due_at?: string;
|
||||
notes?: string;
|
||||
}): Promise<CrmTask> {
|
||||
const res = await apiFetch<{ status: string; data: CrmTask }>(`/api/crm/tasks/${body.reminder_id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
status: body.status,
|
||||
due_at: body.due_at,
|
||||
notes: body.notes,
|
||||
}),
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// ── Kanban ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchKanbanBoard(): Promise<KanbanColumn[]> {
|
||||
const res = await apiFetch<{ status: string; data: KanbanColumn[] }>('/api/crm/kanban');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function updateLeadStage(body: {
|
||||
lead_id: string;
|
||||
status: string;
|
||||
notes?: string;
|
||||
}): Promise<CrmLeadStageUpdate> {
|
||||
const res = await apiFetch<{ status: string; data: CrmLeadStageUpdate }>(`/api/crm/leads/${body.lead_id}/stage`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
status: body.status,
|
||||
notes: body.notes,
|
||||
}),
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// ── QD Scores ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchQdScore(personId: string): Promise<{
|
||||
person_id: string;
|
||||
scores: Record<string, QdScoreEntry>;
|
||||
timeseries: Array<{ score_type: string; value: number; timestamp: string | null; signal_source: string | null; delta: number | null }>;
|
||||
}> {
|
||||
const res = await apiFetch<{
|
||||
status: string;
|
||||
data: {
|
||||
person_id: string;
|
||||
scores: Record<string, QdScoreEntry>;
|
||||
timeseries: Array<{ score_type: string; value: number; timestamp: string | null; signal_source: string | null; delta: number | null }>;
|
||||
};
|
||||
}>(`/api/crm/qd/${personId}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// ── Import Batches ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function uploadCrmImport(file: File, sourceSystem = 'csv_upload'): Promise<{
|
||||
batch_id: string;
|
||||
row_count: number;
|
||||
mapped_columns: number;
|
||||
unmapped_columns: number;
|
||||
mapping_confidence: number;
|
||||
proposals_created: number;
|
||||
parse_errors: string[];
|
||||
lifecycle: string;
|
||||
message: string;
|
||||
}> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch(`${API_BASE}/api/crm/imports?source_system=${sourceSystem}`, {
|
||||
method: 'POST',
|
||||
headers: { ...getAuthHeaders() },
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail ?? `Upload error ${res.status}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export async function fetchImportBatches(lifecycle?: string): Promise<ImportBatchSummary[]> {
|
||||
const qs = lifecycle ? `?lifecycle=${lifecycle}` : '';
|
||||
const res = await apiFetch<{ status: string; data: ImportBatchSummary[] }>(`/api/crm/imports${qs}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function fetchImportBatch(batchId: string): Promise<{
|
||||
batch_id: string;
|
||||
filename: string;
|
||||
row_count: number;
|
||||
mapping_manifest: Record<string, unknown>;
|
||||
lifecycle: string;
|
||||
proposals: ImportProposal[];
|
||||
proposal_count: number;
|
||||
}> {
|
||||
const res = await apiFetch<{ status: string; data: ReturnType<typeof fetchImportBatch> extends Promise<infer R> ? R : never }>(`/api/crm/imports/${batchId}`);
|
||||
return res.data as Awaited<ReturnType<typeof fetchImportBatch>>;
|
||||
}
|
||||
|
||||
export async function reviewProposal(
|
||||
batchId: string,
|
||||
proposalId: string,
|
||||
decision: ImportReviewDecision,
|
||||
notes = ''
|
||||
): Promise<{ decision_id: string; decision: string }> {
|
||||
const res = await apiFetch<{ status: string; data: { decision_id: string; decision: string } }>(
|
||||
`/api/crm/imports/${batchId}/review-proposal`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ proposal_id: proposalId, decision, notes }),
|
||||
}
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function commitImportBatch(batchId: string): Promise<{
|
||||
committed: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
lifecycle: string;
|
||||
}> {
|
||||
const res = await apiFetch<{
|
||||
status: string;
|
||||
data: { committed: number; skipped: number; errors: string[]; lifecycle: string };
|
||||
}>(`/api/crm/imports/${batchId}/commit`, { method: 'POST' });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function fetchOracleClientData(params?: {
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ items: OracleClientDataListItem[]; count: number }> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.search) qs.set('search', params.search);
|
||||
if (params?.limit != null) qs.set('limit', String(params.limit));
|
||||
if (params?.offset != null) qs.set('offset', String(params.offset));
|
||||
const res = await apiFetch<{ status: string; data: OracleClientDataListItem[]; meta?: { count?: number } }>(
|
||||
`/api/crm/client-data?${qs}`,
|
||||
);
|
||||
return { items: res.data, count: res.meta?.count ?? res.data.length };
|
||||
}
|
||||
|
||||
export async function fetchOracleClientDataDetail(personId: string): Promise<OracleClientDataDetail> {
|
||||
const res = await apiFetch<{ status: string; data: OracleClientDataDetail }>(`/api/crm/client-data/${personId}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function patchOracleClientData(
|
||||
personId: string,
|
||||
patch: Record<string, string | null>,
|
||||
): Promise<{ person_id: string; updated: string[] }> {
|
||||
const res = await apiFetch<{ status: string; data: { person_id: string; updated: string[] } }>(
|
||||
`/api/crm/client-data/${personId}`,
|
||||
{ method: 'PATCH', body: JSON.stringify(patch) },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function fetchOracleClientTimeline(personId: string): Promise<OracleClientTimelineItem[]> {
|
||||
const res = await apiFetch<{ status: string; data: OracleClientTimelineItem[] }>(
|
||||
`/api/crm/client-data/${personId}/timeline`,
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
122
webos/src/shared/lib/crmMappers.ts
Normal file
122
webos/src/shared/lib/crmMappers.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { ChatLogRecord, LeadRecord } from '@/lib/api';
|
||||
import type { Lead } from '@/types';
|
||||
import type { LeadBadge, LeadTag, LeadSource, Message, MessageSender, PipelineStage, SentimentLog } from '@/types/crm';
|
||||
|
||||
const TAG_MAP: Record<string, LeadTag> = {
|
||||
whale: '#CashBuyer',
|
||||
potential: '#Investor',
|
||||
hot: '#EndUser',
|
||||
};
|
||||
|
||||
export function mapLeadRecordToStoreLead(record: LeadRecord): Lead {
|
||||
const qualification = record.qualification.toLowerCase() as Lead['qualification'];
|
||||
const status = record.stage === 'closed'
|
||||
? 'closed'
|
||||
: record.stage === 'qualified' || record.stage === 'negotiation'
|
||||
? 'qualified'
|
||||
: record.score >= 75
|
||||
? 'hot'
|
||||
: record.stage === 'new'
|
||||
? 'new'
|
||||
: 'engaged';
|
||||
const tags = Array.isArray(record.metadata?.tags) ? (record.metadata.tags as string[]) : [];
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
phone: record.phone ?? '',
|
||||
source: mapSource(record.source),
|
||||
status,
|
||||
lastMessage: record.notes || 'No conversation summary yet.',
|
||||
lastActive: new Date(record.updated_at ?? record.created_at ?? Date.now()),
|
||||
unreadCount: 0,
|
||||
qualification: qualification === 'tire_kicker' || qualification === 'potential' || qualification === 'whale'
|
||||
? qualification
|
||||
: 'potential',
|
||||
budget: record.budget,
|
||||
interest: record.unit_interest,
|
||||
quantumDynamicsScore: record.score,
|
||||
tags: tags.length > 0 ? tags : [record.qualification],
|
||||
};
|
||||
}
|
||||
|
||||
export function mapLeadRecordToOracleLead(record: LeadRecord, chatLogs: ChatLogRecord[]): import('@/types/crm').Lead {
|
||||
const badge = mapBadge(record.qualification);
|
||||
const tags = mapOracleTags(record.qualification, record.metadata);
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
phone: record.phone ?? '',
|
||||
stage: mapPipelineStage(record.stage),
|
||||
oracleScore: record.score,
|
||||
badge,
|
||||
tags,
|
||||
source: mapSource(record.source),
|
||||
budget: record.budget,
|
||||
unitInterest: record.unit_interest,
|
||||
profileImageUrl: `https://api.dicebear.com/9.x/glass/svg?seed=${encodeURIComponent(record.name)}`,
|
||||
visitedShowroom: record.stage === 'site_visit' || record.stage === 'negotiation' || record.stage === 'closed',
|
||||
inShowroomNow: record.stage === 'site_visit',
|
||||
messages: chatLogs.map(mapChatLogToOracleMessage),
|
||||
sentimentLog: buildSentimentLog(record.score, record.stage),
|
||||
};
|
||||
}
|
||||
|
||||
function mapSource(source: string): LeadSource {
|
||||
if (source === 'walkin' || source === 'website' || source === 'whatsapp') return source;
|
||||
return 'website';
|
||||
}
|
||||
|
||||
function mapPipelineStage(stage: string): PipelineStage {
|
||||
const normalized = stage.toLowerCase();
|
||||
if (normalized === 'new' || normalized === 'new_inquiries') return 'new_inquiries';
|
||||
if (normalized === 'qualified' || normalized === 'qualifying') return 'qualified';
|
||||
if (normalized === 'site_visit') return 'site_visit';
|
||||
if (normalized === 'negotiation') return 'negotiation';
|
||||
return 'closed';
|
||||
}
|
||||
|
||||
function mapBadge(qualification: string): LeadBadge | undefined {
|
||||
const normalized = qualification.toLowerCase();
|
||||
if (normalized === 'whale') return 'whale';
|
||||
if (normalized === 'hot' || normalized === 'potential') return 'hot';
|
||||
if (normalized === 'tire_kicker') return 'tire_kicker';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function mapOracleTags(qualification: string, metadata: Record<string, unknown>): LeadTag[] {
|
||||
const mapped = TAG_MAP[qualification.toLowerCase()];
|
||||
const rawTags = Array.isArray(metadata?.tags) ? metadata.tags as string[] : [];
|
||||
const canonical = rawTags.includes('#CashBuyer') || mapped === '#CashBuyer'
|
||||
? '#CashBuyer'
|
||||
: rawTags.includes('#EndUser') || mapped === '#EndUser'
|
||||
? '#EndUser'
|
||||
: '#Investor';
|
||||
return [canonical];
|
||||
}
|
||||
|
||||
function mapChatLogToOracleMessage(log: ChatLogRecord): Message {
|
||||
return {
|
||||
id: log.id,
|
||||
sender: mapSender(log.sender),
|
||||
content: log.content,
|
||||
createdAt: log.created_at ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function mapSender(sender: string): MessageSender {
|
||||
if (sender === 'lead' || sender === 'oracle' || sender === 'system') return sender;
|
||||
return 'system';
|
||||
}
|
||||
|
||||
function buildSentimentLog(score: number, stage: string): SentimentLog[] {
|
||||
const base = Math.max(20, score - 18);
|
||||
const labels = stage === 'site_visit'
|
||||
? ['Entry', 'Showroom peak', 'Pricing review']
|
||||
: ['Discovery', 'Qualification', 'Follow-up'];
|
||||
return labels.map((label, index) => ({
|
||||
id: `${stage}-${index}`,
|
||||
at: `${10 + index}:0${index}`,
|
||||
score: Math.min(100, base + index * 9),
|
||||
note: label,
|
||||
}));
|
||||
}
|
||||
197
webos/src/shared/lib/dreamWeaverApi.ts
Normal file
197
webos/src/shared/lib/dreamWeaverApi.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { API_URL } from '@/lib/api';
|
||||
import { buildVelocityHeaders } from '@/lib/velocitySession';
|
||||
|
||||
const rawDreamWeaverBase = import.meta.env.VITE_DREAM_WEAVER_URL?.trim();
|
||||
const rawDreamWeaverApiKey = import.meta.env.VITE_DREAM_WEAVER_API_KEY?.trim();
|
||||
const LOCAL_DREAM_WEAVER_GATEWAY = 'http://127.0.0.1:8082';
|
||||
|
||||
export const DREAM_WEAVER_URL = (rawDreamWeaverBase && rawDreamWeaverBase.length > 0
|
||||
? rawDreamWeaverBase
|
||||
: import.meta.env.DEV
|
||||
? LOCAL_DREAM_WEAVER_GATEWAY
|
||||
: API_URL
|
||||
).replace(/\/$/, '');
|
||||
|
||||
export interface DreamWeaverHealth {
|
||||
online: boolean;
|
||||
routeMounted: boolean;
|
||||
status: string;
|
||||
comfyuiOnline?: boolean;
|
||||
comfyuiUrl?: string;
|
||||
checkpointReady?: boolean;
|
||||
checkpointCount?: number;
|
||||
availableCheckpoints?: string[];
|
||||
preferredCheckpoints?: string[];
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface DreamWeaverJobResponse {
|
||||
job_id: string;
|
||||
status?: string;
|
||||
poll_url?: string;
|
||||
result_url?: string;
|
||||
}
|
||||
|
||||
export interface DreamWeaverStatusResponse {
|
||||
status?: string;
|
||||
ready?: boolean;
|
||||
result_url?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SubmitDreamWeaverJobInput {
|
||||
image: File;
|
||||
roomType: string;
|
||||
keywords: string;
|
||||
}
|
||||
|
||||
function buildDreamWeaverHeaders(init?: HeadersInit): Headers {
|
||||
const headers = buildVelocityHeaders(init, false);
|
||||
if (rawDreamWeaverApiKey && !headers.has('X-Dream-Weaver-API-Key')) {
|
||||
headers.set('X-Dream-Weaver-API-Key', rawDreamWeaverApiKey);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function resolveDreamWeaverUrl(candidate: string | undefined, fallbackPath: string): string {
|
||||
const path = candidate && candidate.trim().length > 0 ? candidate.trim() : fallbackPath;
|
||||
if (/^https?:\/\//i.test(path)) {
|
||||
return path;
|
||||
}
|
||||
return `${DREAM_WEAVER_URL}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response, fallback: string): Promise<string> {
|
||||
const body = await response.json().catch(() => null) as { detail?: unknown; message?: unknown; error?: unknown } | null;
|
||||
if (typeof body?.detail === 'string') return body.detail;
|
||||
if (typeof body?.message === 'string') return body.message;
|
||||
if (typeof body?.error === 'string') return body.error;
|
||||
const text = await response.text().catch(() => '');
|
||||
return text.trim() || fallback;
|
||||
}
|
||||
|
||||
async function requestDreamWeaverJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: buildDreamWeaverHeaders(init?.headers),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, `Dream Weaver request failed: ${response.status}`));
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function checkDreamWeaverHealth(): Promise<DreamWeaverHealth> {
|
||||
let status = 'offline';
|
||||
let detail: string | undefined;
|
||||
let comfyuiOnline: boolean | undefined;
|
||||
let comfyuiUrl: string | undefined;
|
||||
let checkpointReady: boolean | undefined;
|
||||
let checkpointCount: number | undefined;
|
||||
let availableCheckpoints: string[] | undefined;
|
||||
let preferredCheckpoints: string[] | undefined;
|
||||
let healthOk = false;
|
||||
|
||||
try {
|
||||
const response = await fetch(resolveDreamWeaverUrl(undefined, '/dream-weaver/health'), {
|
||||
headers: buildDreamWeaverHeaders(),
|
||||
});
|
||||
const body = await response.json().catch(() => null) as {
|
||||
status?: unknown;
|
||||
detail?: unknown;
|
||||
comfyui?: unknown;
|
||||
comfyui_url?: unknown;
|
||||
comfyuiUrl?: unknown;
|
||||
checkpoint_ready?: unknown;
|
||||
checkpoint_count?: unknown;
|
||||
available_checkpoints?: unknown;
|
||||
preferred_checkpoints?: unknown;
|
||||
} | null;
|
||||
status = typeof body?.status === 'string' ? body.status : response.ok ? 'ok' : `HTTP ${response.status}`;
|
||||
detail = typeof body?.detail === 'string' ? body.detail : undefined;
|
||||
comfyuiOnline = typeof body?.comfyui === 'boolean' ? body.comfyui : undefined;
|
||||
comfyuiUrl = typeof body?.comfyui_url === 'string'
|
||||
? body.comfyui_url
|
||||
: typeof body?.comfyuiUrl === 'string'
|
||||
? body.comfyuiUrl
|
||||
: undefined;
|
||||
checkpointReady = typeof body?.checkpoint_ready === 'boolean' ? body.checkpoint_ready : undefined;
|
||||
checkpointCount = typeof body?.checkpoint_count === 'number' ? body.checkpoint_count : undefined;
|
||||
availableCheckpoints = Array.isArray(body?.available_checkpoints)
|
||||
? body.available_checkpoints.filter((item): item is string => typeof item === 'string')
|
||||
: undefined;
|
||||
preferredCheckpoints = Array.isArray(body?.preferred_checkpoints)
|
||||
? body.preferred_checkpoints.filter((item): item is string => typeof item === 'string')
|
||||
: undefined;
|
||||
healthOk = response.ok && ['ok', 'healthy', 'online'].includes(status.toLowerCase());
|
||||
} catch (error) {
|
||||
detail = error instanceof Error ? error.message : 'Unable to reach Dream Weaver gateway.';
|
||||
}
|
||||
|
||||
try {
|
||||
const probe = await fetch(resolveDreamWeaverUrl(undefined, '/dream-weaver/status/velocity-route-probe'), {
|
||||
headers: buildDreamWeaverHeaders(),
|
||||
});
|
||||
if (probe.ok) {
|
||||
return { online: healthOk, routeMounted: true, status, comfyuiOnline, comfyuiUrl, checkpointReady, checkpointCount, availableCheckpoints, preferredCheckpoints, detail };
|
||||
}
|
||||
const probeMessage = await readErrorMessage(probe, '');
|
||||
const expectedMissingJob = probe.status === 404 && /job|not found|missing/i.test(probeMessage);
|
||||
return {
|
||||
online: healthOk && expectedMissingJob,
|
||||
routeMounted: expectedMissingJob,
|
||||
status,
|
||||
comfyuiOnline,
|
||||
comfyuiUrl,
|
||||
checkpointReady,
|
||||
checkpointCount,
|
||||
availableCheckpoints,
|
||||
preferredCheckpoints,
|
||||
detail: detail ?? probeMessage,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
online: false,
|
||||
routeMounted: false,
|
||||
status,
|
||||
comfyuiOnline,
|
||||
comfyuiUrl,
|
||||
checkpointReady,
|
||||
checkpointCount,
|
||||
availableCheckpoints,
|
||||
preferredCheckpoints,
|
||||
detail: error instanceof Error ? error.message : detail,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitDreamWeaverJob(input: SubmitDreamWeaverJobInput): Promise<DreamWeaverJobResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('image', input.image, input.image.name || 'room-source.jpg');
|
||||
formData.append('room_type', input.roomType);
|
||||
const trimmedKeywords = input.keywords.trim();
|
||||
if (trimmedKeywords.length > 0) {
|
||||
formData.append('keywords', trimmedKeywords);
|
||||
}
|
||||
|
||||
return requestDreamWeaverJson<DreamWeaverJobResponse>(resolveDreamWeaverUrl(undefined, '/dream-weaver'), {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDreamWeaverStatus(job: Pick<DreamWeaverJobResponse, 'job_id' | 'poll_url'>): Promise<DreamWeaverStatusResponse> {
|
||||
return requestDreamWeaverJson<DreamWeaverStatusResponse>(
|
||||
resolveDreamWeaverUrl(job.poll_url, `/dream-weaver/status/${encodeURIComponent(job.job_id)}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchDreamWeaverResult(jobId: string, resultUrl?: string): Promise<Blob> {
|
||||
const response = await fetch(resolveDreamWeaverUrl(resultUrl, `/dream-weaver/result/${encodeURIComponent(jobId)}`), {
|
||||
headers: buildDreamWeaverHeaders({ Accept: 'image/png,image/*,*/*' }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response, `Dream Weaver result failed: ${response.status}`));
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
139
webos/src/shared/lib/platformMappers.ts
Normal file
139
webos/src/shared/lib/platformMappers.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { InventoryPropertySummary } from '@/lib/velocityPlatformClient';
|
||||
import type { Unit } from '@/types';
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const cleaned = value.replace(/[^0-9.]/g, '');
|
||||
const parsed = Number(cleaned);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickFirstNumber(values: unknown[]): number | null {
|
||||
for (const value of values) {
|
||||
const parsed = asNumber(value);
|
||||
if (parsed !== null) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mapInventoryStatus(status: string): Unit['status'] {
|
||||
switch ((status ?? '').toLowerCase()) {
|
||||
case 'active':
|
||||
return 'available';
|
||||
case 'under_review':
|
||||
return 'reserved';
|
||||
case 'archived':
|
||||
return 'hold';
|
||||
default:
|
||||
return 'hold';
|
||||
}
|
||||
}
|
||||
|
||||
function inferArea(unitMix: unknown[]): number {
|
||||
for (const item of unitMix) {
|
||||
const record = asRecord(item);
|
||||
const area = pickFirstNumber([
|
||||
record.avg_area_sqm,
|
||||
record.avg_area,
|
||||
record.area_sqm,
|
||||
record.area,
|
||||
record.size_sqm,
|
||||
record.size,
|
||||
]);
|
||||
if (area !== null) {
|
||||
return Math.round(area);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function inferPrice(priceBands: unknown[]): number {
|
||||
for (const item of priceBands) {
|
||||
const record = asRecord(item);
|
||||
const price = pickFirstNumber([
|
||||
record.from,
|
||||
record.min,
|
||||
record.price,
|
||||
record.starting_price,
|
||||
record.amount,
|
||||
record.value,
|
||||
]);
|
||||
if (price !== null) {
|
||||
return Math.round(price);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function inferType(propertyType: string, unitMix: unknown[]): Unit['type'] {
|
||||
const normalizedPropertyType = (propertyType ?? '').toLowerCase();
|
||||
if (normalizedPropertyType.includes('penthouse')) return 'penthouse';
|
||||
if (normalizedPropertyType.includes('studio')) return 'studio';
|
||||
if (normalizedPropertyType.includes('1')) return '1br';
|
||||
if (normalizedPropertyType.includes('2')) return '2br';
|
||||
if (normalizedPropertyType.includes('3')) return '3br';
|
||||
|
||||
for (const item of unitMix) {
|
||||
const record = asRecord(item);
|
||||
const raw = String(
|
||||
record.type ?? record.unit_type ?? record.label ?? record.configuration ?? ''
|
||||
).toLowerCase();
|
||||
if (raw.includes('penthouse')) return 'penthouse';
|
||||
if (raw.includes('studio')) return 'studio';
|
||||
if (raw.includes('1')) return '1br';
|
||||
if (raw.includes('2')) return '2br';
|
||||
if (raw.includes('3')) return '3br';
|
||||
}
|
||||
|
||||
return '2br';
|
||||
}
|
||||
|
||||
function inferFloor(unitMix: unknown[]): number {
|
||||
for (const item of unitMix) {
|
||||
const record = asRecord(item);
|
||||
const floor = pickFirstNumber([record.floor, record.level, record.start_floor]);
|
||||
if (floor !== null) {
|
||||
return Math.round(floor);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function mapInventoryPropertySummaryToUnit(
|
||||
property: InventoryPropertySummary,
|
||||
index: number,
|
||||
): Unit {
|
||||
const location = asRecord(property.location);
|
||||
const unitMix = Array.isArray(property.unit_mix) ? property.unit_mix : [];
|
||||
const priceBands = Array.isArray(property.price_bands) ? property.price_bands : [];
|
||||
const district = typeof location.district === 'string' ? location.district : '';
|
||||
const city = typeof location.city === 'string' ? location.city : '';
|
||||
const view = [district, city].filter(Boolean).join(', ') || property.developer_name || 'Location pending';
|
||||
|
||||
return {
|
||||
id: property.property_id,
|
||||
unitNumber: property.project_name || `Property ${index + 1}`,
|
||||
type: inferType(property.property_type, unitMix),
|
||||
floor: inferFloor(unitMix),
|
||||
area: inferArea(unitMix),
|
||||
price: inferPrice(priceBands),
|
||||
status: mapInventoryStatus(property.status),
|
||||
view,
|
||||
lastUpdated: new Date(property.ingested_at ?? property.created_at ?? Date.now()),
|
||||
};
|
||||
}
|
||||
48
webos/src/shared/lib/utils.ts
Normal file
48
webos/src/shared/lib/utils.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatDistanceToNow(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return 'Just now';
|
||||
}
|
||||
|
||||
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
||||
if (diffInMinutes < 60) {
|
||||
return `${diffInMinutes}m ago`;
|
||||
}
|
||||
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
if (diffInHours < 24) {
|
||||
return `${diffInHours}h ago`;
|
||||
}
|
||||
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
if (diffInDays < 7) {
|
||||
return `${diffInDays}d ago`;
|
||||
}
|
||||
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
import { useCurrencyStore } from '@/store/useCurrencyStore';
|
||||
|
||||
export function formatCurrency(amount: number): string {
|
||||
return useCurrencyStore.getState().formatAmount(amount);
|
||||
}
|
||||
|
||||
export function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
330
webos/src/shared/lib/velocityPlatformClient.ts
Normal file
330
webos/src/shared/lib/velocityPlatformClient.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { API_URL } from '@/lib/api';
|
||||
import {
|
||||
buildVelocityHeaders,
|
||||
setVelocityToken,
|
||||
} from '@/lib/velocitySession';
|
||||
export {
|
||||
VELOCITY_TOKEN_KEY,
|
||||
clearVelocityToken,
|
||||
getVelocityToken,
|
||||
setVelocityToken,
|
||||
} from '@/lib/velocitySession';
|
||||
|
||||
export interface VelocityUserProfile {
|
||||
user_id: string;
|
||||
role: string;
|
||||
tenant_id?: string;
|
||||
full_name?: string | null;
|
||||
email?: string | null;
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
export interface VelocityActiveUser {
|
||||
user_id: string;
|
||||
role: string;
|
||||
tenant_id?: string;
|
||||
full_name?: string | null;
|
||||
email?: string | null;
|
||||
avatar_url?: string | null;
|
||||
}
|
||||
|
||||
export interface VelocityLoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
export interface AdminHealthSnapshot {
|
||||
status: string;
|
||||
timestamp: string;
|
||||
database: {
|
||||
connected: boolean;
|
||||
latency_ms: number;
|
||||
};
|
||||
queues: {
|
||||
pending_transcriptions: number;
|
||||
pending_synthetic_jobs: number;
|
||||
pending_admin_actions: number;
|
||||
pending_inventory_batches: number;
|
||||
};
|
||||
active_sessions: {
|
||||
total: number;
|
||||
by_surface: Record<string, number>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminQueueSnapshot {
|
||||
transcription_jobs: Record<string, number>;
|
||||
synthetic_jobs: Record<string, number>;
|
||||
inventory_batches: Record<string, number>;
|
||||
admin_actions: Record<string, number>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AdminInstallSnapshot {
|
||||
installs: Array<{
|
||||
surface_type: string;
|
||||
app_version: string;
|
||||
session_count: number;
|
||||
last_seen: string | null;
|
||||
}>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AdminActionRecord {
|
||||
action_event_id: string;
|
||||
action_id: string;
|
||||
action_type: string;
|
||||
target_type: string;
|
||||
target_id: string;
|
||||
requested_by: string;
|
||||
status: string;
|
||||
result_message?: string | null;
|
||||
executed_at?: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdminActionRequest {
|
||||
action_type: string;
|
||||
target_type: string;
|
||||
target_id: string;
|
||||
payload?: Record<string, unknown>;
|
||||
idempotency_key?: string;
|
||||
}
|
||||
|
||||
export interface MobileEdgeAlertSnapshot {
|
||||
pending_insights: number;
|
||||
upcoming_calendar_events_24h: number;
|
||||
pending_transcriptions: number;
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
export interface MobileCalendarEvent {
|
||||
calendar_event_id: string;
|
||||
lead_id?: string | null;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
all_day: boolean;
|
||||
status: string;
|
||||
reminder_minutes: number[];
|
||||
created_by: string;
|
||||
location?: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MobileCommunicationEvent {
|
||||
event_id: string;
|
||||
lead_id: string;
|
||||
channel: string;
|
||||
direction: string;
|
||||
provider?: string | null;
|
||||
capture_mode: string;
|
||||
consent_state: string;
|
||||
timestamp: string;
|
||||
duration_seconds?: number | null;
|
||||
summary?: string | null;
|
||||
raw_reference?: string | null;
|
||||
recording_ref?: string | null;
|
||||
provider_metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface InventoryImportBatchSummary {
|
||||
batch_id: string;
|
||||
source_type: string;
|
||||
submitted_by: string;
|
||||
status: string;
|
||||
total_rows: number;
|
||||
accepted_rows: number;
|
||||
rejected_rows: number;
|
||||
created_at: string;
|
||||
completed_at?: string | null;
|
||||
}
|
||||
|
||||
export interface InventoryPropertySummary {
|
||||
property_id: string;
|
||||
project_name: string;
|
||||
developer_name: string;
|
||||
property_type: string;
|
||||
location: Record<string, unknown>;
|
||||
price_bands: Array<Record<string, unknown>>;
|
||||
unit_mix: Array<Record<string, unknown>>;
|
||||
status: string;
|
||||
ingested_at?: string | null;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
function buildHeaders(init?: HeadersInit, includeJson = true): Headers {
|
||||
return buildVelocityHeaders(init, includeJson);
|
||||
}
|
||||
|
||||
async function platformFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
...init,
|
||||
headers: buildHeaders(init?.headers, init?.body !== undefined),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
typeof body?.detail === 'string'
|
||||
? body.detail
|
||||
: typeof body?.message === 'string'
|
||||
? body.message
|
||||
: `Request failed: ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function normalizeVelocityRole(role: string | null | undefined): string {
|
||||
return (role ?? '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
export function resolveVelocityFullName(profile: Pick<VelocityUserProfile, 'full_name' | 'email' | 'user_id'>): string {
|
||||
const fullName = profile.full_name?.trim();
|
||||
if (fullName) {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
const email = profile.email?.trim();
|
||||
if (email) {
|
||||
return email;
|
||||
}
|
||||
|
||||
return profile.user_id;
|
||||
}
|
||||
|
||||
export function resolveVelocityFirstName(profile: Pick<VelocityUserProfile, 'full_name' | 'email' | 'user_id'>): string {
|
||||
const fullName = profile.full_name?.trim();
|
||||
if (fullName) {
|
||||
return fullName.split(/\s+/)[0] ?? fullName;
|
||||
}
|
||||
|
||||
const email = profile.email?.trim();
|
||||
if (email) {
|
||||
const local = email.split('@')[0]?.trim() ?? '';
|
||||
if (local) {
|
||||
const normalized = local.replace(/[._-]+/g, ' ').trim();
|
||||
const firstToken = normalized.split(/\s+/)[0] ?? normalized;
|
||||
return firstToken.charAt(0).toUpperCase() + firstToken.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
return profile.user_id;
|
||||
}
|
||||
|
||||
export function isAdminRole(role: string | null | undefined): boolean {
|
||||
const normalized = normalizeVelocityRole(role);
|
||||
return normalized === 'ADMIN' || normalized === 'SUPERADMIN';
|
||||
}
|
||||
|
||||
export async function loginVelocity(email: string, password: string): Promise<VelocityUserProfile> {
|
||||
const auth = await platformFetch<VelocityLoginResponse>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
setVelocityToken(auth.access_token);
|
||||
return getVelocityMe();
|
||||
}
|
||||
|
||||
export async function getVelocityMe(): Promise<VelocityUserProfile> {
|
||||
return platformFetch<VelocityUserProfile>('/api/auth/me', {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
export async function listVelocityUsers(): Promise<VelocityActiveUser[]> {
|
||||
return platformFetch<VelocityActiveUser[]>('/api/auth/users', {
|
||||
method: 'GET',
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadVelocityAvatar(file: File): Promise<{ avatar_url: string }> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
|
||||
const response = await fetch(`${API_URL}/api/auth/profile/avatar`, {
|
||||
method: 'POST',
|
||||
headers: buildHeaders(undefined, false),
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
typeof body?.detail === 'string'
|
||||
? body.detail
|
||||
: typeof body?.message === 'string'
|
||||
? body.message
|
||||
: `Request failed: ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<{ avatar_url: string }>;
|
||||
}
|
||||
|
||||
export async function getAdminHealth(): Promise<AdminHealthSnapshot> {
|
||||
return platformFetch<AdminHealthSnapshot>('/api/admin-surface/health');
|
||||
}
|
||||
|
||||
export async function getAdminQueues(): Promise<AdminQueueSnapshot> {
|
||||
return platformFetch<AdminQueueSnapshot>('/api/admin-surface/queues');
|
||||
}
|
||||
|
||||
export async function getAdminInstalls(): Promise<AdminInstallSnapshot> {
|
||||
return platformFetch<AdminInstallSnapshot>('/api/admin-surface/installs');
|
||||
}
|
||||
|
||||
export async function listAdminActions(limit = 20): Promise<{ actions: AdminActionRecord[] }> {
|
||||
return platformFetch<{ actions: AdminActionRecord[] }>(
|
||||
`/api/admin-surface/actions?limit=${encodeURIComponent(String(limit))}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function submitAdminAction(body: AdminActionRequest): Promise<{
|
||||
action_event_id: string;
|
||||
action_id: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}> {
|
||||
return platformFetch('/api/admin-surface/actions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMobileAlerts(): Promise<MobileEdgeAlertSnapshot> {
|
||||
return platformFetch<MobileEdgeAlertSnapshot>('/api/mobile-edge/alerts');
|
||||
}
|
||||
|
||||
export async function getMobileCalendarEvents(): Promise<{ events: MobileCalendarEvent[] }> {
|
||||
return platformFetch<{ events: MobileCalendarEvent[] }>('/api/mobile-edge/calendar');
|
||||
}
|
||||
|
||||
export async function getMobileEventsByLead(
|
||||
leadId: string,
|
||||
limit = 20,
|
||||
): Promise<{ events: MobileCommunicationEvent[] }> {
|
||||
const params = new URLSearchParams({
|
||||
lead_id: leadId,
|
||||
limit: String(limit),
|
||||
});
|
||||
return platformFetch<{ events: MobileCommunicationEvent[] }>(`/api/mobile-edge/events?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function listInventoryImportBatches(limit = 10): Promise<{ batches: InventoryImportBatchSummary[] }> {
|
||||
return platformFetch<{ batches: InventoryImportBatchSummary[] }>(
|
||||
`/api/inventory/import-batches?limit=${encodeURIComponent(String(limit))}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listInventoryProperties(limit = 100): Promise<{ properties: InventoryPropertySummary[] }> {
|
||||
return platformFetch<{ properties: InventoryPropertySummary[] }>(
|
||||
`/api/inventory/properties?limit=${encodeURIComponent(String(limit))}`,
|
||||
);
|
||||
}
|
||||
37
webos/src/shared/lib/velocitySession.ts
Normal file
37
webos/src/shared/lib/velocitySession.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export const VELOCITY_TOKEN_KEY = 'velocity-api-token';
|
||||
|
||||
export function getVelocityToken(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return window.localStorage.getItem(VELOCITY_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setVelocityToken(token: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(VELOCITY_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearVelocityToken() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.removeItem(VELOCITY_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function buildVelocityHeaders(init?: HeadersInit, includeJson = true): Headers {
|
||||
const headers = new Headers(init);
|
||||
if (includeJson && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
if (!headers.has('Accept')) {
|
||||
headers.set('Accept', 'application/json');
|
||||
}
|
||||
const token = getVelocityToken();
|
||||
if (token && !headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
Reference in New Issue
Block a user