464 lines
17 KiB
TypeScript
464 lines
17 KiB
TypeScript
/**
|
|
* Cart module — cosmolocal print-on-demand shop.
|
|
*
|
|
* Ported from /opt/apps/rcart/ (Express → Hono).
|
|
* Handles catalog (artifact listings), orders, fulfillment resolution.
|
|
* Integrates with provider-registry for provider matching and flow-service for revenue splits.
|
|
*/
|
|
|
|
import { Hono } from "hono";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { sql } from "../../shared/db/pool";
|
|
import { renderShell } from "../../server/shell";
|
|
import { getModuleInfoList } from "../../shared/module";
|
|
import { depositOrderRevenue } from "./flow";
|
|
import type { RSpaceModule } from "../../shared/module";
|
|
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
|
|
|
const routes = new Hono();
|
|
|
|
// ── DB initialization ──
|
|
const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8");
|
|
|
|
async function initDB() {
|
|
try {
|
|
await sql.unsafe(SCHEMA_SQL);
|
|
console.log("[Cart] DB schema initialized");
|
|
} catch (e) {
|
|
console.error("[Cart] DB init error:", e);
|
|
}
|
|
}
|
|
|
|
initDB();
|
|
|
|
// Provider registry URL (for fulfillment resolution)
|
|
const PROVIDER_REGISTRY_URL = process.env.PROVIDER_REGISTRY_URL || "";
|
|
|
|
function getProviderUrl(): string {
|
|
// In unified mode, providers module is co-located — call its routes directly via internal URL
|
|
// In standalone mode, use PROVIDER_REGISTRY_URL env
|
|
return PROVIDER_REGISTRY_URL || "http://localhost:3000/demo/providers";
|
|
}
|
|
|
|
// ── CATALOG ROUTES ──
|
|
|
|
// POST /api/catalog/ingest — Add artifact to catalog
|
|
routes.post("/api/catalog/ingest", async (c) => {
|
|
const artifact = await c.req.json();
|
|
|
|
if (!artifact.id || !artifact.schema_version || !artifact.type) {
|
|
return c.json({ error: "Invalid artifact envelope. Required: id, schema_version, type" }, 400);
|
|
}
|
|
if (artifact.type !== "print-ready") {
|
|
return c.json({ error: `Only 'print-ready' artifacts can be listed. Got: '${artifact.type}'` }, 400);
|
|
}
|
|
if (!artifact.render_targets || Object.keys(artifact.render_targets).length === 0) {
|
|
return c.json({ error: "print-ready artifacts must have at least one render_target" }, 400);
|
|
}
|
|
|
|
const existing = await sql.unsafe("SELECT id FROM rcart.catalog_entries WHERE artifact_id = $1", [artifact.id]);
|
|
if (existing.length > 0) {
|
|
return c.json({ error: "Artifact already listed", catalog_entry_id: existing[0].id }, 409);
|
|
}
|
|
|
|
const result = await sql.unsafe(
|
|
`INSERT INTO rcart.catalog_entries (
|
|
artifact_id, artifact, title, product_type,
|
|
required_capabilities, substrates, creator_id,
|
|
source_space, tags
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING id, artifact_id, title, product_type, status, created_at`,
|
|
[
|
|
artifact.id, JSON.stringify(artifact),
|
|
artifact.payload?.title || "Untitled",
|
|
artifact.spec?.product_type || null,
|
|
artifact.spec?.required_capabilities || [],
|
|
artifact.spec?.substrates || [],
|
|
artifact.creator?.id || null,
|
|
artifact.source_space || null,
|
|
artifact.payload?.tags || [],
|
|
]
|
|
);
|
|
|
|
return c.json(result[0], 201);
|
|
});
|
|
|
|
// GET /api/catalog — Browse catalog
|
|
routes.get("/api/catalog", async (c) => {
|
|
const { product_type, capability, tag, source_space, q, limit = "50", offset = "0" } = c.req.query();
|
|
|
|
const conditions: string[] = ["status = 'active'"];
|
|
const params: unknown[] = [];
|
|
let paramIdx = 1;
|
|
|
|
if (product_type) {
|
|
conditions.push(`product_type = $${paramIdx}`);
|
|
params.push(product_type);
|
|
paramIdx++;
|
|
}
|
|
if (capability) {
|
|
conditions.push(`required_capabilities && $${paramIdx}`);
|
|
params.push(capability.split(","));
|
|
paramIdx++;
|
|
}
|
|
if (tag) {
|
|
conditions.push(`$${paramIdx} = ANY(tags)`);
|
|
params.push(tag);
|
|
paramIdx++;
|
|
}
|
|
if (source_space) {
|
|
conditions.push(`source_space = $${paramIdx}`);
|
|
params.push(source_space);
|
|
paramIdx++;
|
|
}
|
|
if (q) {
|
|
conditions.push(`title ILIKE $${paramIdx}`);
|
|
params.push(`%${q}%`);
|
|
paramIdx++;
|
|
}
|
|
|
|
const where = conditions.join(" AND ");
|
|
const limitNum = Math.min(parseInt(limit) || 50, 100);
|
|
const offsetNum = parseInt(offset) || 0;
|
|
|
|
const [result, countResult] = await Promise.all([
|
|
sql.unsafe(
|
|
`SELECT id, artifact_id, title, product_type,
|
|
required_capabilities, tags, source_space,
|
|
artifact->'payload'->>'description' as description,
|
|
artifact->'pricing' as pricing,
|
|
artifact->'spec'->'dimensions' as dimensions,
|
|
status, created_at
|
|
FROM rcart.catalog_entries
|
|
WHERE ${where}
|
|
ORDER BY created_at DESC
|
|
LIMIT ${limitNum} OFFSET ${offsetNum}`,
|
|
params
|
|
),
|
|
sql.unsafe(`SELECT count(*) FROM rcart.catalog_entries WHERE ${where}`, params),
|
|
]);
|
|
|
|
return c.json({ entries: result, total: parseInt(countResult[0].count as string), limit: limitNum, offset: offsetNum });
|
|
});
|
|
|
|
// GET /api/catalog/:id — Single catalog entry
|
|
routes.get("/api/catalog/:id", async (c) => {
|
|
const id = c.req.param("id");
|
|
const result = await sql.unsafe(
|
|
"SELECT * FROM rcart.catalog_entries WHERE id = $1 OR artifact_id = $1",
|
|
[id]
|
|
);
|
|
if (result.length === 0) return c.json({ error: "Catalog entry not found" }, 404);
|
|
const row = result[0];
|
|
return c.json({ id: row.id, artifact: row.artifact, status: row.status, created_at: row.created_at, updated_at: row.updated_at });
|
|
});
|
|
|
|
// PATCH /api/catalog/:id — Update listing status
|
|
routes.patch("/api/catalog/:id", async (c) => {
|
|
const { status } = await c.req.json();
|
|
const valid = ["active", "paused", "sold_out", "removed"];
|
|
if (!valid.includes(status)) return c.json({ error: `status must be one of: ${valid.join(", ")}` }, 400);
|
|
|
|
const result = await sql.unsafe(
|
|
"UPDATE rcart.catalog_entries SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING id, status",
|
|
[status, c.req.param("id")]
|
|
);
|
|
if (result.length === 0) return c.json({ error: "Catalog entry not found" }, 404);
|
|
return c.json(result[0]);
|
|
});
|
|
|
|
// ── ORDER ROUTES ──
|
|
|
|
// POST /api/orders — Create an order
|
|
routes.post("/api/orders", async (c) => {
|
|
// Optional auth — set buyer_did from claims if authenticated
|
|
const token = extractToken(c.req.raw.headers);
|
|
let buyerDid: string | null = null;
|
|
if (token) {
|
|
try { const claims = await verifyEncryptIDToken(token); buyerDid = claims.sub; } catch {}
|
|
}
|
|
|
|
const body = await c.req.json();
|
|
const {
|
|
catalog_entry_id, artifact_id, buyer_id, buyer_location, buyer_contact,
|
|
provider_id, provider_name, provider_distance_km,
|
|
quantity = 1, production_cost, creator_payout, community_payout,
|
|
total_price, currency = "USD", payment_method = "manual",
|
|
payment_tx, payment_network,
|
|
} = body;
|
|
|
|
if (!catalog_entry_id && !artifact_id) return c.json({ error: "Required: catalog_entry_id or artifact_id" }, 400);
|
|
if (!provider_id || !total_price) return c.json({ error: "Required: provider_id, total_price" }, 400);
|
|
|
|
const entryResult = await sql.unsafe(
|
|
"SELECT id, artifact_id FROM rcart.catalog_entries WHERE id = $1 OR artifact_id = $1",
|
|
[catalog_entry_id || artifact_id]
|
|
);
|
|
if (entryResult.length === 0) return c.json({ error: "Catalog entry not found" }, 404);
|
|
|
|
const entry = entryResult[0];
|
|
|
|
// x402 detection
|
|
const x402Header = c.req.header("x-payment");
|
|
const effectiveMethod = x402Header ? "x402" : payment_method;
|
|
const initialStatus = x402Header ? "paid" : "pending";
|
|
|
|
const result = await sql.unsafe(
|
|
`INSERT INTO rcart.orders (
|
|
catalog_entry_id, artifact_id, buyer_id, buyer_location, buyer_contact,
|
|
provider_id, provider_name, provider_distance_km,
|
|
quantity, production_cost, creator_payout, community_payout,
|
|
total_price, currency, status, payment_method, payment_tx, payment_network
|
|
${initialStatus === "paid" ? ", paid_at" : ""}
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18
|
|
${initialStatus === "paid" ? ", NOW()" : ""})
|
|
RETURNING *`,
|
|
[
|
|
entry.id, entry.artifact_id,
|
|
buyerDid || buyer_id || null,
|
|
buyer_location ? JSON.stringify(buyer_location) : null,
|
|
buyer_contact ? JSON.stringify(buyer_contact) : null,
|
|
provider_id, provider_name || null, provider_distance_km || null,
|
|
quantity, production_cost || null, creator_payout || null, community_payout || null,
|
|
total_price, currency, initialStatus, effectiveMethod,
|
|
payment_tx || null, payment_network || null,
|
|
]
|
|
);
|
|
|
|
const order = result[0];
|
|
if (initialStatus === "paid") {
|
|
depositOrderRevenue(total_price, order.id);
|
|
}
|
|
|
|
return c.json(order, 201);
|
|
});
|
|
|
|
// GET /api/orders — List orders
|
|
routes.get("/api/orders", async (c) => {
|
|
// Optional auth — filter by buyer if authenticated
|
|
const token = extractToken(c.req.raw.headers);
|
|
let authedBuyer: string | null = null;
|
|
if (token) {
|
|
try { const claims = await verifyEncryptIDToken(token); authedBuyer = claims.sub; } catch {}
|
|
}
|
|
|
|
const { status, provider_id, buyer_id, limit = "50", offset = "0" } = c.req.query();
|
|
|
|
const conditions: string[] = [];
|
|
const params: unknown[] = [];
|
|
let paramIdx = 1;
|
|
|
|
if (status) { conditions.push(`o.status = $${paramIdx}`); params.push(status); paramIdx++; }
|
|
if (provider_id) { conditions.push(`o.provider_id = $${paramIdx}`); params.push(provider_id); paramIdx++; }
|
|
const effectiveBuyerId = buyer_id || (authedBuyer && !status && !provider_id ? authedBuyer : null);
|
|
if (effectiveBuyerId) { conditions.push(`o.buyer_id = $${paramIdx}`); params.push(effectiveBuyerId); paramIdx++; }
|
|
|
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
const limitNum = Math.min(parseInt(limit) || 50, 100);
|
|
const offsetNum = parseInt(offset) || 0;
|
|
|
|
const result = await sql.unsafe(
|
|
`SELECT o.*, c.title as artifact_title, c.product_type
|
|
FROM rcart.orders o JOIN rcart.catalog_entries c ON c.id = o.catalog_entry_id
|
|
${where} ORDER BY o.created_at DESC LIMIT ${limitNum} OFFSET ${offsetNum}`,
|
|
params
|
|
);
|
|
|
|
return c.json({ orders: result });
|
|
});
|
|
|
|
// GET /api/orders/:id — Single order
|
|
routes.get("/api/orders/:id", async (c) => {
|
|
const result = await sql.unsafe(
|
|
`SELECT o.*, c.artifact as artifact_envelope, c.title as artifact_title
|
|
FROM rcart.orders o JOIN rcart.catalog_entries c ON c.id = o.catalog_entry_id
|
|
WHERE o.id = $1`,
|
|
[c.req.param("id")]
|
|
);
|
|
if (result.length === 0) return c.json({ error: "Order not found" }, 404);
|
|
return c.json(result[0]);
|
|
});
|
|
|
|
// PATCH /api/orders/:id/status — Update order status
|
|
routes.patch("/api/orders/:id/status", async (c) => {
|
|
const body = await c.req.json();
|
|
const { status, payment_tx, payment_network } = body;
|
|
const valid = ["pending", "paid", "accepted", "in_production", "ready", "shipped", "completed", "cancelled"];
|
|
if (!valid.includes(status)) return c.json({ error: `status must be one of: ${valid.join(", ")}` }, 400);
|
|
|
|
const timestampField: Record<string, string> = { paid: "paid_at", accepted: "accepted_at", completed: "completed_at" };
|
|
const extraSet = timestampField[status] ? `, ${timestampField[status]} = NOW()` : "";
|
|
|
|
// Use parameterized query for payment info
|
|
let paymentSet = "";
|
|
const params: unknown[] = [status, c.req.param("id")];
|
|
if (status === "paid" && payment_tx) {
|
|
paymentSet = `, payment_tx = $3, payment_network = $4`;
|
|
params.push(payment_tx, payment_network || null);
|
|
}
|
|
|
|
const result = await sql.unsafe(
|
|
`UPDATE rcart.orders SET status = $1, updated_at = NOW()${extraSet}${paymentSet} WHERE id = $2 RETURNING *`,
|
|
params
|
|
);
|
|
if (result.length === 0) return c.json({ error: "Order not found" }, 404);
|
|
|
|
const updated = result[0];
|
|
if (status === "paid" && updated.total_price) {
|
|
depositOrderRevenue(updated.total_price, c.req.param("id"));
|
|
}
|
|
|
|
return c.json(updated);
|
|
});
|
|
|
|
// ── FULFILLMENT ROUTES ──
|
|
|
|
function round2(n: number): number {
|
|
return Math.round(n * 100) / 100;
|
|
}
|
|
|
|
interface ProviderMatch {
|
|
id: string; name: string; distance_km: number;
|
|
location: { city: string; region: string; country: string };
|
|
turnaround: { standard_days: number; rush_days: number; rush_surcharge_pct: number };
|
|
pricing: Record<string, { base_price: number; per_unit: number; per_unit_label: string; currency: string; volume_breaks?: { min_qty: number; per_unit: number }[] }>;
|
|
wallet: string;
|
|
}
|
|
|
|
function composeCost(artifact: Record<string, unknown>, provider: ProviderMatch, quantity: number) {
|
|
const spec = artifact.spec as Record<string, unknown> | undefined;
|
|
const capabilities = (spec?.required_capabilities as string[]) || [];
|
|
const pages = (spec?.pages as number) || 1;
|
|
const breakdown: { label: string; amount: number }[] = [];
|
|
|
|
for (const cap of capabilities) {
|
|
const capPricing = provider.pricing?.[cap];
|
|
if (!capPricing) continue;
|
|
|
|
const basePrice = capPricing.base_price || 0;
|
|
let perUnit = capPricing.per_unit || 0;
|
|
const unitLabel = capPricing.per_unit_label || "per unit";
|
|
|
|
if (capPricing.volume_breaks) {
|
|
for (const vb of capPricing.volume_breaks) {
|
|
if (quantity >= vb.min_qty) perUnit = vb.per_unit;
|
|
}
|
|
}
|
|
|
|
let units = quantity;
|
|
if (unitLabel.includes("page")) units = pages * quantity;
|
|
|
|
if (basePrice > 0) breakdown.push({ label: `${cap} setup`, amount: round2(basePrice) });
|
|
breakdown.push({ label: `${cap} (${units} x $${perUnit} ${unitLabel})`, amount: round2(perUnit * units) });
|
|
}
|
|
|
|
if (breakdown.length === 0) {
|
|
breakdown.push({ label: "Production (estimated)", amount: round2(2.0 * quantity) });
|
|
}
|
|
|
|
return breakdown;
|
|
}
|
|
|
|
// POST /api/fulfill/resolve — Find fulfillment options
|
|
routes.post("/api/fulfill/resolve", async (c) => {
|
|
const body = await c.req.json();
|
|
const { artifact_id, catalog_entry_id, buyer_location, quantity = 1 } = body;
|
|
|
|
if (!buyer_location?.lat || !buyer_location?.lng) {
|
|
return c.json({ error: "Required: buyer_location.lat, buyer_location.lng" }, 400);
|
|
}
|
|
if (!artifact_id && !catalog_entry_id) {
|
|
return c.json({ error: "Required: artifact_id or catalog_entry_id" }, 400);
|
|
}
|
|
|
|
const entryResult = await sql.unsafe(
|
|
"SELECT * FROM rcart.catalog_entries WHERE (artifact_id = $1 OR id = $1) AND status = 'active'",
|
|
[artifact_id || catalog_entry_id]
|
|
);
|
|
if (entryResult.length === 0) return c.json({ error: "Artifact not found in catalog" }, 404);
|
|
|
|
const entry = entryResult[0];
|
|
const artifact = entry.artifact;
|
|
const capabilities = artifact.spec?.required_capabilities || [];
|
|
const substrates = artifact.spec?.substrates || [];
|
|
|
|
if (capabilities.length === 0) {
|
|
return c.json({ error: "Artifact has no required_capabilities" }, 400);
|
|
}
|
|
|
|
// Query provider registry (internal module or external service)
|
|
const providerUrl = getProviderUrl();
|
|
const params = new URLSearchParams({
|
|
capabilities: capabilities.join(","),
|
|
lat: String(buyer_location.lat),
|
|
lng: String(buyer_location.lng),
|
|
});
|
|
if (substrates.length > 0) params.set("substrates", substrates.join(","));
|
|
|
|
let providers: ProviderMatch[];
|
|
try {
|
|
const resp = await fetch(`${providerUrl}/api/providers/match?${params}`);
|
|
if (!resp.ok) throw new Error(`Provider registry returned ${resp.status}`);
|
|
const data = await resp.json() as { matches?: ProviderMatch[] };
|
|
providers = data.matches || [];
|
|
} catch (err) {
|
|
return c.json({ error: "Failed to query provider registry", detail: err instanceof Error ? err.message : String(err) }, 502);
|
|
}
|
|
|
|
if (providers.length === 0) {
|
|
return c.json({ options: [], message: "No local providers found", artifact_id: artifact.id });
|
|
}
|
|
|
|
const options = providers.map((provider) => {
|
|
const costBreakdown = composeCost(artifact, provider, quantity);
|
|
const productionCost = costBreakdown.reduce((sum, item) => sum + item.amount, 0);
|
|
const pricing = artifact.pricing || {};
|
|
const creatorPct = (pricing.creator_share_pct || 30) / 100;
|
|
const communityPct = (pricing.community_share_pct || 0) / 100;
|
|
const markupMultiplier = 1 / (1 - creatorPct - communityPct);
|
|
const totalPrice = productionCost * markupMultiplier;
|
|
const creatorPayout = totalPrice * creatorPct;
|
|
const communityPayout = totalPrice * communityPct;
|
|
|
|
return {
|
|
provider: { id: provider.id, name: provider.name, distance_km: provider.distance_km, city: provider.location?.city || "Unknown" },
|
|
production_cost: round2(productionCost),
|
|
creator_payout: round2(creatorPayout),
|
|
community_payout: round2(communityPayout),
|
|
total_price: round2(totalPrice),
|
|
currency: "USD",
|
|
turnaround_days: provider.turnaround?.standard_days || 5,
|
|
cost_breakdown: costBreakdown,
|
|
};
|
|
});
|
|
|
|
options.sort((a, b) => a.total_price - b.total_price);
|
|
|
|
return c.json({ artifact_id: artifact.id, artifact_title: artifact.payload?.title, buyer_location, quantity, options });
|
|
});
|
|
|
|
// ── Page route: shop ──
|
|
routes.get("/", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
return c.html(renderShell({
|
|
title: `Shop | rSpace`,
|
|
moduleId: "cart",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "light",
|
|
styles: `<link rel="stylesheet" href="/modules/cart/cart.css">`,
|
|
body: `<folk-cart-shop></folk-cart-shop>`,
|
|
scripts: `<script type="module" src="/modules/cart/folk-cart-shop.js"></script>`,
|
|
}));
|
|
});
|
|
|
|
export const cartModule: RSpaceModule = {
|
|
id: "cart",
|
|
name: "rCart",
|
|
icon: "\u{1F6D2}",
|
|
description: "Cosmolocal print-on-demand shop",
|
|
routes,
|
|
standaloneDomain: "rcart.online",
|
|
};
|