Build the Solaire inventory intelligence MVP
Some checks failed
CI / verify (push) Has been cancelled
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:
44
src/integrations/mock.ts
Normal file
44
src/integrations/mock.ts
Normal 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 : []);
|
||||
}
|
||||
}
|
||||
63
src/integrations/odoo/client.ts
Normal file
63
src/integrations/odoo/client.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
46
src/integrations/salesforce/client.ts
Normal file
46
src/integrations/salesforce/client.ts
Normal 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
45
src/integrations/sync.ts
Normal 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
33
src/integrations/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user