feat/#24 WebOS Completion (#25)
#24 WebOS Completion Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: #25
This commit was merged in pull request #25.
This commit is contained in:
@@ -117,19 +117,3 @@ export async function getChatLogs(leadId?: string): Promise<ChatLogRecord[]> {
|
||||
export async function getLeadDemographics(): Promise<LeadDemographics> {
|
||||
return requestWrappedData<LeadDemographics>('/api/leads/demographics');
|
||||
}
|
||||
|
||||
export async function seedSyntheticLeads(count = 100): Promise<{ seeded: number; chat_logs_seeded: number; batch: string }> {
|
||||
const response = await fetch(`${API_URL}/api/leads/seed-synthetic`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ count }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Seed request failed: ${response.status}`);
|
||||
}
|
||||
const payload = await response.json() as { data: { seeded: number; chat_logs_seeded: number; batch: string } };
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
export type OracleCanvasView =
|
||||
| 'pipeline'
|
||||
| 'team_performance'
|
||||
| 'account_timeline'
|
||||
| 'lead_map'
|
||||
| 'calendar_tasks';
|
||||
|
||||
export interface OracleQueryPayload {
|
||||
prompt: string;
|
||||
history: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
mode: 'cot-rag';
|
||||
preferredView?: OracleCanvasView;
|
||||
}
|
||||
|
||||
export interface PipelineCardData {
|
||||
id: string;
|
||||
name: string;
|
||||
company: string;
|
||||
value: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export interface TeamMemberData {
|
||||
id: string;
|
||||
name: string;
|
||||
dealsClosed: number;
|
||||
revenueGenerated: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export interface TimelineEvent {
|
||||
id: string;
|
||||
type: 'email' | 'meeting' | 'call';
|
||||
title: string;
|
||||
when: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface CalendarEventData {
|
||||
id: string;
|
||||
day: string;
|
||||
time: string;
|
||||
title: string;
|
||||
suggested?: boolean;
|
||||
}
|
||||
|
||||
export interface OracleQueryResult {
|
||||
view: OracleCanvasView;
|
||||
insight: string;
|
||||
summary: string;
|
||||
payload: {
|
||||
pipeline?: Record<string, PipelineCardData[]>;
|
||||
revenueSeries?: Array<{ month: string; revenue: number; goal: number }>;
|
||||
quotaAttainment?: number;
|
||||
team?: TeamMemberData[];
|
||||
account?: {
|
||||
name: string;
|
||||
totalDealValue: string;
|
||||
primaryContact: string;
|
||||
industry: string;
|
||||
contacts: Array<{ name: string; role: string; avatar: string }>;
|
||||
timeline: TimelineEvent[];
|
||||
};
|
||||
map?: {
|
||||
region: string;
|
||||
pins: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
temperature: 'cold' | 'warm' | 'hot';
|
||||
count?: number;
|
||||
}>;
|
||||
};
|
||||
calendar?: {
|
||||
weekLabel: string;
|
||||
events: CalendarEventData[];
|
||||
tasks: Array<{ id: string; title: string; subtitle: string; due: string }>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_ORACLE_RESULT: OracleQueryResult = {
|
||||
view: 'pipeline',
|
||||
insight: 'Pipeline Velocity: Average deal cycle is 21 days, 10% faster than Q3.',
|
||||
summary: 'Pipeline view generated for Q4 by stage.',
|
||||
payload: {
|
||||
pipeline: {
|
||||
'New Leads': [
|
||||
{
|
||||
id: 'n1',
|
||||
name: 'Elena Rostova',
|
||||
company: 'Rostova Ventures',
|
||||
value: '$120k',
|
||||
avatar:
|
||||
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80',
|
||||
},
|
||||
{
|
||||
id: 'n2',
|
||||
name: 'Mary Iluskimon',
|
||||
company: 'Nexloop',
|
||||
value: '$130k',
|
||||
avatar:
|
||||
'https://images.unsplash.com/photo-1554151228-14d9def656e4?auto=format&fit=crop&w=80&q=80',
|
||||
},
|
||||
],
|
||||
Qualified: [
|
||||
{
|
||||
id: 'q1',
|
||||
name: 'Etlena Roya',
|
||||
company: 'Mianaperson',
|
||||
value: '$120k',
|
||||
avatar:
|
||||
'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&w=80&q=80',
|
||||
},
|
||||
{
|
||||
id: 'q2',
|
||||
name: 'Silver Rostova',
|
||||
company: 'Silverline Co',
|
||||
value: '$130k',
|
||||
avatar:
|
||||
'https://images.unsplash.com/photo-1542206395-9feb3edaa68d?auto=format&fit=crop&w=80&q=80',
|
||||
},
|
||||
],
|
||||
'Proposal Sent': [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'Magulanta Senneciton',
|
||||
company: 'Senneciton',
|
||||
value: '$140k',
|
||||
avatar:
|
||||
'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&w=80&q=80',
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
name: 'Minatie Ganrison',
|
||||
company: 'Ganrison Group',
|
||||
value: '$130k',
|
||||
avatar:
|
||||
'https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=crop&w=80&q=80',
|
||||
},
|
||||
],
|
||||
Negotiation: [
|
||||
{
|
||||
id: 'g1',
|
||||
name: 'Jomath Bilotmberg',
|
||||
company: 'Biotmberg',
|
||||
value: '$130k',
|
||||
avatar:
|
||||
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=80&q=80',
|
||||
},
|
||||
{
|
||||
id: 'g2',
|
||||
name: 'Josen Oateliars',
|
||||
company: 'Oateliars',
|
||||
value: '$100k',
|
||||
avatar:
|
||||
'https://images.unsplash.com/photo-1560250097-0b93528c311a?auto=format&fit=crop&w=80&q=80',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const VIEW_TO_PROMPT: Record<OracleCanvasView, string> = {
|
||||
pipeline: 'Show me a pipeline view by stage for Q4.',
|
||||
team_performance: "What's the performance of the sales team this month?",
|
||||
account_timeline: "Find all contacts at 'Apex Innovations' and their recent activity.",
|
||||
lead_map: 'Give me a map of all leads in California.',
|
||||
calendar_tasks: 'Schedule a follow-up with the top 3 high-value leads.',
|
||||
};
|
||||
|
||||
export function mockOracleResultForPrompt(prompt: string): OracleQueryResult {
|
||||
const text = prompt.toLowerCase();
|
||||
if (text.includes('performance') || text.includes('team')) {
|
||||
return {
|
||||
view: 'team_performance',
|
||||
insight: 'Team is on track to exceed monthly quota by 15%.',
|
||||
summary: 'Performance dashboard for current month.',
|
||||
payload: {
|
||||
revenueSeries: [
|
||||
{ month: 'Jan', revenue: 10, goal: 20 },
|
||||
{ month: 'Feb', revenue: 30, goal: 35 },
|
||||
{ month: 'Mar', revenue: 28, goal: 40 },
|
||||
{ month: 'Sep', revenue: 52, goal: 55 },
|
||||
{ month: 'Oct', revenue: 56, goal: 60 },
|
||||
{ month: 'Nov', revenue: 74, goal: 70 },
|
||||
{ month: 'Dec', revenue: 88, goal: 80 },
|
||||
],
|
||||
quotaAttainment: 85,
|
||||
team: [
|
||||
{
|
||||
id: 't1',
|
||||
name: 'Elena Rostova',
|
||||
dealsClosed: 12,
|
||||
revenueGenerated: '$1.2M',
|
||||
avatar:
|
||||
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80',
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
name: 'Etlena Roya',
|
||||
dealsClosed: 12,
|
||||
revenueGenerated: '$1.2M',
|
||||
avatar:
|
||||
'https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?auto=format&fit=crop&w=80&q=80',
|
||||
},
|
||||
{
|
||||
id: 't3',
|
||||
name: 'Minatie Ganrison',
|
||||
dealsClosed: 13,
|
||||
revenueGenerated: '$1.2M',
|
||||
avatar:
|
||||
'https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=crop&w=80&q=80',
|
||||
},
|
||||
{
|
||||
id: 't4',
|
||||
name: 'Josen Oateliars',
|
||||
dealsClosed: 18,
|
||||
revenueGenerated: '$0.8M',
|
||||
avatar:
|
||||
'https://images.unsplash.com/photo-1560250097-0b93528c311a?auto=format&fit=crop&w=80&q=80',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (text.includes('apex') || text.includes('activity') || text.includes('contacts')) {
|
||||
return {
|
||||
view: 'account_timeline',
|
||||
insight: "Action: Schedule a check-in call with Apex's CEO regarding the proposal.",
|
||||
summary: 'Account history and associated contacts for Apex Innovations.',
|
||||
payload: {
|
||||
account: {
|
||||
name: 'Apex Innovations',
|
||||
totalDealValue: '$4.5M',
|
||||
primaryContact: 'Elena Rostova, CEO',
|
||||
industry: 'Technology',
|
||||
contacts: [
|
||||
{
|
||||
name: 'Elena Rostova',
|
||||
role: 'CEO',
|
||||
avatar:
|
||||
'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80',
|
||||
},
|
||||
{
|
||||
name: 'Mary Iluskimon',
|
||||
role: 'COO',
|
||||
avatar:
|
||||
'https://images.unsplash.com/photo-1554151228-14d9def656e4?auto=format&fit=crop&w=80&q=80',
|
||||
},
|
||||
{
|
||||
name: 'Entin Veenos',
|
||||
role: 'VP Finance',
|
||||
avatar:
|
||||
'https://images.unsplash.com/photo-1560250097-0b93528c311a?auto=format&fit=crop&w=80&q=80',
|
||||
},
|
||||
],
|
||||
timeline: [
|
||||
{
|
||||
id: 'a1',
|
||||
type: 'email',
|
||||
title: 'Email Sent',
|
||||
when: 'Today, 10:30 AM',
|
||||
summary: 'Proposal Follow-up',
|
||||
},
|
||||
{
|
||||
id: 'a2',
|
||||
type: 'meeting',
|
||||
title: 'Meeting',
|
||||
when: 'Yesterday, 2:00 PM',
|
||||
summary: 'Q4 Strategy',
|
||||
},
|
||||
{
|
||||
id: 'a3',
|
||||
type: 'call',
|
||||
title: 'Call Logged',
|
||||
when: 'Yesterday, 6:20 PM',
|
||||
summary: 'Discussed pricing',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (text.includes('map') || text.includes('california') || text.includes('geographic')) {
|
||||
return {
|
||||
view: 'lead_map',
|
||||
insight: 'Insight: 60% of high-value leads are concentrated in the Bay Area.',
|
||||
summary: 'Geographic lead distribution in California.',
|
||||
payload: {
|
||||
map: {
|
||||
region: 'California',
|
||||
pins: [
|
||||
{ id: 'm1', label: 'SF', x: 26, y: 32, temperature: 'warm', count: 24 },
|
||||
{ id: 'm2', label: 'Oakland', x: 29, y: 35, temperature: 'cold', count: 19 },
|
||||
{ id: 'm3', label: 'San Jose', x: 32, y: 42, temperature: 'hot' },
|
||||
{ id: 'm4', label: 'LA', x: 44, y: 78, temperature: 'warm', count: 8 },
|
||||
{ id: 'm5', label: 'San Diego', x: 46, y: 88, temperature: 'cold' },
|
||||
{ id: 'm6', label: 'Sacramento', x: 36, y: 28, temperature: 'hot' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (text.includes('schedule') || text.includes('calendar') || text.includes('follow-up')) {
|
||||
return {
|
||||
view: 'calendar_tasks',
|
||||
insight:
|
||||
"Scheduling: Proposed times minimize conflicts and align with contact's preferred hours.",
|
||||
summary: 'Weekly calendar and follow-up actions generated.',
|
||||
payload: {
|
||||
calendar: {
|
||||
weekLabel: 'Week 21',
|
||||
events: [
|
||||
{ id: 'c1', day: 'Mon', time: '10:00', title: 'Elena Rostova' },
|
||||
{ id: 'c2', day: 'Tue', time: '12:00', title: 'Appointments' },
|
||||
{ id: 'c3', day: 'Wed', time: '13:00', title: 'Follow-up', suggested: true },
|
||||
{ id: 'c4', day: 'Thu', time: '14:00', title: 'Meeting' },
|
||||
{ id: 'c5', day: 'Fri', time: '12:00', title: 'Follow-up', suggested: true },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'k1', title: 'Follow-up', subtitle: 'Elena Rostova', due: 'Due Today' },
|
||||
{ id: 'k2', title: 'Prepare Proposal', subtitle: 'Apex Innovations', due: 'Due Tomorrow' },
|
||||
{ id: 'k3', title: 'Confirm Slot', subtitle: 'Mr. Kapoor', due: 'Due Today' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return DEFAULT_ORACLE_RESULT;
|
||||
}
|
||||
|
||||
export async function queryOracle(payload: OracleQueryPayload): Promise<OracleQueryResult> {
|
||||
const endpoint = import.meta.env.VITE_ORACLE_QUERY_URL;
|
||||
if (!endpoint) {
|
||||
if (payload.preferredView) {
|
||||
return mockOracleResultForPrompt(VIEW_TO_PROMPT[payload.preferredView]);
|
||||
}
|
||||
return mockOracleResultForPrompt(payload.prompt);
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Oracle query failed with ${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as OracleQueryResult;
|
||||
}
|
||||
139
app/src/lib/platformMappers.ts
Normal file
139
app/src/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()),
|
||||
};
|
||||
}
|
||||
269
app/src/lib/velocityPlatformClient.ts
Normal file
269
app/src/lib/velocityPlatformClient.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { API_URL } from '@/lib/api';
|
||||
|
||||
export const VELOCITY_TOKEN_KEY = 'velocity-api-token';
|
||||
|
||||
export interface VelocityUserProfile {
|
||||
user_id: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
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 setVelocityToken(token: string) {
|
||||
localStorage.setItem(VELOCITY_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function getVelocityToken(): string | null {
|
||||
return localStorage.getItem(VELOCITY_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function clearVelocityToken() {
|
||||
localStorage.removeItem(VELOCITY_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function normalizeVelocityRole(role: string | null | undefined): string {
|
||||
return (role ?? '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
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 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))}`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user