/** * 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"; 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) => { 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, 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) => { 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++; } if (buyer_id) { conditions.push(`o.buyer_id = $${paramIdx}`); params.push(buyer_id); 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 = { 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; wallet: string; } function composeCost(artifact: Record, provider: ProviderMatch, quantity: number) { const spec = artifact.spec as Record | 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: ``, body: ``, scripts: ``, })); }); export const cartModule: RSpaceModule = { id: "cart", name: "rCart", icon: "\u{1F6D2}", description: "Cosmolocal print-on-demand shop", routes, standaloneDomain: "rcart.online", };