371 lines
14 KiB
TypeScript
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",
|
|
};
|