/** * Providers module — local provider directory. * * Ported from /opt/apps/provider-registry/ (Express + pg → Hono + postgres.js). * Uses earthdistance extension for proximity queries. */ 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 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("[Providers] DB schema initialized"); } catch (e) { console.error("[Providers] DB init error:", e); } } initDB(); // ── Seed data (if empty) ── async function seedIfEmpty() { const count = await sql.unsafe("SELECT count(*) FROM providers.providers"); if (parseInt(count[0].count) > 0) return; const providers = [ { name: "Radiant Hall Press", lat: 40.4732, lng: -79.9535, city: "Pittsburgh", region: "PA", country: "US", caps: ["risograph","saddle-stitch","perfect-bind","laser-print","fold"], subs: ["paper-80gsm","paper-100gsm","paper-100gsm-recycled","paper-160gsm-cover"], radius: 25, shipping: true, days: 3, rush: 1, rushPct: 50, email: "hello@radianthallpress.com", website: "https://radianthallpress.com", community: "pittsburgh.mycofi.earth" }, { name: "Tiny Splendor", lat: 37.7799, lng: -122.2822, city: "Oakland", region: "CA", country: "US", caps: ["risograph","saddle-stitch","fold"], subs: ["paper-80gsm","paper-100gsm-recycled"], radius: 30, shipping: true, days: 5, rush: 2, rushPct: 40, email: "print@tinysplendor.com", website: "https://tinysplendor.com", community: "oakland.mycofi.earth" }, { name: "People's Print Shop", lat: 40.7282, lng: -73.7949, city: "New York", region: "NY", country: "US", caps: ["risograph","screen-print","saddle-stitch"], subs: ["paper-80gsm","paper-100gsm","fabric-cotton"], radius: 15, shipping: true, days: 4, rush: 2, rushPct: 50, email: "hello@peoplesprintshop.com", website: "https://peoplesprintshop.com", community: "nyc.mycofi.earth" }, { name: "Colour Code Press", lat: 51.5402, lng: -0.1449, city: "London", region: "England", country: "GB", caps: ["risograph","perfect-bind","fold","laser-print"], subs: ["paper-80gsm","paper-100gsm","paper-160gsm-cover"], radius: 20, shipping: true, days: 5, rush: 2, rushPct: 50, email: "info@colourcodepress.com", website: "https://colourcodepress.com", community: "london.mycofi.earth" }, { name: "Druckwerkstatt Berlin", lat: 52.5200, lng: 13.4050, city: "Berlin", region: "Berlin", country: "DE", caps: ["risograph","screen-print","saddle-stitch","fold"], subs: ["paper-80gsm","paper-100gsm-recycled","paper-160gsm-cover"], radius: 20, shipping: true, days: 4, rush: 1, rushPct: 60, email: "hallo@druckwerkstatt.de", website: "https://druckwerkstatt.de", community: "berlin.mycofi.earth" }, { name: "Kinko Printing Collective", lat: 35.6762, lng: 139.6503, city: "Tokyo", region: "Tokyo", country: "JP", caps: ["risograph","saddle-stitch","fold","perfect-bind"], subs: ["paper-80gsm","paper-100gsm","washi-paper"], radius: 30, shipping: true, days: 5, rush: 2, rushPct: 50, email: "info@kinkoprint.jp", website: "https://kinkoprint.jp", community: "tokyo.mycofi.earth" }, ]; for (const p of providers) { await sql.unsafe( `INSERT INTO providers.providers ( name, lat, lng, city, region, country, capabilities, substrates, service_radius_km, offers_shipping, standard_days, rush_days, rush_surcharge_pct, contact_email, contact_website, communities ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`, [p.name, p.lat, p.lng, p.city, p.region, p.country, p.caps, p.subs, p.radius, p.shipping, p.days, p.rush, p.rushPct, p.email, p.website, [p.community]] ); } console.log("[Providers] Seeded 6 providers"); } initDB().then(seedIfEmpty).catch(() => {}); // ── Transform DB row → API response ── function toProviderResponse(row: Record) { return { id: row.id, name: row.name, description: row.description, location: { lat: row.lat, lng: row.lng, address: row.address, city: row.city, region: row.region, country: row.country, service_radius_km: row.service_radius_km, offers_shipping: row.offers_shipping, }, capabilities: row.capabilities, substrates: row.substrates, turnaround: { standard_days: row.standard_days, rush_days: row.rush_days, rush_surcharge_pct: row.rush_surcharge_pct, }, pricing: row.pricing, communities: row.communities, contact: { email: row.contact_email, phone: row.contact_phone, website: row.contact_website, }, wallet: row.wallet, reputation: { jobs_completed: row.jobs_completed, avg_rating: row.avg_rating, member_since: row.member_since, }, active: row.active, ...(row.distance_km !== undefined && { distance_km: parseFloat(row.distance_km as string) }), }; } // ── GET /api/providers — List/search providers ── routes.get("/api/providers", async (c) => { const { capability, substrate, community, lat, lng, radius_km, active, limit = "50", offset = "0" } = c.req.query(); const conditions: string[] = []; const params: unknown[] = []; let paramIdx = 1; if (active !== "false") { conditions.push("active = TRUE"); } if (capability) { const caps = capability.split(","); conditions.push(`capabilities @> $${paramIdx}`); params.push(caps); paramIdx++; } if (substrate) { const subs = substrate.split(","); conditions.push(`substrates && $${paramIdx}`); params.push(subs); paramIdx++; } if (community) { conditions.push(`$${paramIdx} = ANY(communities)`); params.push(community); paramIdx++; } let distanceSelect = ""; let orderBy = "ORDER BY name"; if (lat && lng) { const latNum = parseFloat(lat); const lngNum = parseFloat(lng); distanceSelect = `, round((earth_distance(ll_to_earth(lat, lng), ll_to_earth($${paramIdx}, $${paramIdx + 1})) / 1000)::numeric, 1) as distance_km`; params.push(latNum, lngNum); if (radius_km) { conditions.push(`earth_distance(ll_to_earth(lat, lng), ll_to_earth($${paramIdx}, $${paramIdx + 1})) <= $${paramIdx + 2} * 1000`); params.push(parseFloat(radius_km)); paramIdx += 3; } else { paramIdx += 2; } orderBy = "ORDER BY distance_km ASC"; } const where = conditions.length > 0 ? `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 *${distanceSelect} FROM providers.providers ${where} ${orderBy} LIMIT ${limitNum} OFFSET ${offsetNum}`, params), sql.unsafe(`SELECT count(*) FROM providers.providers ${where}`, params), ]); return c.json({ providers: result.map(toProviderResponse), total: parseInt(countResult[0].count as string), limit: limitNum, offset: offsetNum, }); }); // ── GET /api/providers/match — Match providers for an artifact ── routes.get("/api/providers/match", async (c) => { const { capabilities, substrates, lat, lng, community } = c.req.query(); if (!capabilities || !lat || !lng) { return c.json({ error: "Required query params: capabilities (comma-separated), lat, lng" }, 400); } const caps = capabilities.split(","); const latNum = parseFloat(lat); const lngNum = parseFloat(lng); const conditions = ["active = TRUE", "capabilities @> $1"]; const params: unknown[] = [caps, latNum, lngNum]; let paramIdx = 4; if (substrates) { const subs = substrates.split(","); conditions.push(`substrates && $${paramIdx}`); params.push(subs); paramIdx++; } if (community) { conditions.push(`$${paramIdx} = ANY(communities)`); params.push(community); paramIdx++; } const result = await sql.unsafe( `SELECT *, round((earth_distance(ll_to_earth(lat, lng), ll_to_earth($2, $3)) / 1000)::numeric, 1) as distance_km FROM providers.providers WHERE ${conditions.join(" AND ")} AND (service_radius_km = 0 OR offers_shipping = TRUE OR earth_distance(ll_to_earth(lat, lng), ll_to_earth($2, $3)) <= service_radius_km * 1000) ORDER BY earth_distance(ll_to_earth(lat, lng), ll_to_earth($2, $3)) ASC LIMIT 20`, params ); return c.json({ matches: result.map(toProviderResponse), query: { capabilities: caps, location: { lat: latNum, lng: lngNum } }, }); }); // ── GET /api/providers/:id — Single provider ── routes.get("/api/providers/:id", async (c) => { const id = c.req.param("id"); const result = await sql.unsafe("SELECT * FROM providers.providers WHERE id = $1", [id]); if (result.length === 0) return c.json({ error: "Provider not found" }, 404); return c.json(toProviderResponse(result[0])); }); // ── POST /api/providers — Register a new provider ── routes.post("/api/providers", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const body = await c.req.json(); const { name, description, location, capabilities, substrates, turnaround, pricing, communities, contact, wallet } = body; if (!name || !location?.lat || !location?.lng || !capabilities?.length) { return c.json({ error: "Required: name, location.lat, location.lng, capabilities (non-empty array)" }, 400); } const result = await sql.unsafe( `INSERT INTO providers.providers ( name, description, lat, lng, address, city, region, country, service_radius_km, offers_shipping, capabilities, substrates, standard_days, rush_days, rush_surcharge_pct, pricing, communities, contact_email, contact_phone, contact_website, wallet ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21) RETURNING *`, [ name, description || null, location.lat, location.lng, location.address || null, location.city || null, location.region || null, location.country || null, location.service_radius_km || 0, location.offers_shipping || false, capabilities, substrates || [], turnaround?.standard_days || null, turnaround?.rush_days || null, turnaround?.rush_surcharge_pct || 0, JSON.stringify(pricing || {}), communities || [], contact?.email || null, contact?.phone || null, contact?.website || null, wallet || null, ] ); return c.json(toProviderResponse(result[0]), 201); }); // ── PUT /api/providers/:id — Update provider ── routes.put("/api/providers/:id", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const id = c.req.param("id"); const existing = await sql.unsafe("SELECT id FROM providers.providers WHERE id = $1", [id]); if (existing.length === 0) return c.json({ error: "Provider not found" }, 404); const body = await c.req.json(); const fields: string[] = []; const params: unknown[] = []; let paramIdx = 1; const settable = ["name", "description", "capabilities", "substrates", "communities", "wallet", "active"]; for (const key of settable) { if (body[key] !== undefined) { fields.push(`${key} = $${paramIdx}`); params.push(body[key]); paramIdx++; } } if (body.location) { for (const [key, col] of Object.entries({ lat: "lat", lng: "lng", address: "address", city: "city", region: "region", country: "country", service_radius_km: "service_radius_km", offers_shipping: "offers_shipping" })) { if (body.location[key] !== undefined) { fields.push(`${col} = $${paramIdx}`); params.push(body.location[key]); paramIdx++; } } } if (body.turnaround) { for (const [key, col] of Object.entries({ standard_days: "standard_days", rush_days: "rush_days", rush_surcharge_pct: "rush_surcharge_pct" })) { if (body.turnaround[key] !== undefined) { fields.push(`${col} = $${paramIdx}`); params.push(body.turnaround[key]); paramIdx++; } } } if (body.pricing !== undefined) { fields.push(`pricing = $${paramIdx}`); params.push(JSON.stringify(body.pricing)); paramIdx++; } if (body.contact) { for (const [key, col] of Object.entries({ email: "contact_email", phone: "contact_phone", website: "contact_website" })) { if (body.contact[key] !== undefined) { fields.push(`${col} = $${paramIdx}`); params.push(body.contact[key]); paramIdx++; } } } if (fields.length === 0) return c.json({ error: "No fields to update" }, 400); fields.push("updated_at = NOW()"); params.push(id); const result = await sql.unsafe( `UPDATE providers.providers SET ${fields.join(", ")} WHERE id = $${paramIdx} RETURNING *`, params ); return c.json(toProviderResponse(result[0])); }); // ── DELETE /api/providers/:id — Deactivate provider ── routes.delete("/api/providers/:id", async (c) => { const result = await sql.unsafe( "UPDATE providers.providers SET active = FALSE, updated_at = NOW() WHERE id = $1 RETURNING *", [c.req.param("id")] ); if (result.length === 0) return c.json({ error: "Provider not found" }, 404); return c.json({ message: "Provider deactivated", provider: toProviderResponse(result[0]) }); }); // ── Page route: browse providers ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `Providers | rSpace`, moduleId: "providers", spaceSlug: space, modules: getModuleInfoList(), theme: "light", styles: ``, body: ``, scripts: ``, })); }); export const providersModule: RSpaceModule = { id: "providers", name: "rProviders", icon: "\u{1F3ED}", description: "Local provider directory for cosmolocal production", routes, standaloneDomain: "providers.mycofi.earth", };