rspace-online/modules/providers/mod.ts

371 lines
14 KiB
TypeScript

/**
* 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<string, unknown>) {
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: `<link rel="stylesheet" href="/modules/providers/providers.css">`,
body: `<folk-provider-directory></folk-provider-directory>`,
scripts: `<script type="module" src="/modules/providers/folk-provider-directory.js"></script>`,
}));
});
export const providersModule: RSpaceModule = {
id: "providers",
name: "rProviders",
icon: "\u{1F3ED}",
description: "Local provider directory for cosmolocal production",
routes,
standaloneDomain: "providers.mycofi.earth",
};