297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
/**
|
|
* Cal module — temporal coordination calendar.
|
|
*
|
|
* Group calendars with lunar/solar/seasonal time systems,
|
|
* location-aware events, and temporal-spatial zoom coupling.
|
|
*/
|
|
|
|
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("[Cal] DB schema initialized");
|
|
} catch (e) {
|
|
console.error("[Cal] DB init error:", e);
|
|
}
|
|
}
|
|
|
|
initDB();
|
|
|
|
// ── API: Events ──
|
|
|
|
// GET /api/events — query events with filters
|
|
routes.get("/api/events", async (c) => {
|
|
const { start, end, source, search, rTool, rEntityId, upcoming } = c.req.query();
|
|
|
|
let where = "WHERE 1=1";
|
|
const params: unknown[] = [];
|
|
let idx = 1;
|
|
|
|
if (start) { where += ` AND e.start_time >= $${idx}`; params.push(start); idx++; }
|
|
if (end) { where += ` AND e.start_time <= ($${idx}::date + interval '1 day')`; params.push(end); idx++; }
|
|
if (source) { where += ` AND e.source_id = $${idx}`; params.push(source); idx++; }
|
|
if (search) { where += ` AND (e.title ILIKE $${idx} OR e.description ILIKE $${idx})`; params.push(`%${search}%`); idx++; }
|
|
if (rTool) { where += ` AND e.r_tool_source = $${idx}`; params.push(rTool); idx++; }
|
|
if (rEntityId) { where += ` AND e.r_tool_entity_id = $${idx}`; params.push(rEntityId); idx++; }
|
|
if (upcoming) {
|
|
where += ` AND e.start_time >= NOW() AND e.start_time <= NOW() + ($${idx} || ' days')::interval`;
|
|
params.push(upcoming);
|
|
idx++;
|
|
}
|
|
|
|
const rows = await sql.unsafe(
|
|
`SELECT e.*, cs.name as source_name, cs.color as source_color, l.name as location_label
|
|
FROM rcal.events e
|
|
LEFT JOIN rcal.calendar_sources cs ON cs.id = e.source_id
|
|
LEFT JOIN rcal.locations l ON l.id = e.location_id
|
|
${where}
|
|
ORDER BY e.start_time ASC LIMIT 500`,
|
|
params
|
|
);
|
|
return c.json({ count: rows.length, results: rows });
|
|
});
|
|
|
|
// POST /api/events — create event
|
|
routes.post("/api/events", async (c) => {
|
|
const token = extractToken(c.req.raw.headers);
|
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
|
let claims;
|
|
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
|
|
|
const body = await c.req.json();
|
|
const { title, description, start_time, end_time, all_day, timezone, source_id, location_id, location_name,
|
|
is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id } = body;
|
|
if (!title?.trim() || !start_time) return c.json({ error: "Title and start_time required" }, 400);
|
|
|
|
const rows = await sql.unsafe(
|
|
`INSERT INTO rcal.events (title, description, start_time, end_time, all_day, timezone, source_id,
|
|
location_id, location_name, is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id, created_by)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
|
|
[title.trim(), description || null, start_time, end_time || null, all_day || false, timezone || "UTC",
|
|
source_id || null, location_id || null, location_name || null, is_virtual || false,
|
|
virtual_url || null, virtual_platform || null, r_tool_source || null, r_tool_entity_id || null, claims.sub]
|
|
);
|
|
return c.json(rows[0], 201);
|
|
});
|
|
|
|
// GET /api/events/:id
|
|
routes.get("/api/events/:id", async (c) => {
|
|
const rows = await sql.unsafe(
|
|
`SELECT e.*, cs.name as source_name, cs.color as source_color
|
|
FROM rcal.events e LEFT JOIN rcal.calendar_sources cs ON cs.id = e.source_id
|
|
WHERE e.id = $1`,
|
|
[c.req.param("id")]
|
|
);
|
|
if (rows.length === 0) return c.json({ error: "Event not found" }, 404);
|
|
return c.json(rows[0]);
|
|
});
|
|
|
|
// PATCH /api/events/:id
|
|
routes.patch("/api/events/:id", async (c) => {
|
|
const token = extractToken(c.req.raw.headers);
|
|
if (!token) return c.json({ error: "Authentication required" }, 401);
|
|
let claims;
|
|
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
|
|
|
const id = c.req.param("id");
|
|
const body = await c.req.json();
|
|
|
|
const fields: string[] = [];
|
|
const params: unknown[] = [];
|
|
let idx = 1;
|
|
const allowed = ["title", "description", "start_time", "end_time", "all_day", "timezone",
|
|
"status", "visibility", "location_name", "is_virtual", "virtual_url"];
|
|
|
|
for (const key of allowed) {
|
|
if (body[key] !== undefined) {
|
|
fields.push(`${key} = $${idx}`);
|
|
params.push(body[key]);
|
|
idx++;
|
|
}
|
|
}
|
|
|
|
if (fields.length === 0) return c.json({ error: "No fields" }, 400);
|
|
fields.push("updated_at = NOW()");
|
|
params.push(id);
|
|
|
|
const rows = await sql.unsafe(
|
|
`UPDATE rcal.events SET ${fields.join(", ")} WHERE id = $${idx} RETURNING *`,
|
|
params
|
|
);
|
|
if (rows.length === 0) return c.json({ error: "Not found" }, 404);
|
|
return c.json(rows[0]);
|
|
});
|
|
|
|
// DELETE /api/events/:id
|
|
routes.delete("/api/events/:id", async (c) => {
|
|
const result = await sql.unsafe("DELETE FROM rcal.events WHERE id = $1 RETURNING id", [c.req.param("id")]);
|
|
if (result.length === 0) return c.json({ error: "Not found" }, 404);
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// ── API: Sources ──
|
|
|
|
routes.get("/api/sources", async (c) => {
|
|
const { is_active, is_visible, source_type } = c.req.query();
|
|
let where = "WHERE 1=1";
|
|
const params: unknown[] = [];
|
|
let idx = 1;
|
|
|
|
if (is_active !== undefined) { where += ` AND is_active = $${idx}`; params.push(is_active === "true"); idx++; }
|
|
if (is_visible !== undefined) { where += ` AND is_visible = $${idx}`; params.push(is_visible === "true"); idx++; }
|
|
if (source_type) { where += ` AND source_type = $${idx}`; params.push(source_type); idx++; }
|
|
|
|
const rows = await sql.unsafe(`SELECT * FROM rcal.calendar_sources ${where} ORDER BY name`, params);
|
|
return c.json({ count: rows.length, results: rows });
|
|
});
|
|
|
|
routes.post("/api/sources", 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 rows = await sql.unsafe(
|
|
`INSERT INTO rcal.calendar_sources (name, source_type, url, color, is_active, is_visible)
|
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
|
[body.name, body.source_type || "MANUAL", body.url || null, body.color || "#6366f1",
|
|
body.is_active ?? true, body.is_visible ?? true]
|
|
);
|
|
return c.json(rows[0], 201);
|
|
});
|
|
|
|
// ── API: Locations ──
|
|
|
|
routes.get("/api/locations", async (c) => {
|
|
const { granularity, parent, search, root } = c.req.query();
|
|
let where = "WHERE 1=1";
|
|
const params: unknown[] = [];
|
|
let idx = 1;
|
|
|
|
if (root === "true") { where += " AND parent_id IS NULL"; }
|
|
if (granularity) { where += ` AND granularity = $${idx}`; params.push(parseInt(granularity)); idx++; }
|
|
if (parent) { where += ` AND parent_id = $${idx}`; params.push(parent); idx++; }
|
|
if (search) { where += ` AND name ILIKE $${idx}`; params.push(`%${search}%`); idx++; }
|
|
|
|
const rows = await sql.unsafe(`SELECT * FROM rcal.locations ${where} ORDER BY name`, params);
|
|
return c.json(rows);
|
|
});
|
|
|
|
routes.get("/api/locations/tree", async (c) => {
|
|
const rows = await sql.unsafe(
|
|
`WITH RECURSIVE tree AS (
|
|
SELECT id, name, granularity, parent_id, 0 as depth FROM rcal.locations WHERE parent_id IS NULL
|
|
UNION ALL
|
|
SELECT l.id, l.name, l.granularity, l.parent_id, t.depth + 1
|
|
FROM rcal.locations l JOIN tree t ON l.parent_id = t.id
|
|
)
|
|
SELECT * FROM tree ORDER BY depth, name`
|
|
);
|
|
return c.json(rows);
|
|
});
|
|
|
|
// ── API: Lunar data (computed, not stored) ──
|
|
|
|
routes.get("/api/lunar", async (c) => {
|
|
const { start, end } = c.req.query();
|
|
if (!start || !end) return c.json({ error: "start and end required" }, 400);
|
|
|
|
// Simple lunar phase approximation based on synodic month
|
|
const SYNODIC_MONTH = 29.53059;
|
|
const KNOWN_NEW_MOON = new Date("2024-01-11T11:57:00Z").getTime();
|
|
const phases: Record<string, { phase: string; illumination: number }> = {};
|
|
|
|
const startDate = new Date(start);
|
|
const endDate = new Date(end);
|
|
const current = new Date(startDate);
|
|
|
|
while (current <= endDate) {
|
|
const daysSinceNewMoon = (current.getTime() - KNOWN_NEW_MOON) / (1000 * 60 * 60 * 24);
|
|
const lunation = ((daysSinceNewMoon % SYNODIC_MONTH) + SYNODIC_MONTH) % SYNODIC_MONTH;
|
|
const fraction = lunation / SYNODIC_MONTH;
|
|
const illumination = 0.5 * (1 - Math.cos(2 * Math.PI * fraction));
|
|
|
|
let phase = "waxing_crescent";
|
|
if (fraction < 0.0625) phase = "new_moon";
|
|
else if (fraction < 0.1875) phase = "waxing_crescent";
|
|
else if (fraction < 0.3125) phase = "first_quarter";
|
|
else if (fraction < 0.4375) phase = "waxing_gibbous";
|
|
else if (fraction < 0.5625) phase = "full_moon";
|
|
else if (fraction < 0.6875) phase = "waning_gibbous";
|
|
else if (fraction < 0.8125) phase = "last_quarter";
|
|
else if (fraction < 0.9375) phase = "waning_crescent";
|
|
else phase = "new_moon";
|
|
|
|
phases[current.toISOString().split("T")[0]] = { phase, illumination: Math.round(illumination * 100) / 100 };
|
|
current.setDate(current.getDate() + 1);
|
|
}
|
|
|
|
return c.json(phases);
|
|
});
|
|
|
|
// ── API: Stats ──
|
|
|
|
routes.get("/api/stats", async (c) => {
|
|
const [eventCount, sourceCount, locationCount] = await Promise.all([
|
|
sql.unsafe("SELECT count(*)::int as cnt FROM rcal.events"),
|
|
sql.unsafe("SELECT count(*)::int as cnt FROM rcal.calendar_sources WHERE is_active = true"),
|
|
sql.unsafe("SELECT count(*)::int as cnt FROM rcal.locations"),
|
|
]);
|
|
return c.json({
|
|
events: eventCount[0]?.cnt || 0,
|
|
sources: sourceCount[0]?.cnt || 0,
|
|
locations: locationCount[0]?.cnt || 0,
|
|
});
|
|
});
|
|
|
|
// ── API: Context (r* tool bridge) ──
|
|
|
|
routes.get("/api/context/:tool", async (c) => {
|
|
const tool = c.req.param("tool");
|
|
const entityId = c.req.query("entityId");
|
|
if (!entityId) return c.json({ error: "entityId required" }, 400);
|
|
|
|
const rows = await sql.unsafe(
|
|
"SELECT * FROM rcal.events WHERE r_tool_source = $1 AND r_tool_entity_id = $2 ORDER BY start_time",
|
|
[tool, entityId]
|
|
);
|
|
return c.json({ count: rows.length, results: rows });
|
|
});
|
|
|
|
// ── Page route ──
|
|
routes.get("/", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
return c.html(renderShell({
|
|
title: `${space} — Calendar | rSpace`,
|
|
moduleId: "cal",
|
|
spaceSlug: space,
|
|
modules: getModuleInfoList(),
|
|
theme: "light",
|
|
styles: `<link rel="stylesheet" href="/modules/cal/cal.css">`,
|
|
body: `<folk-calendar-view space="${space}"></folk-calendar-view>`,
|
|
scripts: `<script type="module" src="/modules/cal/folk-calendar-view.js"></script>`,
|
|
}));
|
|
});
|
|
|
|
export const calModule: RSpaceModule = {
|
|
id: "cal",
|
|
name: "rCal",
|
|
icon: "\u{1F4C5}",
|
|
description: "Temporal coordination calendar with lunar, solar, and seasonal systems",
|
|
routes,
|
|
standaloneDomain: "rcal.online",
|
|
};
|