/// /** * Calendar Event Manager Pattern * * Create, update, delete, and RSVP to Google Calendar events with mandatory * user confirmation for all operations. * * Security: User must see the exact operation details and explicitly confirm * before any calendar modification. This pattern can serve as a declassification * gate when policies are implemented (patterns with verified SHA can be trusted). * * Usage: * 1. Create and favorite a Google Auth piece with "Calendar (create/edit/delete events)" permission * 2. Create a Calendar Event Manager piece * 3. Fill out event details or select an existing event * 4. Click the action button (Create/Update/Delete/RSVP) * 5. Review the confirmation dialog showing exactly what will happen * 6. Confirm to execute the operation * * Multi-account support: Use createGoogleAuth() with accountType parameter * to wish for #googleAuthPersonal or #googleAuthWork accounts. * See: gmail-importer.tsx for an example with account switching dropdown. */ import { Default, derive, handler, ifElse, NAME, pattern, UI, Writable, } from "commontools"; import { CalendarWriteClient, type RSVPStatus, } from "../util/calendar-write-client.ts"; import { type Auth, createGoogleAuth, type ScopeKey, } from "../util/google-auth-manager.tsx"; // ============================================================================ // TYPES // ============================================================================ type CalendarOperation = "create" | "update" | "delete" | "rsvp"; type EventDraft = { /** Event title/summary */ summary: Default; /** Start datetime (ISO or datetime-local format) */ start: Default; /** End datetime (ISO or datetime-local format) */ end: Default; /** Calendar ID (primary for main calendar) */ calendarId: Default; /** Event description */ description: Default; /** Event location */ location: Default; /** Attendee emails (comma-separated) */ attendeesText: Default; }; type ExistingEvent = { /** Event ID for update/delete/rsvp */ id: string; /** Calendar ID */ calendarId: string; /** Event summary for display */ summary?: string; } | null; type PendingOperation = { operation: CalendarOperation; event: { summary: string; start: string; end: string; calendarId: string; description?: string; location?: string; attendees?: string[]; }; existingEventId?: string; rsvpStatus?: RSVPStatus; } | null; type OperationResult = { success: boolean; operation: CalendarOperation; eventId?: string; error?: string; timestamp?: string; } | null; interface Input { /** Event draft for creating/editing */ draft: Default< EventDraft, { summary: ""; start: ""; end: ""; calendarId: "primary"; description: ""; location: ""; attendeesText: ""; } >; /** Existing event for update/delete/rsvp operations */ existingEvent: Default; } /** Google Calendar event manager for creating/editing/deleting events. #calendarManager */ interface Output { draft: EventDraft; existingEvent: ExistingEvent; result: OperationResult; } // ============================================================================ // HELPERS // ============================================================================ function formatDateTime(iso: string): string { if (!iso) return ""; try { const d = new Date(iso); return d.toLocaleString(undefined, { weekday: "short", year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } catch { return iso; } } function parseAttendees(text: string): string[] { if (!text || text.trim() === "") return []; return text .split(",") .map((s) => s.trim()) .filter((s) => s !== "" && s.includes("@")); } function getOperationWarning(op: PendingOperation): { title: string; desc: string; } { if (!op) return { title: "", desc: "" }; const hasAttendees = op.event.attendees && op.event.attendees.length > 0; switch (op.operation) { case "create": return { title: "This will create a real calendar event", desc: hasAttendees ? `Invitations will be sent to ${ op.event.attendees!.length } attendee(s).` : "The event will appear on your Google Calendar.", }; case "update": return { title: "This will update the calendar event", desc: hasAttendees ? "Attendees will be notified of the changes." : "The event will be modified on your calendar.", }; case "delete": return { title: "This will permanently delete the event", desc: hasAttendees ? "Attendees will be notified of the cancellation." : "This action cannot be undone.", }; case "rsvp": return { title: `You are responding "${op.rsvpStatus}"`, desc: "The organizer will be notified of your response.", }; } } // ============================================================================ // HANDLERS // ============================================================================ const prepareCreate = handler< unknown, { draft: Writable; pendingOp: Writable } >((_, { draft, pendingOp }) => { const d = draft.get(); pendingOp.set({ operation: "create", event: { summary: d.summary, start: d.start, end: d.end, calendarId: d.calendarId || "primary", description: d.description, location: d.location, attendees: parseAttendees(d.attendeesText), }, }); }); const prepareUpdate = handler< unknown, { draft: Writable; existingEvent: Writable; pendingOp: Writable; } >((_, { draft, existingEvent, pendingOp }) => { const d = draft.get(); const existing = existingEvent.get(); if (!existing?.id) return; pendingOp.set({ operation: "update", event: { summary: d.summary, start: d.start, end: d.end, calendarId: existing.calendarId || d.calendarId || "primary", description: d.description, location: d.location, attendees: parseAttendees(d.attendeesText), }, existingEventId: existing.id, }); }); const prepareDelete = handler< unknown, { draft: Writable; existingEvent: Writable; pendingOp: Writable; } >((_, { draft, existingEvent, pendingOp }) => { const d = draft.get(); const existing = existingEvent.get(); if (!existing?.id) return; pendingOp.set({ operation: "delete", event: { summary: existing.summary || d.summary, start: d.start, end: d.end, calendarId: existing.calendarId || d.calendarId || "primary", }, existingEventId: existing.id, }); }); const prepareRsvp = handler< unknown, { status: RSVPStatus; draft: Writable; existingEvent: Writable; pendingOp: Writable; } >((_, { status, draft, existingEvent, pendingOp }) => { const d = draft.get(); const existing = existingEvent.get(); if (!existing?.id) return; pendingOp.set({ operation: "rsvp", event: { summary: existing.summary || d.summary, start: d.start, end: d.end, calendarId: existing.calendarId || d.calendarId || "primary", }, existingEventId: existing.id, rsvpStatus: status, }); }); const cancelOperation = handler< unknown, { pendingOp: Writable } >( (_, { pendingOp }) => { pendingOp.set(null); }, ); const confirmOperation = handler< unknown, { pendingOp: Writable; auth: Writable; processing: Writable; result: Writable; draft: Writable; existingEvent: Writable; } >( async ( _, { pendingOp, auth, processing, result, draft, existingEvent }, ) => { const op = pendingOp.get(); if (!op) return; processing.set(true); result.set(null); try { const client = new CalendarWriteClient(auth, { debugMode: true }); let eventId: string | undefined; switch (op.operation) { case "create": { const created = await client.createEvent({ calendarId: op.event.calendarId, summary: op.event.summary, start: op.event.start, end: op.event.end, description: op.event.description, location: op.event.location, attendees: op.event.attendees, sendUpdates: "all", }); eventId = created.id; break; } case "update": { const updated = await client.updateEvent( op.event.calendarId, op.existingEventId!, { summary: op.event.summary, start: op.event.start, end: op.event.end, description: op.event.description, location: op.event.location, attendees: op.event.attendees, }, "all", ); eventId = updated.id; break; } case "delete": { await client.deleteEvent( op.event.calendarId, op.existingEventId!, "all", ); break; } case "rsvp": { const rsvped = await client.rsvpToEvent( op.event.calendarId, op.existingEventId!, op.rsvpStatus!, ); eventId = rsvped.id; break; } } result.set({ success: true, operation: op.operation, eventId, timestamp: new Date().toISOString(), }); pendingOp.set(null); // Clear draft on create success if (op.operation === "create") { draft.set({ summary: "", start: "", end: "", calendarId: "primary", description: "", location: "", attendeesText: "", }); } // Clear existing event on delete if (op.operation === "delete") { existingEvent.set(null); } } catch (error) { result.set({ success: false, operation: op.operation, error: error instanceof Error ? error.message : String(error), timestamp: new Date().toISOString(), }); // Close confirmation modal on error pendingOp.set(null); } finally { processing.set(false); } }, ); const dismissResult = handler }>( (_, { result }) => { result.set(null); }, ); // ============================================================================ // PATTERN // ============================================================================ export default pattern(({ draft, existingEvent }) => { // Auth via createGoogleAuth utility - handles discovery, validation, and UI const { auth, fullUI, isReady } = createGoogleAuth({ requiredScopes: ["calendar", "calendarWrite"] as ScopeKey[], }); const hasAuth = isReady; // UI state const pendingOp = Writable.of(null); const processing = Writable.of(false); const result = Writable.of(null); // Computed helpers const hasExistingEvent = derive(existingEvent, (e) => !!e?.id); const canCreate = derive( { hasAuth, draft, processing }, ({ hasAuth, draft, processing }) => hasAuth && draft.summary.trim() !== "" && draft.start.trim() !== "" && draft.end.trim() !== "" && !processing, ); return { [NAME]: "Calendar Manager", [UI]: (

Calendar Event Manager

{/* Auth status - handled by createGoogleAuth utility */} {fullUI} {/* Result display */} {ifElse( derive(result, (r: OperationResult) => r?.success === true),
{derive(result, (r: OperationResult) => { switch (r?.operation) { case "create": return "Event Created!"; case "update": return "Event Updated!"; case "delete": return "Event Deleted!"; case "rsvp": return "RSVP Sent!"; default: return "Success!"; } })}
{ifElse( derive(result, (r: OperationResult) => !!r?.eventId),
Event ID:{" "} {derive(result, (r: OperationResult) => r?.eventId)}
, null, )}
, null, )} {ifElse( derive(result, (r: OperationResult) => r?.success === false),
Operation Failed
{derive(result, (r: OperationResult) => r?.error)}
, null, )} {/* Event form */}
{/* Existing event indicator */} {ifElse( hasExistingEvent,
Editing event:{" "} {derive( existingEvent, (e: ExistingEvent) => e?.summary || e?.id, )}
, null, )}
{/* Action buttons */}
{/* Create button (shown when no existing event) */} {ifElse( hasExistingEvent, null, , )} {/* Update/Delete/RSVP buttons (shown when existing event) */} {ifElse( hasExistingEvent,
RSVP:
, null, )}
{/* CONFIRMATION DIALOG */} {ifElse( derive(pendingOp, (op: PendingOperation) => op !== null),
{/* Header */}
`2px solid ${ op?.operation === "delete" ? "#dc2626" : "#2563eb" }`, ), display: "flex", alignItems: "center", gap: "12px", }} > {derive(pendingOp, (op: PendingOperation) => { switch (op?.operation) { case "create": return "📅"; case "update": return "✏️"; case "delete": return "🗑️"; case "rsvp": return "📬"; default: return "📅"; } })}

op?.operation === "delete" ? "#dc2626" : "#2563eb", ), }} > {derive(pendingOp, (op: PendingOperation) => { switch (op?.operation) { case "create": return "Create Event"; case "update": return "Update Event"; case "delete": return "Delete Event"; case "rsvp": return "RSVP to Event"; default: return "Confirm"; } })}

{/* Content */}
{/* Event summary */}
{derive( pendingOp, (op: PendingOperation) => op?.event.summary || "Untitled", )}
{/* Time */}
🕐 {derive(pendingOp, (op: PendingOperation) => op?.event.start ? `${formatDateTime(op.event.start)} - ${ formatDateTime(op.event.end) }` : "")}
{/* Location */} {ifElse( derive( pendingOp, (op: PendingOperation) => !!op?.event.location, ),
📍 {derive(pendingOp, (op: PendingOperation) => op?.event.location)}
, null, )} {/* Attendees */} {ifElse( derive( pendingOp, (op: PendingOperation) => op?.event.attendees && op.event.attendees.length > 0, ),
👥 Attendees ( {derive( pendingOp, (op: PendingOperation) => op?.event.attendees?.length || 0, )} )
{derive(pendingOp, (op: PendingOperation) => (op?.event.attendees || []).map((email) => ( {email} )))}
, null, )} {/* RSVP status indicator */} {ifElse( derive( pendingOp, (op: PendingOperation) => op?.operation === "rsvp" && !!op?.rsvpStatus, ),
{ switch (op?.rsvpStatus) { case "accepted": return "#d1fae5"; case "declined": return "#fee2e2"; case "tentative": return "#fef3c7"; default: return "#f3f4f6"; } }, ), }} > Your response:{" "} {derive( pendingOp, (op: PendingOperation) => op?.rsvpStatus, )}
, null, )}
{/* Warning */}
op?.operation === "delete" ? "1px solid #ef4444" : "1px solid #f59e0b", ), background: derive( pendingOp, (op: PendingOperation) => op?.operation === "delete" ? "#fee2e2" : "#fef3c7", ), }} >
op?.operation === "delete" ? "#991b1b" : "#92400e", ), }} > {derive( pendingOp, (op: PendingOperation) => getOperationWarning(op).title, )}
op?.operation === "delete" ? "#b91c1c" : "#78350f", ), }} > {derive( pendingOp, (op: PendingOperation) => getOperationWarning(op).desc, )}
{/* Footer */}
, null, )}
), draft, existingEvent, result, }; });