Build the Solaire inventory intelligence MVP
Some checks failed
CI / verify (push) Has been cancelled

Add the tenant-ready dashboard, freshness and pricing analytics, mock integration adapters, persistence schema, and verification tooling needed for the initial pilot.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-25 12:16:52 -07:00
parent bf336f8d15
commit 135be480e5
58 changed files with 7884 additions and 265 deletions

44
src/integrations/mock.ts Normal file
View File

@@ -0,0 +1,44 @@
import {
branches,
inventoryBatches,
products,
sales,
salespeople,
} from "@/lib/demo-data";
import type { IntegrationProvider } from "@/lib/types";
import type { IntegrationAdapter, SyncPage } from "@/integrations/types";
function page<T>(records: T[]): SyncPage<T> {
return { records, nextCursor: null };
}
export class MockIntegrationAdapter implements IntegrationAdapter {
constructor(readonly provider: IntegrationProvider) {}
async testConnection() {
return {
ok: true,
message: `${this.provider === "odoo" ? "Odoo" : "Salesforce"} demo source is ready`,
};
}
async fetchProducts() {
return page(this.provider === "odoo" ? products : []);
}
async fetchBranches() {
return page(branches);
}
async fetchInventory() {
return page(this.provider === "odoo" ? inventoryBatches : []);
}
async fetchSalespeople() {
return page(this.provider === "salesforce" ? salespeople : []);
}
async fetchSales() {
return page(this.provider === "salesforce" ? sales : []);
}
}

View File

@@ -0,0 +1,63 @@
import { z } from "zod";
export const odooConfigSchema = z.object({
baseUrl: z.string().url(),
apiKey: z.string().min(8),
database: z.string().optional(),
apiMode: z.enum(["json2", "legacy-rpc"]).default("json2"),
});
export type OdooConfig = z.infer<typeof odooConfigSchema>;
export class OdooClient {
private readonly config: OdooConfig;
constructor(config: OdooConfig) {
this.config = odooConfigSchema.parse(config);
}
async call<T>(
model: string,
method: string,
body: Record<string, unknown>,
): Promise<T> {
if (this.config.apiMode !== "json2") {
throw new Error(
"Legacy RPC requires an Odoo version-specific adapter; configure it after discovery",
);
}
const headers: Record<string, string> = {
Authorization: `bearer ${this.config.apiKey}`,
"Content-Type": "application/json",
};
if (this.config.database) {
headers["X-Odoo-Database"] = this.config.database;
}
const response = await fetch(
`${this.config.baseUrl.replace(/\/$/, "")}/json/2/${model}/${method}`,
{
method: "POST",
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(15_000),
},
);
if (!response.ok) {
throw new Error(`Odoo ${model}.${method} failed with ${response.status}`);
}
return response.json() as Promise<T>;
}
async testConnection() {
await this.call("res.users", "read", {
ids: [],
fields: ["id"],
context: {},
});
return { ok: true, message: "Connected to Odoo JSON-2" };
}
}

View File

@@ -0,0 +1,46 @@
import { z } from "zod";
export const salesforceConfigSchema = z.object({
instanceUrl: z.string().url(),
accessToken: z.string().min(8),
apiVersion: z.string().regex(/^v\d+\.\d+$/).default("v67.0"),
});
export type SalesforceConfig = z.infer<typeof salesforceConfigSchema>;
export class SalesforceClient {
private readonly config: SalesforceConfig;
constructor(config: SalesforceConfig) {
this.config = salesforceConfigSchema.parse(config);
}
private async request<T>(path: string): Promise<T> {
const response = await fetch(
`${this.config.instanceUrl.replace(/\/$/, "")}/services/data/${this.config.apiVersion}/${path}`,
{
headers: {
Authorization: `Bearer ${this.config.accessToken}`,
Accept: "application/json",
},
signal: AbortSignal.timeout(15_000),
},
);
if (!response.ok) {
throw new Error(`Salesforce request failed with ${response.status}`);
}
return response.json() as Promise<T>;
}
async query<T>(soql: string) {
return this.request<{ records: T[]; done: boolean; nextRecordsUrl?: string }>(
`query?q=${encodeURIComponent(soql)}`,
);
}
async testConnection() {
await this.request("limits");
return { ok: true, message: "Connected to Salesforce" };
}
}

45
src/integrations/sync.ts Normal file
View File

@@ -0,0 +1,45 @@
import { MockIntegrationAdapter } from "@/integrations/mock";
import type { IntegrationAdapter, SyncSummary } from "@/integrations/types";
import type { IntegrationProvider } from "@/lib/types";
async function countAll(adapter: IntegrationAdapter) {
const results = await Promise.all([
adapter.fetchProducts(),
adapter.fetchBranches(),
adapter.fetchInventory(),
adapter.fetchSalespeople(),
adapter.fetchSales(),
]);
return results.reduce((sum, result) => sum + result.records.length, 0);
}
export async function runDemoSync(
provider: IntegrationProvider,
): Promise<SyncSummary> {
const startedAt = new Date().toISOString();
const adapter = new MockIntegrationAdapter(provider);
try {
const connection = await adapter.testConnection();
const recordsRead = await countAll(adapter);
return {
provider,
startedAt,
finishedAt: new Date().toISOString(),
recordsRead,
recordsWritten: recordsRead,
status: "succeeded",
message: `${connection.message}. ${recordsRead} normalized records upserted idempotently.`,
};
} catch (error) {
return {
provider,
startedAt,
finishedAt: new Date().toISOString(),
recordsRead: 0,
recordsWritten: 0,
status: "failed",
message: error instanceof Error ? error.message : "Unknown sync failure",
};
}
}

33
src/integrations/types.ts Normal file
View File

@@ -0,0 +1,33 @@
import type {
Branch,
IntegrationProvider,
InventoryBatch,
Product,
Sale,
Salesperson,
} from "@/lib/types";
export interface SyncPage<T> {
records: T[];
nextCursor: string | null;
}
export interface IntegrationAdapter {
readonly provider: IntegrationProvider;
testConnection(): Promise<{ ok: boolean; message: string }>;
fetchProducts(cursor?: string): Promise<SyncPage<Product>>;
fetchBranches(cursor?: string): Promise<SyncPage<Branch>>;
fetchInventory(cursor?: string): Promise<SyncPage<InventoryBatch>>;
fetchSalespeople(cursor?: string): Promise<SyncPage<Salesperson>>;
fetchSales(cursor?: string): Promise<SyncPage<Sale>>;
}
export interface SyncSummary {
provider: IntegrationProvider;
startedAt: string;
finishedAt: string;
recordsRead: number;
recordsWritten: number;
status: "succeeded" | "failed";
message: string;
}