diff --git a/modules/rcal/mod.ts b/modules/rcal/mod.ts index f68852a..0bd0055 100644 --- a/modules/rcal/mod.ts +++ b/modules/rcal/mod.ts @@ -17,7 +17,7 @@ import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { calendarSchema, calendarDocId } from './schemas'; -import type { CalendarDoc, CalendarEvent, CalendarSource } from './schemas'; +import type { CalendarDoc, CalendarEvent, CalendarSource, ScheduledItemMetadata } from './schemas'; let _syncServer: SyncServer | null = null; @@ -313,7 +313,8 @@ routes.post("/api/events", async (c) => { const space = c.req.param("space") || "demo"; 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; + is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id, + is_scheduled_item, provenance, item_preview } = body; if (!title?.trim() || !start_time) return c.json({ error: "Title and start_time required" }, 400); const docId = calendarDocId(space); @@ -321,6 +322,36 @@ routes.post("/api/events", async (c) => { const eventId = crypto.randomUUID(); const now = Date.now(); + // Build metadata for scheduled knowledge items + let metadata: ScheduledItemMetadata | null = null; + if (is_scheduled_item && provenance) { + metadata = { + isScheduledItem: true, + provenance: { + rid: provenance.rid || '', + contentHash: provenance.content_hash ?? provenance.contentHash ?? null, + sourceType: provenance.source_type ?? provenance.sourceType ?? '', + sourceSpace: provenance.source_space ?? provenance.sourceSpace ?? space, + lifecycleEvent: provenance.lifecycle_event ?? provenance.lifecycleEvent ?? 'NEW', + originalCreatedAt: provenance.original_created_at ?? provenance.originalCreatedAt ?? now, + scheduledBy: provenance.scheduled_by ?? provenance.scheduledBy ?? null, + scheduledAt: now, + }, + itemPreview: item_preview ? { + textPreview: item_preview.text_preview ?? item_preview.textPreview ?? '', + mimeType: item_preview.mime_type ?? item_preview.mimeType ?? 'text/plain', + thumbnailUrl: item_preview.thumbnail_url ?? item_preview.thumbnailUrl, + canvasUrl: item_preview.canvas_url ?? item_preview.canvasUrl, + shapeSnapshot: item_preview.shape_snapshot ?? item_preview.shapeSnapshot, + } : { + textPreview: description || title, + mimeType: 'text/plain', + }, + reminderSent: false, + reminderSentAt: null, + }; + } + _syncServer!.changeDoc(docId, `create event ${eventId}`, (d) => { d.events[eventId] = { id: eventId, @@ -346,11 +377,11 @@ routes.post("/api/events", async (c) => { isVirtual: is_virtual || false, virtualUrl: virtual_url || null, virtualPlatform: virtual_platform || null, - rToolSource: r_tool_source || null, + rToolSource: r_tool_source || 'rSchedule', rToolEntityId: r_tool_entity_id || null, attendees: [], attendeeCount: 0, - metadata: null, + metadata: metadata, createdAt: now, updatedAt: now, }; @@ -360,6 +391,41 @@ routes.post("/api/events", async (c) => { return c.json(eventToRow(updated.events[eventId], updated.sources), 201); }); +// GET /api/events/scheduled — query only scheduled knowledge items +routes.get("/api/events/scheduled", async (c) => { + const space = c.req.param("space") || "demo"; + const { date, upcoming, pending_only } = c.req.query(); + + const doc = ensureDoc(space); + let events = Object.values(doc.events).filter((e) => { + const meta = e.metadata as ScheduledItemMetadata | null; + return meta?.isScheduledItem === true; + }); + + if (date) { + const dayStart = new Date(date).setHours(0, 0, 0, 0); + const dayEnd = dayStart + 86400000; + events = events.filter((e) => e.startTime >= dayStart && e.startTime < dayEnd); + } + + if (upcoming) { + const nowMs = Date.now(); + const futureMs = nowMs + parseInt(upcoming) * 86400000; + events = events.filter((e) => e.startTime >= nowMs && e.startTime <= futureMs); + } + + if (pending_only === "true") { + events = events.filter((e) => { + const meta = e.metadata as ScheduledItemMetadata; + return !meta.reminderSent; + }); + } + + events.sort((a, b) => a.startTime - b.startTime); + const rows = events.map((e) => eventToRow(e, doc.sources)); + return c.json({ count: rows.length, results: rows }); +}); + // GET /api/events/:id routes.get("/api/events/:id", async (c) => { const space = c.req.param("space") || "demo"; @@ -399,6 +465,7 @@ routes.patch("/api/events/:id", async (c) => { location_name: 'locationName', is_virtual: 'isVirtual', virtual_url: 'virtualUrl', + metadata: 'metadata', }; const updates: Array<{ field: keyof CalendarEvent; value: any }> = []; diff --git a/modules/rcal/schemas.ts b/modules/rcal/schemas.ts index 72a6132..f88ec04 100644 --- a/modules/rcal/schemas.ts +++ b/modules/rcal/schemas.ts @@ -87,6 +87,54 @@ export const calendarSchema: DocSchema = { }), }; +// ── KOI-Inspired Provenance (stored in CalendarEvent.metadata) ── + +/** Lifecycle events following BlockScience KOI's RID pattern */ +export type ProvenanceLifecycle = 'NEW' | 'UPDATE' | 'FORGET'; + +/** KOI-inspired provenance — stored in CalendarEvent.metadata.provenance */ +export interface KnowledgeProvenance { + /** Reference identifier in "type:uuid" format, e.g. "folk-note:abc-123" */ + rid: string; + /** SHA-256 hex digest of content at scheduling time */ + contentHash: string | null; + /** Shape type that originated this item (e.g. "folk-note", "folk-photo") */ + sourceType: string; + /** Space slug where the item lives */ + sourceSpace: string; + /** KOI lifecycle event */ + lifecycleEvent: ProvenanceLifecycle; + /** Original creation timestamp of the source item (epoch ms) */ + originalCreatedAt: number; + /** DID or user identifier who scheduled this */ + scheduledBy: string | null; + /** When the scheduling action occurred (epoch ms) */ + scheduledAt: number; +} + +/** Preview snapshot for email rendering — stored in metadata.itemPreview */ +export interface ItemPreview { + /** First ~200 chars of content */ + textPreview: string; + /** MIME type: text/markdown, image/jpeg, etc. */ + mimeType: string; + /** Thumbnail URL for photos/media */ + thumbnailUrl?: string; + /** Deep link back to canvas shape */ + canvasUrl?: string; + /** Full serialized shape data for reconstruction */ + shapeSnapshot?: Record; +} + +/** Typed metadata shape when CalendarEvent represents a scheduled knowledge item */ +export interface ScheduledItemMetadata { + isScheduledItem: true; + provenance: KnowledgeProvenance; + itemPreview: ItemPreview; + reminderSent: boolean; + reminderSentAt: number | null; +} + // ── Helpers ── export function calendarDocId(space: string) {