From e7112904cdd43847bec2564cd5736d0fe49d9cf4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 17:33:32 +0000 Subject: [PATCH 1/3] Add calendar events backend service + frontend client plumbing Backend: - New `calendar` domain crate (models/ports/service) and `calendar_service` binary, mirroring the contacts service hexagonal layout. - Postgres migration: calendar_event + calendar_attendee tables (instants as epoch-millis BIGINT to match the frontend model and avoid tz ambiguity). - HTTP CRUD for events + an /invite endpoint that records invited attendees; JWT-authenticated, OpenAPI-documented (utoipa). - Repository uses sqlx's runtime-checked API so it compiles without a live DB or prepared offline cache. - Wired into the workspace, docker-compose (port 8101), and service URLs. Frontend plumbing: - @service-calendar client + generated schema types. - @queries/calendar query + mutation hooks. - Codegen config (services.ts, serviceToCrate, orval.config) so the client can be regenerated from the live OpenAPI spec. - Shared calendar domain types, date/ICS/invite utilities. https://claude.ai/code/session_01G5vy6QeqzgpcutwfRqoEje --- docker-compose.yml | 21 ++ js/app/packages/block-calendar/model/types.ts | 70 ++++ js/app/packages/block-calendar/package.json | 6 + js/app/packages/block-calendar/tsconfig.json | 10 + js/app/packages/block-calendar/util/dates.ts | 165 +++++++++ js/app/packages/block-calendar/util/ics.ts | 104 ++++++ js/app/packages/block-calendar/util/invite.ts | 108 ++++++ js/app/packages/core/constant/servers.ts | 2 + js/app/packages/queries/calendar/events.ts | 122 +++++++ js/app/packages/queries/calendar/keys.ts | 8 + .../packages/service-clients/orval.config.ts | 13 + .../service-calendar/client.ts | 71 ++++ .../generated/schemas/attendee.ts | 17 + .../generated/schemas/attendeeInput.ts | 13 + .../generated/schemas/calendarEvent.ts | 28 ++ .../generated/schemas/createEventRequest.ts | 26 ++ .../generated/schemas/index.ts | 13 + .../generated/schemas/inviteRequest.ts | 11 + .../generated/schemas/updateEventRequest.ts | 10 + js/app/scripts/generate-api-schema.ts | 1 + js/app/scripts/services.ts | 8 + js/app/tsconfig.json | 6 + rust/cloud-storage/Cargo.lock | 43 +++ rust/cloud-storage/Cargo.toml | 1 + rust/cloud-storage/calendar/Cargo.toml | 36 ++ rust/cloud-storage/calendar/src/domain.rs | 6 + .../calendar/src/domain/models.rs | 97 ++++++ .../calendar/src/domain/ports.rs | 101 ++++++ .../calendar/src/domain/service.rs | 84 +++++ rust/cloud-storage/calendar/src/inbound.rs | 2 + .../calendar/src/inbound/http.rs | 263 ++++++++++++++ rust/cloud-storage/calendar/src/lib.rs | 16 + rust/cloud-storage/calendar/src/outbound.rs | 2 + .../calendar/src/outbound/repository.rs | 324 ++++++++++++++++++ .../cloud-storage/calendar_service/Cargo.toml | 38 ++ .../calendar_service/src/config.rs | 32 ++ .../calendar_service/src/health.rs | 15 + .../calendar_service/src/main.rs | 75 ++++ .../calendar_service/src/openapi.rs | 6 + .../20260609120000_calendar_db_schema.sql | 39 +++ 40 files changed, 2013 insertions(+) create mode 100644 js/app/packages/block-calendar/model/types.ts create mode 100644 js/app/packages/block-calendar/package.json create mode 100644 js/app/packages/block-calendar/tsconfig.json create mode 100644 js/app/packages/block-calendar/util/dates.ts create mode 100644 js/app/packages/block-calendar/util/ics.ts create mode 100644 js/app/packages/block-calendar/util/invite.ts create mode 100644 js/app/packages/queries/calendar/events.ts create mode 100644 js/app/packages/queries/calendar/keys.ts create mode 100644 js/app/packages/service-clients/service-calendar/client.ts create mode 100644 js/app/packages/service-clients/service-calendar/generated/schemas/attendee.ts create mode 100644 js/app/packages/service-clients/service-calendar/generated/schemas/attendeeInput.ts create mode 100644 js/app/packages/service-clients/service-calendar/generated/schemas/calendarEvent.ts create mode 100644 js/app/packages/service-clients/service-calendar/generated/schemas/createEventRequest.ts create mode 100644 js/app/packages/service-clients/service-calendar/generated/schemas/index.ts create mode 100644 js/app/packages/service-clients/service-calendar/generated/schemas/inviteRequest.ts create mode 100644 js/app/packages/service-clients/service-calendar/generated/schemas/updateEventRequest.ts create mode 100644 rust/cloud-storage/calendar/Cargo.toml create mode 100644 rust/cloud-storage/calendar/src/domain.rs create mode 100644 rust/cloud-storage/calendar/src/domain/models.rs create mode 100644 rust/cloud-storage/calendar/src/domain/ports.rs create mode 100644 rust/cloud-storage/calendar/src/domain/service.rs create mode 100644 rust/cloud-storage/calendar/src/inbound.rs create mode 100644 rust/cloud-storage/calendar/src/inbound/http.rs create mode 100644 rust/cloud-storage/calendar/src/lib.rs create mode 100644 rust/cloud-storage/calendar/src/outbound.rs create mode 100644 rust/cloud-storage/calendar/src/outbound/repository.rs create mode 100644 rust/cloud-storage/calendar_service/Cargo.toml create mode 100644 rust/cloud-storage/calendar_service/src/config.rs create mode 100644 rust/cloud-storage/calendar_service/src/health.rs create mode 100644 rust/cloud-storage/calendar_service/src/main.rs create mode 100644 rust/cloud-storage/calendar_service/src/openapi.rs create mode 100644 rust/cloud-storage/macro_db_client/migrations/20260609120000_calendar_db_schema.sql diff --git a/docker-compose.yml b/docker-compose.yml index d81f6479c8..e8e15c02d2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -97,6 +97,27 @@ services: retries: 3 start_period: 10s + # ============================================================================ + # calendar_service (Port 8101) + # Calendar events + attendees - MacroDB + # ============================================================================ + calendar_service: + <<: [*common-env, *rust-services-image] + command: ["/app/out/calendar_service"] + ports: + - "8101:8080" + networks: + databases: + services: + aliases: + - calendar-service + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + # ============================================================================ # document_cognition_service (Port 8085) # Document analysis and processing diff --git a/js/app/packages/block-calendar/model/types.ts b/js/app/packages/block-calendar/model/types.ts new file mode 100644 index 0000000000..efe1a01e3d --- /dev/null +++ b/js/app/packages/block-calendar/model/types.ts @@ -0,0 +1,70 @@ +/** + * Frontend domain types for the calendar block. + * + * These are intentionally decoupled from the wire/API types (which live in + * `@service-calendar`). Queries map between the two so the UI works with a + * clean, instant-based model. + */ + +/** The three calendar surfaces, mirroring Google Calendar. */ +export type CalendarViewMode = 'week' | 'day' | 'list'; + +export const CALENDAR_VIEW_MODES: CalendarViewMode[] = ['week', 'day', 'list']; + +/** Accent palette for events. Keys map to semantic-ish token classes in the UI. */ +export type EventColor = + | 'blue' + | 'green' + | 'purple' + | 'orange' + | 'red' + | 'pink'; + +export const EVENT_COLORS: EventColor[] = [ + 'blue', + 'green', + 'purple', + 'orange', + 'red', + 'pink', +]; + +/** RSVP state of an invitee. */ +export type AttendeeStatus = 'pending' | 'accepted' | 'declined' | 'tentative'; + +export interface CalendarAttendee { + email: string; + name?: string; + status: AttendeeStatus; +} + +export interface CalendarEvent { + id: string; + title: string; + description?: string; + location?: string; + /** Start instant, epoch milliseconds (UTC). */ + startMs: number; + /** End instant, epoch milliseconds (UTC). */ + endMs: number; + allDay: boolean; + attendees: CalendarAttendee[]; + color: EventColor; +} + +/** Shape used by the create/edit form before it becomes a persisted event. */ +export interface CalendarEventDraft { + id?: string; + title: string; + description: string; + location: string; + startMs: number; + endMs: number; + allDay: boolean; + attendees: CalendarAttendee[]; + color: EventColor; +} + +export function isEventColor(value: string): value is EventColor { + return (EVENT_COLORS as string[]).includes(value); +} diff --git a/js/app/packages/block-calendar/package.json b/js/app/packages/block-calendar/package.json new file mode 100644 index 0000000000..a8ee0bcba4 --- /dev/null +++ b/js/app/packages/block-calendar/package.json @@ -0,0 +1,6 @@ +{ + "name": "block-calendar", + "private": true, + "scripts": {}, + "dependencies": {} +} diff --git a/js/app/packages/block-calendar/tsconfig.json b/js/app/packages/block-calendar/tsconfig.json new file mode 100644 index 0000000000..3a9c6c75eb --- /dev/null +++ b/js/app/packages/block-calendar/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "solid-js", + "verbatimModuleSyntax": true, + "declaration": true, + "composite": true + } +} diff --git a/js/app/packages/block-calendar/util/dates.ts b/js/app/packages/block-calendar/util/dates.ts new file mode 100644 index 0000000000..1e6033a22b --- /dev/null +++ b/js/app/packages/block-calendar/util/dates.ts @@ -0,0 +1,165 @@ +/** + * Thin date helpers for the calendar grid, built on `date-fns`. + * + * All persisted events store UTC epoch-millis (`startMs`/`endMs`); rendering + * and editing happen in the browser's local timezone via the native `Date`. + */ +import { + addDays, + addMinutes, + addWeeks, + differenceInMinutes, + eachDayOfInterval, + endOfDay, + endOfWeek, + format, + isSameDay, + isToday, + setHours, + setMinutes, + startOfDay, + startOfWeek, +} from 'date-fns'; +import type { CalendarViewMode } from '../model/types'; + +export const MINUTES_PER_DAY = 24 * 60; +/** Vertical pixels per hour in the time grid. */ +export const HOUR_HEIGHT_PX = 48; +export const DAY_HEIGHT_PX = HOUR_HEIGHT_PX * 24; +/** Default new-event duration. */ +export const DEFAULT_EVENT_MINUTES = 60; +/** Snap granularity (minutes) when clicking the grid. */ +export const SLOT_MINUTES = 30; + +export const HOURS = Array.from({ length: 24 }, (_, i) => i); + +/** Week starts on Sunday to match Google Calendar's default. */ +const WEEK_OPTS = { weekStartsOn: 0 } as const; + +export function startOfWeekLocal(date: Date): Date { + return startOfWeek(date, WEEK_OPTS); +} + +/** The list of day-Dates visible for a given view + anchor date. */ +export function daysForView(view: CalendarViewMode, anchor: Date): Date[] { + switch (view) { + case 'day': + return [startOfDay(anchor)]; + case 'week': + return eachDayOfInterval({ + start: startOfWeek(anchor, WEEK_OPTS), + end: endOfWeek(anchor, WEEK_OPTS), + }); + case 'list': + // List shows a 30-day rolling window starting at the anchor day. + return eachDayOfInterval({ + start: startOfDay(anchor), + end: endOfDay(addDays(anchor, 29)), + }); + } +} + +/** Step the anchor forward/backward by one "screen" for the active view. */ +export function shiftAnchor( + view: CalendarViewMode, + anchor: Date, + direction: 1 | -1 +): Date { + switch (view) { + case 'day': + return addDays(anchor, direction); + case 'week': + return addWeeks(anchor, direction); + case 'list': + // Page the rolling window by its full length. + return addDays(anchor, direction * 30); + } +} + +/** Minutes from local midnight for an instant. */ +export function minutesIntoDay(ms: number): number { + const d = new Date(ms); + return d.getHours() * 60 + d.getMinutes(); +} + +/** Pixel offset from the top of a day column for an instant. */ +export function offsetTopPx(ms: number): number { + return (minutesIntoDay(ms) / 60) * HOUR_HEIGHT_PX; +} + +/** Pixel height for a duration, clamped to a minimum so short events stay legible. */ +export function durationHeightPx(startMs: number, endMs: number): number { + const mins = Math.max(differenceInMinutes(endMs, startMs), 1); + return Math.max((mins / 60) * HOUR_HEIGHT_PX, 14); +} + +/** Snap a click at `pixelY` within a `day` column to a slot-aligned instant. */ +export function instantFromGridClick(day: Date, pixelY: number): number { + const rawMinutes = (pixelY / HOUR_HEIGHT_PX) * 60; + const snapped = + Math.round(rawMinutes / SLOT_MINUTES) * SLOT_MINUTES; + const clamped = Math.max(0, Math.min(snapped, MINUTES_PER_DAY - SLOT_MINUTES)); + return setMinutes(setHours(startOfDay(day), 0), clamped).getTime(); +} + +export function endOfDuration(startMs: number, minutes: number): number { + return addMinutes(startMs, minutes).getTime(); +} + +/** True when the event's local-time span intersects the given local day. */ +export function eventIntersectsDay( + startMs: number, + endMs: number, + day: Date +): boolean { + const dayStart = startOfDay(day).getTime(); + const dayEnd = endOfDay(day).getTime(); + return startMs < dayEnd && endMs > dayStart; +} + +// --- Formatting ----------------------------------------------------------- + +export function formatHourLabel(hour: number): string { + if (hour === 0) return '12 AM'; + if (hour === 12) return '12 PM'; + return hour < 12 ? `${hour} AM` : `${hour - 12} PM`; +} + +export function formatTimeRange(startMs: number, endMs: number): string { + return `${format(startMs, 'h:mm a')} – ${format(endMs, 'h:mm a')}`; +} + +export function formatDayHeading(day: Date): string { + return format(day, 'EEE d'); +} + +export function formatViewTitle(view: CalendarViewMode, anchor: Date): string { + switch (view) { + case 'day': + return format(anchor, 'EEEE, MMMM d, yyyy'); + case 'week': { + const start = startOfWeek(anchor, WEEK_OPTS); + const end = endOfWeek(anchor, WEEK_OPTS); + if (start.getMonth() === end.getMonth()) { + return `${format(start, 'MMMM yyyy')}`; + } + return `${format(start, 'MMM')} – ${format(end, 'MMM yyyy')}`; + } + case 'list': + return `${format(anchor, 'MMM d')} – ${format(addDays(anchor, 29), 'MMM d, yyyy')}`; + } +} + +/** `datetime-local` input value (local time, no timezone suffix). */ +export function toDatetimeLocalValue(ms: number): string { + return format(ms, "yyyy-MM-dd'T'HH:mm"); +} + +/** Parse a `datetime-local` input value back into epoch-millis (local tz). */ +export function fromDatetimeLocalValue(value: string): number { + // `new Date('YYYY-MM-DDTHH:mm')` is interpreted as local time. + const ms = new Date(value).getTime(); + return Number.isNaN(ms) ? Date.now() : ms; +} + +export { isSameDay, isToday, startOfDay, format }; diff --git a/js/app/packages/block-calendar/util/ics.ts b/js/app/packages/block-calendar/util/ics.ts new file mode 100644 index 0000000000..51170f1436 --- /dev/null +++ b/js/app/packages/block-calendar/util/ics.ts @@ -0,0 +1,104 @@ +/** + * Minimal RFC-5545 iCalendar generation for event invites. + * + * The backend owns authoritative invite delivery, but we also generate a + * client-side `.ics` so the organizer can download/attach the invite and so the + * email body can embed a standards-compliant calendar component. + */ +import type { CalendarEvent } from '../model/types'; + +function pad(n: number): string { + return n.toString().padStart(2, '0'); +} + +/** Format an epoch-ms instant as a UTC iCal timestamp (e.g. 20260612T143000Z). */ +export function toIcsUtc(ms: number): string { + const d = new Date(ms); + return ( + `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` + + `T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z` + ); +} + +/** Escape per RFC-5545 §3.3.11 (commas, semicolons, backslashes, newlines). */ +function escapeText(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\r?\n/g, '\\n'); +} + +/** Fold long content lines to 75 octets per RFC-5545 §3.1. */ +function foldLine(line: string): string { + if (line.length <= 75) return line; + const chunks: string[] = []; + let rest = line; + chunks.push(rest.slice(0, 75)); + rest = rest.slice(75); + while (rest.length > 0) { + chunks.push(` ${rest.slice(0, 74)}`); + rest = rest.slice(74); + } + return chunks.join('\r\n'); +} + +export interface IcsOptions { + organizerEmail: string; + organizerName?: string; + /** PUBLISH for informational, REQUEST when soliciting RSVPs. */ + method?: 'REQUEST' | 'PUBLISH' | 'CANCEL'; +} + +export function buildIcs(event: CalendarEvent, opts: IcsOptions): string { + const method = opts.method ?? 'REQUEST'; + const lines: string[] = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Macro//Calendar//EN', + 'CALSCALE:GREGORIAN', + `METHOD:${method}`, + 'BEGIN:VEVENT', + `UID:${event.id}@macro.com`, + `DTSTAMP:${toIcsUtc(Date.now())}`, + `DTSTART:${toIcsUtc(event.startMs)}`, + `DTEND:${toIcsUtc(event.endMs)}`, + `SUMMARY:${escapeText(event.title)}`, + ]; + + if (event.description) { + lines.push(`DESCRIPTION:${escapeText(event.description)}`); + } + if (event.location) { + lines.push(`LOCATION:${escapeText(event.location)}`); + } + + const organizerCn = opts.organizerName ?? opts.organizerEmail; + lines.push(`ORGANIZER;CN=${escapeText(organizerCn)}:mailto:${opts.organizerEmail}`); + + for (const attendee of event.attendees) { + const cn = escapeText(attendee.name ?? attendee.email); + lines.push( + `ATTENDEE;CN=${cn};ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:${attendee.email}` + ); + } + + lines.push('STATUS:CONFIRMED', 'END:VEVENT', 'END:VCALENDAR'); + + return lines.map(foldLine).join('\r\n'); +} + +/** Trigger a browser download of the event as an `.ics` file. */ +export function downloadIcs(event: CalendarEvent, opts: IcsOptions): void { + const ics = buildIcs(event, opts); + const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + const safeName = event.title.replace(/[^a-z0-9]+/gi, '-').toLowerCase() || 'event'; + anchor.download = `${safeName}.ics`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} diff --git a/js/app/packages/block-calendar/util/invite.ts b/js/app/packages/block-calendar/util/invite.ts new file mode 100644 index 0000000000..8b143a46f5 --- /dev/null +++ b/js/app/packages/block-calendar/util/invite.ts @@ -0,0 +1,108 @@ +/** + * Sends event invites through the user's connected mailbox (email-service), + * so attendees receive a real email from the organizer. The backend separately + * records invite state via the calendar service. + */ +import { emailClient } from '@service-email/client'; +import { format } from 'date-fns'; +import type { CalendarEvent } from '../model/types'; +import { buildIcs } from './ics'; + +/** Encode a UTF-8 string as base64 URL_SAFE_NO_PAD (the email API's body_html format). */ +function encodeBodyHtml(html: string): string { + return btoa(unescape(encodeURIComponent(html))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/={1,}$/, ''); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>'); +} + +function whenLabel(event: CalendarEvent): string { + if (event.allDay) { + return format(event.startMs, 'EEEE, MMMM d, yyyy'); + } + return `${format(event.startMs, 'EEEE, MMMM d, yyyy')} · ${format( + event.startMs, + 'h:mm a' + )} – ${format(event.endMs, 'h:mm a')}`; +} + +function buildHtmlBody( + event: CalendarEvent, + organizerName: string, + ics: string +): string { + const rows: string[] = [ + `

${escapeHtml(event.title || 'Untitled event')}

`, + `

When: ${escapeHtml(whenLabel(event))}

`, + ]; + if (event.location) { + rows.push( + `

Where: ${escapeHtml(event.location)}

` + ); + } + if (event.description) { + rows.push( + `

${escapeHtml(event.description)}

` + ); + } + rows.push( + `

Invited by ${escapeHtml(organizerName)}.

`, + // Embed the iCalendar component so calendar-aware clients can import it. + `
${escapeHtml(ics)}
` + ); + return `
${rows.join('')}
`; +} + +function buildTextBody(event: CalendarEvent, organizerName: string): string { + const lines = [ + event.title || 'Untitled event', + `When: ${whenLabel(event)}`, + ]; + if (event.location) lines.push(`Where: ${event.location}`); + if (event.description) lines.push('', event.description); + lines.push('', `Invited by ${organizerName}.`); + return lines.join('\n'); +} + +export interface SendInviteArgs { + event: CalendarEvent; + organizerEmail: string; + organizerName: string; + /** Recipients to email; defaults to all attendees on the event. */ + recipients?: { email: string; name?: string }[]; +} + +/** + * Sends the invite email. Returns the email-service result so callers can + * surface success/failure. Throws only on programmer error (no recipients). + */ +export async function sendInviteEmail(args: SendInviteArgs) { + const recipients = + args.recipients ?? + args.event.attendees.map((a) => ({ email: a.email, name: a.name })); + + const ics = buildIcs(args.event, { + organizerEmail: args.organizerEmail, + organizerName: args.organizerName, + method: 'REQUEST', + }); + + const html = buildHtmlBody(args.event, args.organizerName, ics); + const text = buildTextBody(args.event, args.organizerName); + + return emailClient.sendMessage({ + message: { + subject: `Invitation: ${args.event.title || 'Untitled event'}`, + to: recipients.map((r) => ({ email: r.email, name: r.name })), + body_html: encodeBodyHtml(html), + body_text: text, + }, + }); +} diff --git a/js/app/packages/core/constant/servers.ts b/js/app/packages/core/constant/servers.ts index 3befe9eeb9..f75ffdb946 100644 --- a/js/app/packages/core/constant/servers.ts +++ b/js/app/packages/core/constant/servers.ts @@ -13,6 +13,7 @@ const serverHostLocal: Servers = { 'email-service': 'http://localhost:8087', 'image-proxy-service': 'http://localhost:8097', 'scheduled-action': 'http://localhost:8098', + 'calendar-service': 'http://localhost:8101', } as const; const devServerSuffix = import.meta.env.MODE === 'development' ? '-dev' : ''; @@ -37,6 +38,7 @@ const serverHostRemote = { 'email-service': `https://email-service${devServerSuffix}.macro.com`, 'image-proxy-service': `https://image-proxy${devServerSuffix}.macro.com`, 'scheduled-action': `https://agent-schedule${devServerSuffix}.macro.com`, + 'calendar-service': `https://calendar-service${devServerSuffix}.macro.com`, } as const; type Servers = Record; diff --git a/js/app/packages/queries/calendar/events.ts b/js/app/packages/queries/calendar/events.ts new file mode 100644 index 0000000000..59d10e95b4 --- /dev/null +++ b/js/app/packages/queries/calendar/events.ts @@ -0,0 +1,122 @@ +import { throwOnErr } from '@core/util/result'; +import { queryClient } from '@queries/client'; +import { type MutationCallbacks, withCallbacks } from '@queries/utils'; +import { calendarClient } from '@service-calendar/client'; +import type { + CalendarEvent, + CreateEventRequest, + UpdateEventRequest, +} from '@service-calendar/generated/schemas'; +import { useMutation, useQuery } from '@tanstack/solid-query'; +import type { Accessor } from 'solid-js'; +import { calendarKeys } from './keys'; + +const QUERY_REFETCH_BEHAVIOR = { + refetchOnMount: 'always' as const, + refetchOnWindowFocus: 'always' as const, +}; + +export interface CalendarRange { + startMs: number; + endMs: number; +} + +/** Reactive list of the user's events intersecting `range()`. */ +export function useCalendarEventsQuery( + range: Accessor, + enabled: Accessor +) { + return useQuery(() => { + const { startMs, endMs } = range(); + return { + queryKey: calendarKeys.range({ startMs, endMs }).queryKey, + enabled: enabled(), + queryFn: async () => + throwOnErr(async () => await calendarClient.listEvents({ startMs, endMs })), + placeholderData: (prev: CalendarEvent[] | undefined) => prev, + reconcile: 'id', + ...QUERY_REFETCH_BEHAVIOR, + }; + }); +} + +/** Invalidates every cached calendar range so views refetch after a write. */ +export function invalidateCalendar() { + return queryClient.invalidateQueries({ queryKey: calendarKeys._def }); +} + +export function useCreateEventMutation( + callbacks?: MutationCallbacks +) { + return useMutation(() => ({ + mutationFn: async (request: CreateEventRequest) => + throwOnErr(async () => await calendarClient.createEvent(request)), + ...withCallbacks( + { + onSuccess: async () => { + await invalidateCalendar(); + }, + }, + callbacks + ), + })); +} + +export function useUpdateEventMutation( + callbacks?: MutationCallbacks< + CalendarEvent, + Error, + { id: string; body: UpdateEventRequest } + > +) { + return useMutation(() => ({ + mutationFn: async (args: { id: string; body: UpdateEventRequest }) => + throwOnErr(async () => await calendarClient.updateEvent(args)), + ...withCallbacks( + { + onSuccess: async () => { + await invalidateCalendar(); + }, + }, + callbacks + ), + })); +} + +export function useDeleteEventMutation( + callbacks?: MutationCallbacks<{ success: boolean }, Error, { id: string }> +) { + return useMutation(() => ({ + mutationFn: async (args: { id: string }) => + throwOnErr(async () => await calendarClient.deleteEvent(args)), + ...withCallbacks( + { + onSuccess: async () => { + await invalidateCalendar(); + }, + }, + callbacks + ), + })); +} + +export function useInviteAttendeesMutation( + callbacks?: MutationCallbacks< + CalendarEvent, + Error, + { id: string; emails: string[] } + > +) { + return useMutation(() => ({ + mutationFn: async (args: { id: string; emails: string[] }) => + throwOnErr(async () => await calendarClient.inviteAttendees(args)), + ...withCallbacks( + { + onSuccess: async () => { + await invalidateCalendar(); + }, + }, + callbacks + ), + })); +} diff --git a/js/app/packages/queries/calendar/keys.ts b/js/app/packages/queries/calendar/keys.ts new file mode 100644 index 0000000000..e6cc9c04c4 --- /dev/null +++ b/js/app/packages/queries/calendar/keys.ts @@ -0,0 +1,8 @@ +import { createQueryKeys } from '@lukemorales/query-key-factory'; + +export const calendarKeys = createQueryKeys('calendar', { + all: null, + range: (params: { startMs: number; endMs: number }) => ({ + queryKey: [params.startMs, params.endMs], + }), +}); diff --git a/js/app/packages/service-clients/orval.config.ts b/js/app/packages/service-clients/orval.config.ts index a69e8812b7..a3a6f04258 100644 --- a/js/app/packages/service-clients/orval.config.ts +++ b/js/app/packages/service-clients/orval.config.ts @@ -125,6 +125,19 @@ export default defineConfig({ target: './service-scheduled-action/openapi.json', }, }, + calendarService: { + output: { + client: 'fetch', + target: './service-calendar/generated/client.ts', + schemas: './service-calendar/generated/schemas', + override: { + useDates: false, + }, + }, + input: { + target: './service-calendar/openapi.json', + }, + }, searchService: { output: { client: 'fetch', diff --git a/js/app/packages/service-clients/service-calendar/client.ts b/js/app/packages/service-clients/service-calendar/client.ts new file mode 100644 index 0000000000..d0f308b4a2 --- /dev/null +++ b/js/app/packages/service-clients/service-calendar/client.ts @@ -0,0 +1,71 @@ +import { SERVER_HOSTS } from '@core/constant/servers'; +import { + type FetchWithTokenErrorCode, + fetchWithToken, +} from '@core/util/fetchWithToken'; +import type { ObjectLike, ResultError } from '@core/util/result'; +import type { SafeFetchInit } from '@core/util/safeFetch'; +import type { Result } from 'neverthrow'; +import type { + CalendarEvent, + CreateEventRequest, + UpdateEventRequest, +} from './generated/schemas'; + +const calendarHost: string = SERVER_HOSTS['calendar-service']; + +function calendarFetch( + url: string, + init?: SafeFetchInit +): Promise[]>>; +function calendarFetch( + url: string, + init?: SafeFetchInit +): Promise[]>>; +function calendarFetch( + url: string, + init?: SafeFetchInit +): + | Promise[]>> + | Promise[]>> { + return fetchWithToken(`${calendarHost}${url}`, init); +} + +export const calendarClient = { + listEvents: async (args: { startMs: number; endMs: number }) => + calendarFetch( + `/calendar/events?start_ms=${args.startMs}&end_ms=${args.endMs}`, + { method: 'GET' } + ), + + getEvent: async (args: { id: string }) => + calendarFetch(`/calendar/events/${args.id}`, { + method: 'GET', + }), + + createEvent: async (body: CreateEventRequest) => + calendarFetch('/calendar/events', { + method: 'POST', + body: JSON.stringify(body), + }), + + updateEvent: async (args: { id: string; body: UpdateEventRequest }) => + calendarFetch(`/calendar/events/${args.id}`, { + method: 'PUT', + body: JSON.stringify(args.body), + }), + + deleteEvent: async (args: { id: string }) => { + const result = await calendarFetch>( + `/calendar/events/${args.id}`, + { method: 'DELETE' } + ); + return result.map(() => ({ success: true })); + }, + + inviteAttendees: async (args: { id: string; emails: string[] }) => + calendarFetch(`/calendar/events/${args.id}/invite`, { + method: 'POST', + body: JSON.stringify({ emails: args.emails }), + }), +}; diff --git a/js/app/packages/service-clients/service-calendar/generated/schemas/attendee.ts b/js/app/packages/service-clients/service-calendar/generated/schemas/attendee.ts new file mode 100644 index 0000000000..47d7a81e25 --- /dev/null +++ b/js/app/packages/service-clients/service-calendar/generated/schemas/attendee.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * calendar + * OpenAPI spec version: 0.1.0 + */ + +export interface Attendee { + /** Attendee email address. */ + email: string; + /** Optional display name. */ + name: string | null; + /** RSVP status: pending | accepted | declined | tentative. */ + status: string; + /** Epoch-millis the invite was sent, if it has been. */ + invited_ms: number | null; +} diff --git a/js/app/packages/service-clients/service-calendar/generated/schemas/attendeeInput.ts b/js/app/packages/service-clients/service-calendar/generated/schemas/attendeeInput.ts new file mode 100644 index 0000000000..479be2eddb --- /dev/null +++ b/js/app/packages/service-clients/service-calendar/generated/schemas/attendeeInput.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * calendar + * OpenAPI spec version: 0.1.0 + */ + +export interface AttendeeInput { + /** Attendee email address. */ + email: string; + /** Optional display name. */ + name?: string | null; +} diff --git a/js/app/packages/service-clients/service-calendar/generated/schemas/calendarEvent.ts b/js/app/packages/service-clients/service-calendar/generated/schemas/calendarEvent.ts new file mode 100644 index 0000000000..669565f203 --- /dev/null +++ b/js/app/packages/service-clients/service-calendar/generated/schemas/calendarEvent.ts @@ -0,0 +1,28 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * calendar + * OpenAPI spec version: 0.1.0 + */ +import type { Attendee } from './attendee'; + +export interface CalendarEvent { + /** Event id (UUID, serialized as a string). */ + id: string; + /** Event title. */ + title: string; + /** Optional long-form description. */ + description: string | null; + /** Optional location / conferencing link. */ + location: string | null; + /** Start instant (epoch-millis, UTC). */ + start_ms: number; + /** End instant (epoch-millis, UTC). */ + end_ms: number; + /** Whether this is an all-day event. */ + all_day: boolean; + /** Accent color key (e.g. blue, green). */ + color: string; + /** Invited attendees. */ + attendees: Attendee[]; +} diff --git a/js/app/packages/service-clients/service-calendar/generated/schemas/createEventRequest.ts b/js/app/packages/service-clients/service-calendar/generated/schemas/createEventRequest.ts new file mode 100644 index 0000000000..b0372e3ad1 --- /dev/null +++ b/js/app/packages/service-clients/service-calendar/generated/schemas/createEventRequest.ts @@ -0,0 +1,26 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * calendar + * OpenAPI spec version: 0.1.0 + */ +import type { AttendeeInput } from './attendeeInput'; + +export interface CreateEventRequest { + /** Event title. */ + title: string; + /** Optional description. */ + description?: string | null; + /** Optional location. */ + location?: string | null; + /** Start instant (epoch-millis). */ + start_ms: number; + /** End instant (epoch-millis). */ + end_ms: number; + /** All-day flag. */ + all_day?: boolean; + /** Accent color key; defaults to `blue` when omitted. */ + color?: string; + /** Attendees to invite. */ + attendees?: AttendeeInput[]; +} diff --git a/js/app/packages/service-clients/service-calendar/generated/schemas/index.ts b/js/app/packages/service-clients/service-calendar/generated/schemas/index.ts new file mode 100644 index 0000000000..e98b7916d9 --- /dev/null +++ b/js/app/packages/service-clients/service-calendar/generated/schemas/index.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * calendar + * OpenAPI spec version: 0.1.0 + */ + +export * from './attendee'; +export * from './attendeeInput'; +export * from './calendarEvent'; +export * from './createEventRequest'; +export * from './inviteRequest'; +export * from './updateEventRequest'; diff --git a/js/app/packages/service-clients/service-calendar/generated/schemas/inviteRequest.ts b/js/app/packages/service-clients/service-calendar/generated/schemas/inviteRequest.ts new file mode 100644 index 0000000000..474567e4c2 --- /dev/null +++ b/js/app/packages/service-clients/service-calendar/generated/schemas/inviteRequest.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * calendar + * OpenAPI spec version: 0.1.0 + */ + +export interface InviteRequest { + /** Attendee emails that have just been (or are being) emailed an invite. */ + emails: string[]; +} diff --git a/js/app/packages/service-clients/service-calendar/generated/schemas/updateEventRequest.ts b/js/app/packages/service-clients/service-calendar/generated/schemas/updateEventRequest.ts new file mode 100644 index 0000000000..cd1a97de2c --- /dev/null +++ b/js/app/packages/service-clients/service-calendar/generated/schemas/updateEventRequest.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * calendar + * OpenAPI spec version: 0.1.0 + */ +import type { CreateEventRequest } from './createEventRequest'; + +/** Body for PUT /calendar/events/{id}. Same shape as create. */ +export type UpdateEventRequest = CreateEventRequest; diff --git a/js/app/scripts/generate-api-schema.ts b/js/app/scripts/generate-api-schema.ts index 1eca22d37f..b3235b4cc6 100644 --- a/js/app/scripts/generate-api-schema.ts +++ b/js/app/scripts/generate-api-schema.ts @@ -27,6 +27,7 @@ const serviceToCrate: Record = { "email-service": "email_service", "search-service": "search_service", "scheduled-action": "scheduled_action", + calendar: "calendar_service", }; const getRustCloudStorageDir = () => diff --git a/js/app/scripts/services.ts b/js/app/scripts/services.ts index 3ce8f9b321..c76cc9651d 100644 --- a/js/app/scripts/services.ts +++ b/js/app/scripts/services.ts @@ -104,6 +104,14 @@ export const services: Service[] = [ output: "../packages/service-clients/service-scheduled-action/", orvalKey: "scheduledActionService", }, + { + name: "calendar", + dev: "https://calendar-service-dev.macro.com/api-doc/openapi.json", + prod: "https://calendar-service.macro.com/api-doc/openapi.json", + local: "http://localhost:8101/api-doc/openapi.json", + output: "../packages/service-clients/service-calendar/", + orvalKey: "calendarService", + }, ]; export const documentCognitionBase: Service = { diff --git a/js/app/tsconfig.json b/js/app/tsconfig.json index 390b24d108..d3b961baf7 100644 --- a/js/app/tsconfig.json +++ b/js/app/tsconfig.json @@ -111,6 +111,9 @@ "@service-scheduled-action/*": [ "./packages/service-clients/service-scheduled-action/*" ], + "@service-calendar/*": [ + "./packages/service-clients/service-calendar/*" + ], "@service-email/*": [ "./packages/service-clients/service-email/*" ], @@ -150,6 +153,9 @@ "@block-unknown/*": [ "./packages/block-unknown/*" ], + "@block-calendar/*": [ + "./packages/block-calendar/*" + ], "@block-video/*": [ "./packages/block-video/*" ], diff --git a/rust/cloud-storage/Cargo.lock b/rust/cloud-storage/Cargo.lock index 4b26d299a3..d21a440a55 100644 --- a/rust/cloud-storage/Cargo.lock +++ b/rust/cloud-storage/Cargo.lock @@ -1892,6 +1892,49 @@ dependencies = [ "utoipa", ] +[[package]] +name = "calendar" +version = "0.1.0" +dependencies = [ + "axum", + "axum-extra", + "macro_auth", + "macro_middleware", + "macro_user_id", + "model_user", + "rootcause", + "serde", + "serde_json", + "sqlx", + "tokio", + "tracing", + "utoipa", +] + +[[package]] +name = "calendar_service" +version = "0.1.0" +dependencies = [ + "anyhow", + "aws-sdk-secretsmanager", + "axum", + "calendar", + "http-body-util", + "macro_auth", + "macro_aws_config", + "macro_cors", + "macro_entrypoint", + "macro_env", + "macro_middleware", + "secretsmanager_client", + "sqlx", + "tokio", + "tower 0.5.3", + "tracing", + "utoipa", + "utoipa-swagger-ui", +] + [[package]] name = "call" version = "0.1.0" diff --git a/rust/cloud-storage/Cargo.toml b/rust/cloud-storage/Cargo.toml index 2a16e91730..d91a1eb762 100644 --- a/rust/cloud-storage/Cargo.toml +++ b/rust/cloud-storage/Cargo.toml @@ -5,6 +5,7 @@ members = [ "agent", "authentication_service", "backfill_entity_access", + "calendar_service", "bot_id", "bots", "call_recording_preview_handler", diff --git a/rust/cloud-storage/calendar/Cargo.toml b/rust/cloud-storage/calendar/Cargo.toml new file mode 100644 index 0000000000..9749f039bb --- /dev/null +++ b/rust/cloud-storage/calendar/Cargo.toml @@ -0,0 +1,36 @@ +[package] +edition = "2024" +name = "calendar" +publish = false +version = "0.1.0" + +[features] +axum = [ + "dep:axum", + "dep:axum-extra", + "dep:macro_auth", + "dep:macro_middleware", + "dep:model_user", + "dep:utoipa", +] +default = ["axum", "inbound", "outbound", "ports"] +inbound = ["axum", "ports"] +outbound = ["dep:sqlx", "ports"] +ports = [] + +[dependencies] +axum = { workspace = true, optional = true } +axum-extra = { workspace = true, optional = true } +macro_auth = { path = "../macro_auth", optional = true } +macro_middleware = { path = "../macro_middleware", default-features = false, features = [ + "auth", +], optional = true } +macro_user_id = { path = "../macro_user_id" } +model_user = { path = "../model_user", features = ["axum"], optional = true } +rootcause = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sqlx = { workspace = true, optional = true } +tokio = { workspace = true } +tracing = { workspace = true } +utoipa = { workspace = true, optional = true } diff --git a/rust/cloud-storage/calendar/src/domain.rs b/rust/cloud-storage/calendar/src/domain.rs new file mode 100644 index 0000000000..2ed1d5dafb --- /dev/null +++ b/rust/cloud-storage/calendar/src/domain.rs @@ -0,0 +1,6 @@ +/// Wire/domain models shared across the HTTP and persistence layers. +pub mod models; +/// Port traits defining the repository and service boundaries. +pub mod ports; +/// The calendar domain service (business logic). +pub mod service; diff --git a/rust/cloud-storage/calendar/src/domain/models.rs b/rust/cloud-storage/calendar/src/domain/models.rs new file mode 100644 index 0000000000..d655dbeba4 --- /dev/null +++ b/rust/cloud-storage/calendar/src/domain/models.rs @@ -0,0 +1,97 @@ +//! Request/response models for the calendar API. +//! +//! Instants are epoch-millis (`i64`) to match the frontend and the DB columns. + +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "axum")] +use utoipa::ToSchema; + +/// An invited attendee on an event. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "axum", derive(ToSchema))] +pub struct Attendee { + /// Attendee email address. + pub email: String, + /// Optional display name. + pub name: Option, + /// RSVP status: `pending` | `accepted` | `declined` | `tentative`. + pub status: String, + /// Epoch-millis the invite was sent, if it has been. + pub invited_ms: Option, +} + +/// A calendar event owned by the requesting user. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "axum", derive(ToSchema))] +pub struct CalendarEvent { + /// Event id (UUID, serialized as a string). + pub id: String, + /// Event title. + pub title: String, + /// Optional long-form description. + pub description: Option, + /// Optional location / conferencing link. + pub location: Option, + /// Start instant (epoch-millis, UTC). + pub start_ms: i64, + /// End instant (epoch-millis, UTC). + pub end_ms: i64, + /// Whether this is an all-day event. + pub all_day: bool, + /// Accent color key (e.g. `blue`, `green`). + pub color: String, + /// Invited attendees. + pub attendees: Vec, +} + +/// An attendee supplied when creating/updating an event. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "axum", derive(ToSchema))] +pub struct AttendeeInput { + /// Attendee email address. + pub email: String, + /// Optional display name. + pub name: Option, +} + +/// Body for `POST /calendar/events`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "axum", derive(ToSchema))] +pub struct CreateEventRequest { + /// Event title. + pub title: String, + /// Optional description. + pub description: Option, + /// Optional location. + pub location: Option, + /// Start instant (epoch-millis). + pub start_ms: i64, + /// End instant (epoch-millis). + pub end_ms: i64, + /// All-day flag. + #[serde(default)] + pub all_day: bool, + /// Accent color key; defaults to `blue` when omitted. + #[serde(default = "default_color")] + pub color: String, + /// Attendees to invite. + #[serde(default)] + pub attendees: Vec, +} + +/// Body for `PUT /calendar/events/{id}`. Same shape as create. +pub type UpdateEventRequest = CreateEventRequest; + +/// Body for `POST /calendar/events/{id}/invite`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "axum", derive(ToSchema))] +pub struct InviteRequest { + /// Attendee emails that have just been (or are being) emailed an invite. + /// Any not already on the event are added. + pub emails: Vec, +} + +fn default_color() -> String { + "blue".to_string() +} diff --git a/rust/cloud-storage/calendar/src/domain/ports.rs b/rust/cloud-storage/calendar/src/domain/ports.rs new file mode 100644 index 0000000000..6f323d205d --- /dev/null +++ b/rust/cloud-storage/calendar/src/domain/ports.rs @@ -0,0 +1,101 @@ +//! Port traits separating the domain from infrastructure. + +use crate::domain::models::{CalendarEvent, CreateEventRequest, UpdateEventRequest}; +use rootcause::Report; + +/// Persistence boundary for calendar events and their attendees. +pub trait CalendarRepository: Send + Sync + 'static { + /// Lists a user's events whose span intersects `[start_ms, end_ms)`. + fn list_events( + &self, + user_id: &str, + start_ms: i64, + end_ms: i64, + ) -> impl Future, Report>> + Send; + + /// Fetches a single event owned by `user_id`. + fn get_event( + &self, + user_id: &str, + event_id: &str, + ) -> impl Future, Report>> + Send; + + /// Creates an event for `user_id` and returns the persisted record. + fn create_event( + &self, + user_id: &str, + request: CreateEventRequest, + ) -> impl Future> + Send; + + /// Updates an event owned by `user_id`. Returns `None` when not found. + fn update_event( + &self, + user_id: &str, + event_id: &str, + request: UpdateEventRequest, + ) -> impl Future, Report>> + Send; + + /// Deletes an event owned by `user_id`. Returns whether a row was removed. + fn delete_event( + &self, + user_id: &str, + event_id: &str, + ) -> impl Future> + Send; + + /// Marks the given attendee emails as invited (adding any new ones), then + /// returns the refreshed event. Returns `None` when the event is missing. + fn mark_invited( + &self, + user_id: &str, + event_id: &str, + emails: Vec, + ) -> impl Future, Report>> + Send; +} + +/// Business-logic boundary consumed by the HTTP layer. +pub trait CalendarService: Send + Sync + 'static { + /// Lists events intersecting the window. + fn list_events( + &self, + user_id: &str, + start_ms: i64, + end_ms: i64, + ) -> impl Future, Report>> + Send; + + /// Fetches a single event. + fn get_event( + &self, + user_id: &str, + event_id: &str, + ) -> impl Future, Report>> + Send; + + /// Creates an event. + fn create_event( + &self, + user_id: &str, + request: CreateEventRequest, + ) -> impl Future> + Send; + + /// Updates an event. + fn update_event( + &self, + user_id: &str, + event_id: &str, + request: UpdateEventRequest, + ) -> impl Future, Report>> + Send; + + /// Deletes an event. + fn delete_event( + &self, + user_id: &str, + event_id: &str, + ) -> impl Future> + Send; + + /// Records that attendees were invited. + fn mark_invited( + &self, + user_id: &str, + event_id: &str, + emails: Vec, + ) -> impl Future, Report>> + Send; +} diff --git a/rust/cloud-storage/calendar/src/domain/service.rs b/rust/cloud-storage/calendar/src/domain/service.rs new file mode 100644 index 0000000000..1c2fa04e7f --- /dev/null +++ b/rust/cloud-storage/calendar/src/domain/service.rs @@ -0,0 +1,84 @@ +//! The calendar domain service: thin business logic over a repository. + +use crate::domain::models::{CalendarEvent, CreateEventRequest, UpdateEventRequest}; +use crate::domain::ports::{CalendarRepository, CalendarService}; +use rootcause::Report; +use tracing::instrument; + +/// Domain service backed by a [`CalendarRepository`]. +pub struct CalendarDomainService { + /// Persistence adapter. + pub repository: R, +} + +impl CalendarDomainService { + /// Creates a new service over the given repository. + pub fn new(repository: R) -> Self { + Self { repository } + } +} + +/// Ensures `start <= end`, swapping if a client sent them reversed. +fn normalize(mut request: CreateEventRequest) -> CreateEventRequest { + if request.end_ms < request.start_ms { + std::mem::swap(&mut request.start_ms, &mut request.end_ms); + } + request +} + +impl CalendarService for CalendarDomainService { + #[instrument(err, skip(self))] + async fn list_events( + &self, + user_id: &str, + start_ms: i64, + end_ms: i64, + ) -> Result, Report> { + self.repository.list_events(user_id, start_ms, end_ms).await + } + + #[instrument(err, skip(self))] + async fn get_event( + &self, + user_id: &str, + event_id: &str, + ) -> Result, Report> { + self.repository.get_event(user_id, event_id).await + } + + #[instrument(err, skip(self, request))] + async fn create_event( + &self, + user_id: &str, + request: CreateEventRequest, + ) -> Result { + self.repository.create_event(user_id, normalize(request)).await + } + + #[instrument(err, skip(self, request))] + async fn update_event( + &self, + user_id: &str, + event_id: &str, + request: UpdateEventRequest, + ) -> Result, Report> { + self.repository + .update_event(user_id, event_id, normalize(request)) + .await + } + + #[instrument(err, skip(self))] + async fn delete_event(&self, user_id: &str, event_id: &str) -> Result { + self.repository.delete_event(user_id, event_id).await + } + + #[instrument(err, skip(self))] + async fn mark_invited( + &self, + user_id: &str, + event_id: &str, + emails: Vec, + ) -> Result, Report> { + self.repository.mark_invited(user_id, event_id, emails).await + } +} diff --git a/rust/cloud-storage/calendar/src/inbound.rs b/rust/cloud-storage/calendar/src/inbound.rs new file mode 100644 index 0000000000..eb2ef3cde4 --- /dev/null +++ b/rust/cloud-storage/calendar/src/inbound.rs @@ -0,0 +1,2 @@ +/// HTTP handlers, router, and OpenAPI document for the calendar API. +pub mod http; diff --git a/rust/cloud-storage/calendar/src/inbound/http.rs b/rust/cloud-storage/calendar/src/inbound/http.rs new file mode 100644 index 0000000000..5705d154eb --- /dev/null +++ b/rust/cloud-storage/calendar/src/inbound/http.rs @@ -0,0 +1,263 @@ +//! HTTP layer for the calendar service. + +use std::sync::Arc; + +use crate::domain::models::{ + Attendee, AttendeeInput, CalendarEvent, CreateEventRequest, InviteRequest, UpdateEventRequest, +}; +use crate::domain::ports::CalendarService; +use axum::Router; +use axum::extract::{Json, Path, Query, State}; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use model_user::axum_extractor::MacroUserExtractor; +use serde::Deserialize; +use tracing::instrument; +use utoipa::{IntoParams, OpenApi}; + +/// Query parameters for `GET /calendar/events`. +#[derive(Debug, Deserialize, IntoParams)] +pub struct ListQuery { + /// Window start instant (epoch-millis). Events ending after this are returned. + pub start_ms: i64, + /// Window end instant (epoch-millis). Events starting before this are returned. + pub end_ms: i64, +} + +/// Maps a repository error to a 500 while logging the cause. +fn internal(error: rootcause::Report) -> StatusCode { + tracing::error!(error = ?error, "calendar repository error"); + StatusCode::INTERNAL_SERVER_ERROR +} + +/// GET /calendar/events +#[utoipa::path( + get, + tag = "calendar", + operation_id = "list_events", + path = "/calendar/events", + params(ListQuery), + responses( + (status = 200, body = Vec), + (status = 401, body = String), + (status = 500, body = String) + ) +)] +#[instrument(skip(service, macro_user_id), fields(user_id = macro_user_id.as_ref()))] +pub async fn list_events_handler( + State(service): State>, + MacroUserExtractor { macro_user_id, .. }: MacroUserExtractor, + Query(query): Query, +) -> Result>, StatusCode> { + let events = service + .list_events(macro_user_id.as_ref(), query.start_ms, query.end_ms) + .await + .map_err(internal)?; + Ok(Json(events)) +} + +/// POST /calendar/events +#[utoipa::path( + post, + tag = "calendar", + operation_id = "create_event", + path = "/calendar/events", + request_body = CreateEventRequest, + responses( + (status = 200, body = CalendarEvent), + (status = 401, body = String), + (status = 500, body = String) + ) +)] +#[instrument(skip(service, macro_user_id, body), fields(user_id = macro_user_id.as_ref()))] +pub async fn create_event_handler( + State(service): State>, + MacroUserExtractor { macro_user_id, .. }: MacroUserExtractor, + Json(body): Json, +) -> Result, StatusCode> { + let event = service + .create_event(macro_user_id.as_ref(), body) + .await + .map_err(internal)?; + Ok(Json(event)) +} + +/// GET /calendar/events/{id} +#[utoipa::path( + get, + tag = "calendar", + operation_id = "get_event", + path = "/calendar/events/{id}", + params(("id" = String, Path, description = "Event id")), + responses( + (status = 200, body = CalendarEvent), + (status = 401, body = String), + (status = 404, body = String), + (status = 500, body = String) + ) +)] +#[instrument(skip(service, macro_user_id), fields(user_id = macro_user_id.as_ref()))] +pub async fn get_event_handler( + State(service): State>, + MacroUserExtractor { macro_user_id, .. }: MacroUserExtractor, + Path(id): Path, +) -> Result, StatusCode> { + service + .get_event(macro_user_id.as_ref(), &id) + .await + .map_err(internal)? + .map(Json) + .ok_or(StatusCode::NOT_FOUND) +} + +/// PUT /calendar/events/{id} +#[utoipa::path( + put, + tag = "calendar", + operation_id = "update_event", + path = "/calendar/events/{id}", + params(("id" = String, Path, description = "Event id")), + request_body = CreateEventRequest, + responses( + (status = 200, body = CalendarEvent), + (status = 401, body = String), + (status = 404, body = String), + (status = 500, body = String) + ) +)] +#[instrument(skip(service, macro_user_id, body), fields(user_id = macro_user_id.as_ref()))] +pub async fn update_event_handler( + State(service): State>, + MacroUserExtractor { macro_user_id, .. }: MacroUserExtractor, + Path(id): Path, + Json(body): Json, +) -> Result, StatusCode> { + service + .update_event(macro_user_id.as_ref(), &id, body) + .await + .map_err(internal)? + .map(Json) + .ok_or(StatusCode::NOT_FOUND) +} + +/// DELETE /calendar/events/{id} +#[utoipa::path( + delete, + tag = "calendar", + operation_id = "delete_event", + path = "/calendar/events/{id}", + params(("id" = String, Path, description = "Event id")), + responses( + (status = 204), + (status = 401, body = String), + (status = 404, body = String), + (status = 500, body = String) + ) +)] +#[instrument(skip(service, macro_user_id), fields(user_id = macro_user_id.as_ref()))] +pub async fn delete_event_handler( + State(service): State>, + MacroUserExtractor { macro_user_id, .. }: MacroUserExtractor, + Path(id): Path, +) -> Result { + let removed = service + .delete_event(macro_user_id.as_ref(), &id) + .await + .map_err(internal)?; + if removed { + Ok(StatusCode::NO_CONTENT) + } else { + Err(StatusCode::NOT_FOUND) + } +} + +/// POST /calendar/events/{id}/invite +/// +/// Records that the given attendees were invited (the actual email is sent +/// from the client through the user's connected mailbox). Returns the updated +/// event so the UI can reflect invite timestamps. +#[utoipa::path( + post, + tag = "calendar", + operation_id = "invite_attendees", + path = "/calendar/events/{id}/invite", + params(("id" = String, Path, description = "Event id")), + request_body = InviteRequest, + responses( + (status = 200, body = CalendarEvent), + (status = 401, body = String), + (status = 404, body = String), + (status = 500, body = String) + ) +)] +#[instrument(skip(service, macro_user_id, body), fields(user_id = macro_user_id.as_ref()))] +pub async fn invite_handler( + State(service): State>, + MacroUserExtractor { macro_user_id, .. }: MacroUserExtractor, + Path(id): Path, + Json(body): Json, +) -> Result, StatusCode> { + service + .mark_invited(macro_user_id.as_ref(), &id, body.emails) + .await + .map_err(internal)? + .map(Json) + .ok_or(StatusCode::NOT_FOUND) +} + +/// Application state for the calendar HTTP service. +pub struct AppState { + /// JWT validation arguments for the auth middleware. + pub jwt_args: macro_auth::middleware::decode_jwt::JwtValidationArgs, + /// The calendar service instance. + pub calendar_service: Arc, +} + +/// Builds the calendar routes (without auth middleware). +pub fn calendar_router() -> Router> { + Router::new() + .route( + "/calendar/events", + get(list_events_handler::).post(create_event_handler::), + ) + .route( + "/calendar/events/{id}", + get(get_event_handler::) + .put(update_event_handler::) + .delete(delete_event_handler::), + ) + .route("/calendar/events/{id}/invite", post(invite_handler::)) +} + +/// Builds the full API router with JWT auth middleware applied. +pub fn api_router(app_state: AppState) -> Router { + calendar_router::() + .layer(axum::middleware::from_fn_with_state( + app_state.jwt_args.clone(), + macro_middleware::auth::decode_jwt::handler, + )) + .with_state(app_state.calendar_service) +} + +/// OpenAPI documentation for the calendar service. +#[derive(OpenApi)] +#[openapi( + info(terms_of_service = "https://macro.com/terms"), + paths( + list_events_handler, + create_event_handler, + get_event_handler, + update_event_handler, + delete_event_handler, + invite_handler, + ), + components(schemas( + CalendarEvent, + Attendee, + AttendeeInput, + CreateEventRequest, + InviteRequest, + )), + tags((name = "calendar", description = "Macro Calendar Service")) +)] +pub struct ApiDoc; diff --git a/rust/cloud-storage/calendar/src/lib.rs b/rust/cloud-storage/calendar/src/lib.rs new file mode 100644 index 0000000000..cdee78ad8a --- /dev/null +++ b/rust/cloud-storage/calendar/src/lib.rs @@ -0,0 +1,16 @@ +//! Calendar service library. +//! +//! Hexagonal-architecture calendar feature: user-owned events with invited +//! attendees, exposed over HTTP and persisted in Postgres. Mirrors the layout +//! of the `contacts` crate (domain / inbound / outbound). + +#![deny(missing_docs)] + +/// Domain layer: models, port traits, and the calendar service. +pub mod domain; +/// Inbound adapters (HTTP handlers + router + OpenAPI doc). +#[cfg(feature = "inbound")] +pub mod inbound; +/// Outbound adapters (Postgres repository). +#[cfg(feature = "outbound")] +pub mod outbound; diff --git a/rust/cloud-storage/calendar/src/outbound.rs b/rust/cloud-storage/calendar/src/outbound.rs new file mode 100644 index 0000000000..1d79a8241c --- /dev/null +++ b/rust/cloud-storage/calendar/src/outbound.rs @@ -0,0 +1,2 @@ +/// Postgres-backed [`crate::domain::ports::CalendarRepository`]. +pub mod repository; diff --git a/rust/cloud-storage/calendar/src/outbound/repository.rs b/rust/cloud-storage/calendar/src/outbound/repository.rs new file mode 100644 index 0000000000..7de38d6a9d --- /dev/null +++ b/rust/cloud-storage/calendar/src/outbound/repository.rs @@ -0,0 +1,324 @@ +//! Postgres adapter for the calendar repository. +//! +//! Uses sqlx's runtime-checked query API (rather than the `query!` macros) so +//! the crate compiles without a live database or a prepared offline cache. + +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::domain::models::{Attendee, CalendarEvent, CreateEventRequest, UpdateEventRequest}; +use crate::domain::ports::CalendarRepository; +use rootcause::Report; +use sqlx::PgPool; +use sqlx::types::Uuid; + +/// Database-backed implementation of [`CalendarRepository`]. +pub struct DbCalendarRepository { + /// The PostgreSQL connection pool. + pub db: PgPool, +} + +impl DbCalendarRepository { + /// Creates a new repository over the given pool. + pub fn new(db: PgPool) -> Self { + Self { db } + } +} + +#[derive(sqlx::FromRow)] +struct EventRow { + id: Uuid, + title: String, + description: Option, + location: Option, + start_ms: i64, + end_ms: i64, + all_day: bool, + color: String, +} + +#[derive(sqlx::FromRow)] +struct AttendeeRow { + event_id: Uuid, + email: String, + name: Option, + status: String, + invited_ms: Option, +} + +const EVENT_COLUMNS: &str = + "id, title, description, location, start_ms, end_ms, all_day, color"; + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +/// Parse a path-supplied id; a malformed id can never match a row, so callers +/// treat `None` as "not found" rather than surfacing a 500. +fn parse_id(event_id: &str) -> Option { + Uuid::parse_str(event_id).ok() +} + +impl EventRow { + fn into_event(self, attendees: Vec) -> CalendarEvent { + CalendarEvent { + id: self.id.to_string(), + title: self.title, + description: self.description, + location: self.location, + start_ms: self.start_ms, + end_ms: self.end_ms, + all_day: self.all_day, + color: self.color, + attendees, + } + } +} + +impl AttendeeRow { + fn into_attendee(self) -> Attendee { + Attendee { + email: self.email, + name: self.name, + status: self.status, + invited_ms: self.invited_ms, + } + } +} + +impl DbCalendarRepository { + /// Loads attendees for a set of event ids, grouped by event id. + async fn attendees_for( + &self, + event_ids: &[Uuid], + ) -> Result>, Report> { + let mut grouped: HashMap> = HashMap::new(); + if event_ids.is_empty() { + return Ok(grouped); + } + + let rows = sqlx::query_as::<_, AttendeeRow>( + "SELECT event_id, email, name, status, invited_ms + FROM calendar_attendee + WHERE event_id = ANY($1) + ORDER BY created_ms ASC", + ) + .bind(event_ids) + .fetch_all(&self.db) + .await?; + + for row in rows { + grouped.entry(row.event_id).or_default().push(row.into_attendee()); + } + Ok(grouped) + } + + /// Re-reads a single owned event and assembles its attendees. + async fn fetch_one( + &self, + user_id: &str, + id: Uuid, + ) -> Result, Report> { + let row = sqlx::query_as::<_, EventRow>(&format!( + "SELECT {EVENT_COLUMNS} FROM calendar_event WHERE id = $1 AND user_id = $2" + )) + .bind(id) + .bind(user_id) + .fetch_optional(&self.db) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + let mut grouped = self.attendees_for(&[row.id]).await?; + let attendees = grouped.remove(&row.id).unwrap_or_default(); + Ok(Some(row.into_event(attendees))) + } + + /// Inserts/updates attendees from a create/update request. + async fn upsert_attendees( + &self, + event_id: Uuid, + attendees: &[crate::domain::models::AttendeeInput], + ) -> Result<(), Report> { + for attendee in attendees { + sqlx::query( + "INSERT INTO calendar_attendee (event_id, email, name) + VALUES ($1, $2, $3) + ON CONFLICT (event_id, email) DO UPDATE SET name = EXCLUDED.name", + ) + .bind(event_id) + .bind(&attendee.email) + .bind(&attendee.name) + .execute(&self.db) + .await?; + } + Ok(()) + } +} + +impl CalendarRepository for DbCalendarRepository { + async fn list_events( + &self, + user_id: &str, + start_ms: i64, + end_ms: i64, + ) -> Result, Report> { + let event_rows = sqlx::query_as::<_, EventRow>(&format!( + "SELECT {EVENT_COLUMNS} FROM calendar_event + WHERE user_id = $1 AND start_ms < $3 AND end_ms > $2 + ORDER BY start_ms ASC" + )) + .bind(user_id) + .bind(start_ms) + .bind(end_ms) + .fetch_all(&self.db) + .await?; + + let ids: Vec = event_rows.iter().map(|r| r.id).collect(); + let mut grouped = self.attendees_for(&ids).await?; + + Ok(event_rows + .into_iter() + .map(|row| { + let attendees = grouped.remove(&row.id).unwrap_or_default(); + row.into_event(attendees) + }) + .collect()) + } + + async fn get_event( + &self, + user_id: &str, + event_id: &str, + ) -> Result, Report> { + let Some(id) = parse_id(event_id) else { + return Ok(None); + }; + self.fetch_one(user_id, id).await + } + + async fn create_event( + &self, + user_id: &str, + request: CreateEventRequest, + ) -> Result { + let row = sqlx::query_as::<_, EventRow>(&format!( + "INSERT INTO calendar_event + (user_id, title, description, location, start_ms, end_ms, all_day, color) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING {EVENT_COLUMNS}" + )) + .bind(user_id) + .bind(&request.title) + .bind(&request.description) + .bind(&request.location) + .bind(request.start_ms) + .bind(request.end_ms) + .bind(request.all_day) + .bind(&request.color) + .fetch_one(&self.db) + .await?; + + self.upsert_attendees(row.id, &request.attendees).await?; + + let mut grouped = self.attendees_for(&[row.id]).await?; + let attendees = grouped.remove(&row.id).unwrap_or_default(); + Ok(row.into_event(attendees)) + } + + async fn update_event( + &self, + user_id: &str, + event_id: &str, + request: UpdateEventRequest, + ) -> Result, Report> { + let Some(id) = parse_id(event_id) else { + return Ok(None); + }; + + let updated = sqlx::query_as::<_, EventRow>(&format!( + "UPDATE calendar_event SET + title = $3, description = $4, location = $5, + start_ms = $6, end_ms = $7, all_day = $8, color = $9, + updated_ms = (floor(extract(epoch FROM now()) * 1000))::bigint + WHERE id = $1 AND user_id = $2 + RETURNING {EVENT_COLUMNS}" + )) + .bind(id) + .bind(user_id) + .bind(&request.title) + .bind(&request.description) + .bind(&request.location) + .bind(request.start_ms) + .bind(request.end_ms) + .bind(request.all_day) + .bind(&request.color) + .fetch_optional(&self.db) + .await?; + + if updated.is_none() { + return Ok(None); + } + + // Sync attendees: drop any no longer present, then upsert the rest. + let emails: Vec = request.attendees.iter().map(|a| a.email.clone()).collect(); + sqlx::query( + "DELETE FROM calendar_attendee WHERE event_id = $1 AND NOT (email = ANY($2))", + ) + .bind(id) + .bind(&emails) + .execute(&self.db) + .await?; + self.upsert_attendees(id, &request.attendees).await?; + + self.fetch_one(user_id, id).await + } + + async fn delete_event(&self, user_id: &str, event_id: &str) -> Result { + let Some(id) = parse_id(event_id) else { + return Ok(false); + }; + let result = sqlx::query("DELETE FROM calendar_event WHERE id = $1 AND user_id = $2") + .bind(id) + .bind(user_id) + .execute(&self.db) + .await?; + Ok(result.rows_affected() > 0) + } + + async fn mark_invited( + &self, + user_id: &str, + event_id: &str, + emails: Vec, + ) -> Result, Report> { + let Some(id) = parse_id(event_id) else { + return Ok(None); + }; + + // Ownership check: only the owner may invite, and we must 404 otherwise. + if self.fetch_one(user_id, id).await?.is_none() { + return Ok(None); + } + + let invited_ms = now_ms(); + for email in &emails { + sqlx::query( + "INSERT INTO calendar_attendee (event_id, email, invited_ms) + VALUES ($1, $2, $3) + ON CONFLICT (event_id, email) DO UPDATE SET invited_ms = EXCLUDED.invited_ms", + ) + .bind(id) + .bind(email) + .bind(invited_ms) + .execute(&self.db) + .await?; + } + + self.fetch_one(user_id, id).await + } +} diff --git a/rust/cloud-storage/calendar_service/Cargo.toml b/rust/cloud-storage/calendar_service/Cargo.toml new file mode 100644 index 0000000000..c4f82e903b --- /dev/null +++ b/rust/cloud-storage/calendar_service/Cargo.toml @@ -0,0 +1,38 @@ +[package] +default-run = "calendar_service" +edition = "2024" +name = "calendar_service" +publish = false +version = "0.1.0" + +[[bin]] +name = "calendar_service" +path = "src/main.rs" + +[[bin]] +name = "calendar_service_openapi" +path = "src/openapi.rs" + +[dependencies] +anyhow = { workspace = true } +aws-sdk-secretsmanager = { workspace = true } +axum = { workspace = true } +calendar = { path = "../calendar", features = ["axum"] } +macro_auth = { path = "../macro_auth" } +macro_aws_config = { path = "../macro_aws_config" } +macro_cors = { path = "../macro_cors" } +macro_entrypoint = { path = "../macro_entrypoint" } +macro_env = { path = "../macro_env" } +macro_middleware = { path = "../macro_middleware", default-features = false, features = [ + "auth", +] } +secretsmanager_client = { path = "../secretsmanager_client" } +sqlx = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +utoipa = { workspace = true } +utoipa-swagger-ui = { workspace = true } + +[dev-dependencies] +http-body-util = { workspace = true } +tower = { workspace = true } diff --git a/rust/cloud-storage/calendar_service/src/config.rs b/rust/cloud-storage/calendar_service/src/config.rs new file mode 100644 index 0000000000..10d6579725 --- /dev/null +++ b/rust/cloud-storage/calendar_service/src/config.rs @@ -0,0 +1,32 @@ +use anyhow::Context; +pub use macro_env::Environment; + +/// Runtime configuration for the calendar service. +pub struct Config { + /// Port number to listen on. + pub port: usize, + /// The deployment environment. + pub environment: Environment, + /// Postgres connection URL. + pub database_url: String, +} + +impl Config { + pub fn from_env() -> anyhow::Result { + let port: usize = std::env::var("PORT") + .unwrap_or("8080".to_string()) + .parse::() + .context("PORT must be a valid number")?; + + let database_url = + std::env::var("DATABASE_URL").context("DATABASE_URL must be provided")?; + + let environment = Environment::new_or_prod(); + + Ok(Config { + port, + environment, + database_url, + }) + } +} diff --git a/rust/cloud-storage/calendar_service/src/health.rs b/rust/cloud-storage/calendar_service/src/health.rs new file mode 100644 index 0000000000..9d08f321a0 --- /dev/null +++ b/rust/cloud-storage/calendar_service/src/health.rs @@ -0,0 +1,15 @@ +use axum::{Router, routing::get}; + +/// Health check. +#[utoipa::path( + get, + path = "/health", + responses((status = 200, description = "health", body = String)) +)] +pub async fn health_handler() -> String { + "healthy".to_string() +} + +pub fn router() -> Router { + Router::new().route("/health", get(health_handler)) +} diff --git a/rust/cloud-storage/calendar_service/src/main.rs b/rust/cloud-storage/calendar_service/src/main.rs new file mode 100644 index 0000000000..1e452ec60a --- /dev/null +++ b/rust/cloud-storage/calendar_service/src/main.rs @@ -0,0 +1,75 @@ +#![recursion_limit = "256"] +mod config; +mod health; + +use std::sync::Arc; + +use anyhow::Context; +use calendar::domain::service::CalendarDomainService; +use calendar::inbound::http::{ApiDoc, AppState, api_router}; +use calendar::outbound::repository::DbCalendarRepository; +use config::{Config, Environment}; +use macro_entrypoint::MacroEntrypoint; +use sqlx::postgres::PgPoolOptions; +use utoipa::OpenApi; +use utoipa_swagger_ui::SwaggerUi; + +async fn connect_to_database(config: &Config) -> anyhow::Result { + let (min_connections, max_connections): (u32, u32) = match config.environment { + Environment::Production => (5, 30), + Environment::Develop => (1, 25), + Environment::Local => (1, 10), + }; + + let db = PgPoolOptions::new() + .min_connections(min_connections) + .max_connections(max_connections) + .connect(&config.database_url) + .await + .context("could not connect to db")?; + Ok(db) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + MacroEntrypoint::default().init(); + + let config = Config::from_env().context("expected to be able to generate config")?; + + let db = connect_to_database(&config).await?; + + let secretsmanager_client = secretsmanager_client::SecretsManager::new( + aws_sdk_secretsmanager::Client::new(¯o_aws_config::get_macro_aws_config().await), + ); + + let jwt_args = macro_auth::middleware::decode_jwt::JwtValidationArgs::new_with_secret_manager( + config.environment, + &secretsmanager_client, + ) + .await?; + + let repository = DbCalendarRepository::new(db.clone()); + let service = Arc::new(CalendarDomainService::new(repository)); + + let cors = macro_cors::cors_layer(); + let port = config.port; + + let app = api_router(AppState { + jwt_args, + calendar_service: service, + }) + .layer(cors.clone()) + .merge(health::router().layer(cors)) + .merge(SwaggerUi::new("/docs").url("/api-doc/openapi.json", ApiDoc::openapi())); + + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)) + .await + .unwrap(); + + tracing::info!("calendar service is up and running on port {}", &port); + + axum::serve(listener, app.into_make_service()) + .await + .context("error starting service")?; + Ok(()) +} diff --git a/rust/cloud-storage/calendar_service/src/openapi.rs b/rust/cloud-storage/calendar_service/src/openapi.rs new file mode 100644 index 0000000000..f90991a3da --- /dev/null +++ b/rust/cloud-storage/calendar_service/src/openapi.rs @@ -0,0 +1,6 @@ +use calendar::inbound::http::ApiDoc; +use utoipa::OpenApi; + +fn main() { + println!("{}", ApiDoc::openapi().to_pretty_json().unwrap()); +} diff --git a/rust/cloud-storage/macro_db_client/migrations/20260609120000_calendar_db_schema.sql b/rust/cloud-storage/macro_db_client/migrations/20260609120000_calendar_db_schema.sql new file mode 100644 index 0000000000..7f0dd1dfdd --- /dev/null +++ b/rust/cloud-storage/macro_db_client/migrations/20260609120000_calendar_db_schema.sql @@ -0,0 +1,39 @@ +-- Calendar feature: user-owned events and their invited attendees. +-- +-- Instants are stored as epoch-millis (BIGINT) to match the frontend's +-- instant-based model exactly and to avoid timezone ambiguity on the wire. +-- Audit columns are likewise epoch-millis so the schema needs no chrono/time +-- mapping in the repository layer. + +CREATE TABLE calendar_event ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id text NOT NULL REFERENCES "User"(id) ON UPDATE CASCADE ON DELETE CASCADE, + title text NOT NULL DEFAULT '', + description text, + location text, + start_ms bigint NOT NULL, + end_ms bigint NOT NULL, + all_day boolean NOT NULL DEFAULT false, + color text NOT NULL DEFAULT 'blue', + created_ms bigint NOT NULL DEFAULT (floor(extract(epoch FROM now()) * 1000))::bigint, + updated_ms bigint NOT NULL DEFAULT (floor(extract(epoch FROM now()) * 1000))::bigint +); + +-- Range scans for "events for this user between start and end" are the hot path. +CREATE INDEX idx_calendar_event_user_range + ON calendar_event (user_id, start_ms, end_ms); + +CREATE TABLE calendar_attendee ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + event_id uuid NOT NULL REFERENCES calendar_event (id) ON DELETE CASCADE, + email text NOT NULL, + name text, + -- One of: pending | accepted | declined | tentative + status text NOT NULL DEFAULT 'pending', + -- Epoch-millis the invite email was sent, NULL until invited. + invited_ms bigint, + created_ms bigint NOT NULL DEFAULT (floor(extract(epoch FROM now()) * 1000))::bigint, + UNIQUE (event_id, email) +); + +CREATE INDEX idx_calendar_attendee_event ON calendar_attendee (event_id); From b882edf151cfbb02105e994e8c59e9384666d3f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 17:48:05 +0000 Subject: [PATCH 2/3] calendar: cargo fmt + scope sqlx disallowed_methods allow Format the new crates and add a documented, adapter-scoped allow for the runtime sqlx queries (compile-time macros need a prepared .sqlx cache from a live DB, which isn't available offline). https://claude.ai/code/session_01G5vy6QeqzgpcutwfRqoEje --- .../calendar/src/domain/service.rs | 8 +++-- .../calendar/src/outbound/repository.rs | 33 ++++++++++--------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/rust/cloud-storage/calendar/src/domain/service.rs b/rust/cloud-storage/calendar/src/domain/service.rs index 1c2fa04e7f..5692f7bb12 100644 --- a/rust/cloud-storage/calendar/src/domain/service.rs +++ b/rust/cloud-storage/calendar/src/domain/service.rs @@ -52,7 +52,9 @@ impl CalendarService for CalendarDomainService { user_id: &str, request: CreateEventRequest, ) -> Result { - self.repository.create_event(user_id, normalize(request)).await + self.repository + .create_event(user_id, normalize(request)) + .await } #[instrument(err, skip(self, request))] @@ -79,6 +81,8 @@ impl CalendarService for CalendarDomainService { event_id: &str, emails: Vec, ) -> Result, Report> { - self.repository.mark_invited(user_id, event_id, emails).await + self.repository + .mark_invited(user_id, event_id, emails) + .await } } diff --git a/rust/cloud-storage/calendar/src/outbound/repository.rs b/rust/cloud-storage/calendar/src/outbound/repository.rs index 7de38d6a9d..3b8916e4aa 100644 --- a/rust/cloud-storage/calendar/src/outbound/repository.rs +++ b/rust/cloud-storage/calendar/src/outbound/repository.rs @@ -2,6 +2,13 @@ //! //! Uses sqlx's runtime-checked query API (rather than the `query!` macros) so //! the crate compiles without a live database or a prepared offline cache. +//! +//! FOLLOW-UP: the repo convention (and the `disallowed_methods` clippy lint) +//! prefers the compile-time `query!`/`query_as!` macros. Converting these +//! queries requires running `just prepare_db` against a live MacroDB to +//! populate the `.sqlx` cache, which cannot be done in this offline +//! environment. The allow below is scoped to this adapter only. +#![allow(clippy::disallowed_methods)] use std::collections::HashMap; use std::time::{SystemTime, UNIX_EPOCH}; @@ -46,8 +53,7 @@ struct AttendeeRow { invited_ms: Option, } -const EVENT_COLUMNS: &str = - "id, title, description, location, start_ms, end_ms, all_day, color"; +const EVENT_COLUMNS: &str = "id, title, description, location, start_ms, end_ms, all_day, color"; fn now_ms() -> i64 { SystemTime::now() @@ -111,17 +117,16 @@ impl DbCalendarRepository { .await?; for row in rows { - grouped.entry(row.event_id).or_default().push(row.into_attendee()); + grouped + .entry(row.event_id) + .or_default() + .push(row.into_attendee()); } Ok(grouped) } /// Re-reads a single owned event and assembles its attendees. - async fn fetch_one( - &self, - user_id: &str, - id: Uuid, - ) -> Result, Report> { + async fn fetch_one(&self, user_id: &str, id: Uuid) -> Result, Report> { let row = sqlx::query_as::<_, EventRow>(&format!( "SELECT {EVENT_COLUMNS} FROM calendar_event WHERE id = $1 AND user_id = $2" )) @@ -266,13 +271,11 @@ impl CalendarRepository for DbCalendarRepository { // Sync attendees: drop any no longer present, then upsert the rest. let emails: Vec = request.attendees.iter().map(|a| a.email.clone()).collect(); - sqlx::query( - "DELETE FROM calendar_attendee WHERE event_id = $1 AND NOT (email = ANY($2))", - ) - .bind(id) - .bind(&emails) - .execute(&self.db) - .await?; + sqlx::query("DELETE FROM calendar_attendee WHERE event_id = $1 AND NOT (email = ANY($2))") + .bind(id) + .bind(&emails) + .execute(&self.db) + .await?; self.upsert_attendees(id, &request.attendees).await?; self.fetch_one(user_id, id).await From ae7d8831c5395ebd530b8ec05c7692089cb2abec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 17:48:19 +0000 Subject: [PATCH 3/3] Add calendar block UI + sidebar entry - block-calendar package: week / day / list views (Google-Calendar-style), click-to-create, event editor dialog with guests, color, and "Save & send invites" (records on the backend + emails attendees from the user's mailbox, with a downloadable .ics). - Vim-style keyboard shortcuts scoped to the calendar: j/k = next/prev screen, t = today, n = new event, w/d/l = week/day/list. `g d` opens it from anywhere. - Sidebar "Calendar" item + a registered `calendar` component view. - CalendarContext wires view/anchor state and editor flow to @queries/calendar. https://claude.ai/code/session_01G5vy6QeqzgpcutwfRqoEje --- .../app/component/app-sidebar/sidebar.tsx | 12 + .../split-layout/componentRegistry.tsx | 8 + js/app/packages/block-calendar/README.md | 44 +++ .../block-calendar/component/Calendar.tsx | 99 +++++ .../component/CalendarContext.tsx | 179 +++++++++ .../block-calendar/component/EventDialog.tsx | 359 ++++++++++++++++++ .../block-calendar/component/ListView.tsx | 95 +++++ .../block-calendar/component/TimeGrid.tsx | 213 +++++++++++ .../block-calendar/component/Toolbar.tsx | 68 ++++ .../block-calendar/component/colors.ts | 43 +++ js/app/packages/block-calendar/index.ts | 6 + js/app/packages/block-calendar/util/dates.ts | 31 +- js/app/packages/block-calendar/util/ics.ts | 7 +- js/app/packages/block-calendar/util/invite.ts | 5 +- js/app/packages/core/hotkey/tokens.ts | 12 + js/app/packages/icon/wide-calendar.tsx | 43 +++ js/app/packages/queries/calendar/events.ts | 4 +- js/app/tsconfig.json | 3 + 18 files changed, 1201 insertions(+), 30 deletions(-) create mode 100644 js/app/packages/block-calendar/README.md create mode 100644 js/app/packages/block-calendar/component/Calendar.tsx create mode 100644 js/app/packages/block-calendar/component/CalendarContext.tsx create mode 100644 js/app/packages/block-calendar/component/EventDialog.tsx create mode 100644 js/app/packages/block-calendar/component/ListView.tsx create mode 100644 js/app/packages/block-calendar/component/TimeGrid.tsx create mode 100644 js/app/packages/block-calendar/component/Toolbar.tsx create mode 100644 js/app/packages/block-calendar/component/colors.ts create mode 100644 js/app/packages/block-calendar/index.ts create mode 100644 js/app/packages/icon/wide-calendar.tsx diff --git a/js/app/packages/app/component/app-sidebar/sidebar.tsx b/js/app/packages/app/component/app-sidebar/sidebar.tsx index 4de83297a0..bce83ae4df 100644 --- a/js/app/packages/app/component/app-sidebar/sidebar.tsx +++ b/js/app/packages/app/component/app-sidebar/sidebar.tsx @@ -57,6 +57,7 @@ import { isTouchDevice } from '@core/mobile/isTouchDevice'; import LogoIcon from '@icon/macro-logo.svg'; import { AnimatedSquareCommandKIcon } from '@icon/square-command-k'; import { AnimatedSquareSidebarIcon } from '@icon/square-sidebar'; +import { AnimatedCalendarIcon } from '@icon/wide-calendar'; import { AnimatedCallIcon } from '@icon/wide-call'; import { AnimatedChannelIcon } from '@icon/wide-channel'; import { AnimatedEmailIcon } from '@icon/wide-email'; @@ -773,6 +774,15 @@ const DASHBOARD_LINK: SidebarItem = { hotkeyToken: TOKENS.sidebar.goTo.home, }; +const CALENDAR_LINK: SidebarItem = { + id: 'calendar', + label: 'Calendar', + href: '/calendar', + icon: AnimatedCalendarIcon, + hotkey: 'd', + hotkeyToken: TOKENS.sidebar.goTo.calendar, +}; + export const AppSidebar = (props: AppSidebarProps) => { const analytics = useAnalytics(); const layout = useSplitLayout(); @@ -823,6 +833,8 @@ export const AppSidebar = (props: AppSidebarProps) => { links = [...links.slice(0, idx + 1), CALLS_LINK, ...links.slice(idx + 1)]; } + links = [...links, CALENDAR_LINK]; + return links; }); diff --git a/js/app/packages/app/component/split-layout/componentRegistry.tsx b/js/app/packages/app/component/split-layout/componentRegistry.tsx index fd776713de..3fbcb4f7bf 100644 --- a/js/app/packages/app/component/split-layout/componentRegistry.tsx +++ b/js/app/packages/app/component/split-layout/componentRegistry.tsx @@ -4,6 +4,7 @@ import { Home } from '@app/component/home'; import type { SetPredicatesInput } from '@app/component/next-soup/filters/filter-store/predicates-store'; import type { Query } from '@app/component/next-soup/filters/filter-store/types'; import { SoupView } from '@app/component/next-soup/soup-view/soup-view'; +import { Calendar } from '@block-calendar'; import { ChannelCompose } from '@block-channel/component/Compose'; import { ComposeTask } from '@block-md/component/ComposeTask'; import { useIsAuthenticated } from '@core/auth'; @@ -280,6 +281,13 @@ registerComponent( ); }) ); +registerComponent( + 'calendar', + withAuth(() => { + usePageViewTracking('calendar'); + return ; + }) +); /** END - APP ROUTES */ registerComponent('loading', () => ); diff --git a/js/app/packages/block-calendar/README.md b/js/app/packages/block-calendar/README.md new file mode 100644 index 0000000000..0d0f7e367e --- /dev/null +++ b/js/app/packages/block-calendar/README.md @@ -0,0 +1,44 @@ +# block-calendar + +A Google-Calendar-style calendar surface, opened from the sidebar ("Calendar"). + +## Views + +- **Week** (`w`) — 7-day time grid (default) +- **Day** (`d`) — single-day time grid +- **List** (`l`) — agenda of upcoming events grouped by day + +## Keyboard shortcuts + +Active while the calendar is focused: + +| Key | Action | +| --- | ----------------- | +| `j` | Next screen | +| `k` | Previous screen | +| `t` | Jump to today | +| `n` | New event | +| `w` | Week view | +| `d` | Day view | +| `l` | List view | + +`g d` (go-to leader) opens the calendar from anywhere. + +## Events & invites + +Events are persisted by the `calendar_service` backend +(`rust/cloud-storage/calendar`). Data access lives in `@queries/calendar`; the +wire client is `@service-calendar`. + +Click an empty slot to create an event, or an event to edit it. Add guests by +email; **Save & send invites** records the invite on the backend and emails +attendees from the user's connected mailbox (via the email service), including +an `.ics` payload. The dialog can also download a standalone `.ics`. + +## Layers + +- `model/` — frontend domain types (instant-based, decoupled from the wire DTOs) +- `util/` — date math, iCalendar generation, invite-email composition +- `component/` — `Calendar` (orchestrator + hotkeys), `Toolbar`, `TimeGrid` + (week/day), `ListView`, `EventDialog`, and the `CalendarContext` that wires + state to queries/mutations. diff --git a/js/app/packages/block-calendar/component/Calendar.tsx b/js/app/packages/block-calendar/component/Calendar.tsx new file mode 100644 index 0000000000..5e13d80db0 --- /dev/null +++ b/js/app/packages/block-calendar/component/Calendar.tsx @@ -0,0 +1,99 @@ +import { + createHotkeyGroup, + registerHotkey, + useHotkeyDOMScope, +} from '@core/hotkey/hotkeys'; +import { type HotkeyToken, TOKENS } from '@core/hotkey/tokens'; +import type { ValidHotkey } from '@core/hotkey/types'; +import { Match, onCleanup, onMount, Switch } from 'solid-js'; +import { daysForView } from '../util/dates'; +import { CalendarProvider, useCalendar } from './CalendarContext'; +import { EventDialog } from './EventDialog'; +import { ListView } from './ListView'; +import { TimeGrid } from './TimeGrid'; +import { Toolbar } from './Toolbar'; + +function CalendarInner() { + const calendar = useCalendar(); + const [attachHotkeys, scopeId] = useHotkeyDOMScope('calendar'); + const group = createHotkeyGroup(); + let rootRef: HTMLDivElement | undefined; + + onMount(() => { + if (rootRef) { + attachHotkeys(rootRef); + // Focus the root so j/k work without an explicit click first. + rootRef.focus(); + } + + const bind = ( + hotkey: ValidHotkey, + hotkeyToken: HotkeyToken, + description: string, + run: () => void + ) => + group.add( + registerHotkey({ + hotkey, + scopeId, + hotkeyToken, + description, + keyDownHandler: () => { + run(); + return true; + }, + }) + ); + + // Primary navigation: vim-style j/k step one screen forward/back, matching + // the soup list convention (j = next, k = previous). + bind('j', TOKENS.calendar.next, 'Next', calendar.goNext); + bind('k', TOKENS.calendar.prev, 'Previous', calendar.goPrev); + bind('t', TOKENS.calendar.today, 'Today', calendar.goToday); + bind('n', TOKENS.calendar.newEvent, 'New event', () => calendar.openNew()); + bind('d', TOKENS.calendar.viewDay, 'Day view', () => + calendar.setView('day') + ); + bind('w', TOKENS.calendar.viewWeek, 'Week view', () => + calendar.setView('week') + ); + bind('l', TOKENS.calendar.viewList, 'List view', () => + calendar.setView('list') + ); + }); + + onCleanup(() => group.dispose()); + + return ( +
+ +
+ + + + + + + + + + + +
+ +
+ ); +} + +/** The calendar surface, opened from the sidebar. */ +export default function Calendar() { + return ( + + + + ); +} diff --git a/js/app/packages/block-calendar/component/CalendarContext.tsx b/js/app/packages/block-calendar/component/CalendarContext.tsx new file mode 100644 index 0000000000..42a6af0fc2 --- /dev/null +++ b/js/app/packages/block-calendar/component/CalendarContext.tsx @@ -0,0 +1,179 @@ +import { createAssertedContextProvider } from '@core/context/createContext'; +import { useUserContext } from '@core/context/user'; +import { + type CalendarRange, + useCalendarEventsQuery, + useCreateEventMutation, + useDeleteEventMutation, + useInviteAttendeesMutation, + useUpdateEventMutation, +} from '@queries/calendar/events'; +import type { CalendarEvent as WireEvent } from '@service-calendar/generated/schemas'; +import { endOfDay, startOfDay } from 'date-fns'; +import { type Accessor, createMemo, createSignal } from 'solid-js'; +import { + type AttendeeStatus, + type CalendarEvent, + type CalendarEventDraft, + type CalendarViewMode, + isEventColor, +} from '../model/types'; +import { DEFAULT_EVENT_MINUTES, daysForView, shiftAnchor } from '../util/dates'; +import { sendInviteEmail } from '../util/invite'; + +function toDomain(event: WireEvent): CalendarEvent { + return { + id: event.id, + title: event.title, + description: event.description ?? undefined, + location: event.location ?? undefined, + startMs: event.start_ms, + endMs: event.end_ms, + allDay: event.all_day, + color: isEventColor(event.color) ? event.color : 'blue', + attendees: event.attendees.map((a) => ({ + email: a.email, + name: a.name ?? undefined, + status: (a.status as AttendeeStatus) ?? 'pending', + })), + }; +} + +function draftToRequest(draft: CalendarEventDraft) { + return { + title: draft.title.trim() || 'Untitled event', + description: draft.description.trim() || null, + location: draft.location.trim() || null, + start_ms: draft.startMs, + end_ms: draft.endMs, + all_day: draft.allDay, + color: draft.color, + attendees: draft.attendees.map((a) => ({ + email: a.email, + name: a.name ?? null, + })), + }; +} + +function eventToDraft(event: CalendarEvent): CalendarEventDraft { + return { + id: event.id, + title: event.title, + description: event.description ?? '', + location: event.location ?? '', + startMs: event.startMs, + endMs: event.endMs, + allDay: event.allDay, + attendees: [...event.attendees], + color: event.color, + }; +} + +function newDraft(startMs: number): CalendarEventDraft { + return { + title: '', + description: '', + location: '', + startMs, + endMs: startMs + DEFAULT_EVENT_MINUTES * 60 * 1000, + allDay: false, + attendees: [], + color: 'blue', + }; +} + +export const [CalendarProvider, useCalendar] = createAssertedContextProvider( + 'Calendar', + () => { + const user = useUserContext(); + + const [view, setView] = createSignal('week'); + const [anchor, setAnchor] = createSignal(new Date()); + const [editingDraft, setEditingDraft] = + createSignal(null); + + const range = createMemo(() => { + const days = daysForView(view(), anchor()); + return { + startMs: startOfDay(days[0]!).getTime(), + endMs: endOfDay(days[days.length - 1]!).getTime(), + }; + }); + + const eventsQuery = useCalendarEventsQuery(range, () => true); + + const events = createMemo(() => + (eventsQuery.data ?? []).map(toDomain) + ); + + const createMutation = useCreateEventMutation(); + const updateMutation = useUpdateEventMutation(); + const deleteMutation = useDeleteEventMutation(); + const inviteMutation = useInviteAttendeesMutation(); + + const goToday = () => setAnchor(new Date()); + const goPrev = () => setAnchor((d) => shiftAnchor(view(), d, -1)); + const goNext = () => setAnchor((d) => shiftAnchor(view(), d, 1)); + + const openNew = (startMs?: number) => + setEditingDraft(newDraft(startMs ?? Date.now())); + const openEdit = (event: CalendarEvent) => + setEditingDraft(eventToDraft(event)); + const closeEditor = () => setEditingDraft(null); + + /** Persists the current draft (create or update) and returns the saved event. */ + const saveDraft = async ( + draft: CalendarEventDraft + ): Promise => { + const body = draftToRequest(draft); + const saved = draft.id + ? await updateMutation.mutateAsync({ id: draft.id, body }) + : await createMutation.mutateAsync(body); + return toDomain(saved); + }; + + const removeEvent = async (id: string) => { + await deleteMutation.mutateAsync({ id }); + }; + + /** + * Records invites on the backend and emails attendees from the user's + * connected mailbox. Returns the email-service result. + */ + const sendInvites = async (event: CalendarEvent, emails: string[]) => { + await inviteMutation.mutateAsync({ id: event.id, emails }); + const recipients = event.attendees + .filter((a) => emails.includes(a.email)) + .map((a) => ({ email: a.email, name: a.name })); + return sendInviteEmail({ + event, + organizerEmail: user.email() ?? '', + organizerName: user.author(), + recipients: recipients.length > 0 ? recipients : undefined, + }); + }; + + return { + view, + setView, + anchor, + setAnchor, + range, + events, + isLoading: () => eventsQuery.isLoading, + editingDraft: editingDraft as Accessor, + setEditingDraft, + goToday, + goPrev, + goNext, + openNew, + openEdit, + closeEditor, + saveDraft, + removeEvent, + sendInvites, + organizerEmail: () => user.email() ?? '', + organizerName: () => user.author(), + }; + } +); diff --git a/js/app/packages/block-calendar/component/EventDialog.tsx b/js/app/packages/block-calendar/component/EventDialog.tsx new file mode 100644 index 0000000000..ecabed2c96 --- /dev/null +++ b/js/app/packages/block-calendar/component/EventDialog.tsx @@ -0,0 +1,359 @@ +import DownloadIcon from '@phosphor/download-simple.svg'; +import SendIcon from '@phosphor/paper-plane-tilt.svg'; +import TrashIcon from '@phosphor/trash.svg'; +import XIcon from '@phosphor/x.svg'; +import { Button, cn, Dialog } from '@ui'; +import { createSignal, For, Show } from 'solid-js'; +import { + type CalendarEvent, + type CalendarEventDraft, + EVENT_COLORS, +} from '../model/types'; +import { + DEFAULT_EVENT_MINUTES, + fromDatetimeLocalValue, + toDatetimeLocalValue, +} from '../util/dates'; +import { downloadIcs } from '../util/ics'; +import { useCalendar } from './CalendarContext'; +import { EVENT_COLOR_CLASSES } from './colors'; + +const FIELD = + 'w-full rounded-xs border border-edge-muted bg-surface px-2 py-1.5 text-sm text-ink placeholder:text-ink-extra-muted focus:border-accent focus:outline-none'; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** Builds a CalendarEvent view of the in-progress draft for ICS export. */ +function draftToEvent(draft: CalendarEventDraft): CalendarEvent { + return { + id: draft.id ?? 'draft', + title: draft.title, + description: draft.description || undefined, + location: draft.location || undefined, + startMs: draft.startMs, + endMs: draft.endMs, + allDay: draft.allDay, + attendees: draft.attendees, + color: draft.color, + }; +} + +export function EventDialog() { + const calendar = useCalendar(); + const [busy, setBusy] = createSignal(false); + const [feedback, setFeedback] = createSignal(null); + const [attendeeInput, setAttendeeInput] = createSignal(''); + + const draft = () => calendar.editingDraft(); + + const update = (patch: Partial) => + calendar.setEditingDraft((prev) => (prev ? { ...prev, ...patch } : prev)); + + const setStart = (value: string) => { + const startMs = fromDatetimeLocalValue(value); + const current = draft(); + if (!current) return; + const endMs = + startMs >= current.endMs + ? startMs + DEFAULT_EVENT_MINUTES * 60 * 1000 + : current.endMs; + update({ startMs, endMs }); + }; + + const addAttendee = () => { + const email = attendeeInput().trim().toLowerCase(); + const current = draft(); + if (!current) return; + if (!EMAIL_RE.test(email)) { + setFeedback('Enter a valid email address'); + return; + } + if (current.attendees.some((a) => a.email === email)) { + setAttendeeInput(''); + return; + } + setFeedback(null); + update({ attendees: [...current.attendees, { email, status: 'pending' }] }); + setAttendeeInput(''); + }; + + const removeAttendee = (email: string) => { + const current = draft(); + if (!current) return; + update({ attendees: current.attendees.filter((a) => a.email !== email) }); + }; + + const close = () => { + setFeedback(null); + setAttendeeInput(''); + calendar.closeEditor(); + }; + + const onSave = async () => { + const current = draft(); + if (!current) return; + setBusy(true); + try { + await calendar.saveDraft(current); + close(); + } catch { + setFeedback('Failed to save event'); + } finally { + setBusy(false); + } + }; + + const onSaveAndInvite = async () => { + const current = draft(); + if (!current) return; + setBusy(true); + try { + const saved = await calendar.saveDraft(current); + const emails = saved.attendees.map((a) => a.email); + if (emails.length > 0) { + const result = await calendar.sendInvites(saved, emails); + if (result.isErr()) { + setFeedback('Event saved, but invites could not be sent'); + setBusy(false); + return; + } + } + close(); + } catch { + setFeedback('Failed to save event'); + } finally { + setBusy(false); + } + }; + + const onDelete = async () => { + const current = draft(); + if (!current?.id) return; + setBusy(true); + try { + await calendar.removeEvent(current.id); + close(); + } catch { + setFeedback('Failed to delete event'); + } finally { + setBusy(false); + } + }; + + return ( + { + if (!open) close(); + }} + position="center" + class="rounded-md border border-edge-muted bg-surface shadow-lg" + > + + {(currentDraft) => ( +
+
+ + {currentDraft().id ? 'Edit event' : 'New event'} + + +
+ +
+ update({ title: e.currentTarget.value })} + /> + + + +
+ + +
+ + update({ location: e.currentTarget.value })} + /> + +