Skip to content

Add calendar feature with event management and invites#3889

Draft
jbecke wants to merge 3 commits into
mainfrom
claude/calendar-block-shortcuts-0u7v6o
Draft

Add calendar feature with event management and invites#3889
jbecke wants to merge 3 commits into
mainfrom
claude/calendar-block-shortcuts-0u7v6o

Conversation

@jbecke

@jbecke jbecke commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Introduces a complete calendar feature enabling users to create, edit, and manage events with attendee invitations. Includes a full-stack implementation with a Rust backend service, PostgreSQL persistence, and a Solid.js frontend UI with multiple view modes.

Key Changes

Backend (Rust)

  • Calendar service (rust/cloud-storage/calendar_service/) - New microservice with HTTP API and health checks
  • Calendar domain library (rust/cloud-storage/calendar/) - Hexagonal architecture with:
    • Domain models for events and attendees
    • Repository port for persistence
    • Service port for business logic
    • PostgreSQL adapter using sqlx runtime-checked queries
    • HTTP handlers with OpenAPI documentation
  • Database schema - New calendar_event and calendar_attendee tables storing instants as epoch-millis
  • Service integration - Docker Compose configuration for calendar_service on port 8101

Frontend (TypeScript/Solid.js)

  • Calendar block (js/app/packages/block-calendar/) - New package with:
    • Three view modes: Week (time grid), Day (single-day grid), List (agenda)
    • Event dialog for creating/editing events with attendee management
    • Time grid with drag-to-create and now-indicator
    • Keyboard shortcuts (j/k navigation, t=today, n=new, w/d/l=views)
    • Event color coding (6 accent colors)
    • ICS export for event downloads
    • Email invite generation with HTML formatting
  • Calendar context - Reactive state management with TanStack Query integration
  • Date utilities - Timezone-aware helpers for grid rendering and event calculations
  • Service client - Generated fetch client for calendar API with OpenAPI schemas

Integration

  • Sidebar navigation entry for Calendar
  • Hotkey token registration for calendar navigation
  • Service client configuration and TypeScript path aliases
  • Query key factory for cache management

Notable Implementation Details

  • Instants stored and transmitted as epoch-millis (i64) to avoid timezone ambiguity
  • Frontend domain types intentionally decoupled from wire types for clean separation
  • ICS generation follows RFC-5545 with proper text escaping and line folding
  • Email invites sent through email-service with base64 URL-safe encoding
  • Time grid uses CSS positioning with pixel calculations for precise event placement
  • Repository queries use runtime-checked sqlx API (not compile-time macros) for offline compilation

https://claude.ai/code/session_01G5vy6QeqzgpcutwfRqoEje

claude added 3 commits June 9, 2026 17:33
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
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
- 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
@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Calendar application with Week, Day, and List view modes
    • Create, edit, and delete calendar events
    • Manage event attendees and send email invitations
    • Keyboard shortcuts for calendar navigation
    • Download events as ICS files
    • Calendar sidebar navigation link

Walkthrough

This pull request introduces a full-featured calendar system across the entire application stack. The frontend provides a Google Calendar-style interface with week, day, and list views, supporting event creation, editing, guest invitations, and iCalendar export. The backend is a new Rust service implementing RESTful CRUD operations for events and attendees backed by PostgreSQL. Integration points include TypeScript import aliases, hotkey bindings, sidebar navigation, Docker configuration, and database schema migrations. The implementation spans 50+ files with ~2,500 new lines of code.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning The PR title does not follow conventional commits format (missing prefix like feat:, fix:, chore:, etc.) and is not structured according to the specified requirements. Update the title to follow conventional commits format, e.g., 'feat: add calendar feature with event management and invites' (under 72 characters).
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The pull request description provides comprehensive details about the calendar feature implementation, covering backend, frontend, integration, and notable implementation details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@evanhutnik

Copy link
Copy Markdown
Contributor

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (4)
rust/cloud-storage/macro_db_client/migrations/20260609120000_calendar_db_schema.sql (1)

39-39: ⚡ Quick win

Drop the redundant attendee event_id index.

On Line 39, idx_calendar_attendee_event duplicates the left-most key of UNIQUE (event_id, email) from Line 36, so it adds write cost without adding new lookup capability for typical event_id = ? filters.

As per coding guidelines, index strategy should match query patterns, and this index is overlapping with an existing composite unique index.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@rust/cloud-storage/macro_db_client/migrations/20260609120000_calendar_db_schema.sql`
at line 39, Remove the redundant single-column index idx_calendar_attendee_event
on table calendar_attendee because it duplicates the left-most key of the
existing UNIQUE constraint UNIQUE(event_id, email); update the migration by
deleting the CREATE INDEX idx_calendar_attendee_event statement so only the
composite unique index remains, ensuring no other migration steps or constraints
reference idx_calendar_attendee_event (if they do, remove or adjust those
references accordingly).

Source: Coding guidelines

js/app/packages/block-calendar/util/dates.ts (1)

37-53: ⚡ Quick win

Use match from ts-pattern for exhaustive view branching.

These branches are exhaustive over CalendarViewMode, but project conventions require match(...) instead of native switch in TS files.

As per coding guidelines, “For exhaustive switch statements in TypeScript, use match from ts-pattern instead of native switch.”

Also applies to: 61-70, 124-138

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/app/packages/block-calendar/util/dates.ts` around lines 37 - 53, Replace
the native switch in daysForView with ts-pattern's match to make branching
exhaustive: import match from 'ts-pattern', call match(view).with('day', () =>
...).with('week', () => ...).with('list', () => ...).exhaustive() (or
.otherwise(...) if appropriate) and return the same Date[] results; ensure you
preserve use of WEEK_OPTS and the same date-fns helpers (startOfDay,
eachDayOfInterval, etc.). Apply the same refactor to the other CalendarViewMode
branching sites referenced in the review (the functions handling view-based
branching around the other ranges) so all exhaustive switches use match and
compile with no implicit any.

Source: Coding guidelines

js/app/packages/block-calendar/component/colors.ts (1)

14-41: ⚡ Quick win

Use semantic color tokens instead of raw Tailwind palette classes.

EVENT_COLOR_CLASSES currently hardcodes palette utilities (bg-*-500, border-*-500). In js/app/**, styling should use semantic tokens so themes/branding can evolve without touching feature code.

As per coding guidelines, "Use semantic color tokens, not raw Tailwind color classes, for styling."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/app/packages/block-calendar/component/colors.ts` around lines 14 - 41,
EVENT_COLOR_CLASSES in colors.ts currently uses raw Tailwind palette classes
(e.g., bg-blue-500, border-blue-500); update the EVENT_COLOR_CLASSES mapping to
use semantic design-system tokens instead (replace bg-*-500 / border-*-500 /
bg-*-500 dot/swatch with semantic token classes such as
bg-event-<color>-surface, border-event-<color>, text-... and
bg-event-<color>-solid for dot/swatch) so theming/branding can change centrally;
modify the entries for blue, green, purple, orange, red, pink in the
EVENT_COLOR_CLASSES object (and any referenced keys) to the agreed semantic
token names from our design system.

Source: Coding guidelines

js/app/packages/block-calendar/util/invite.ts (1)

12-17: ⚡ Quick win

Consider modernizing base64url encoding to avoid deprecated unescape.

The btoa(unescape(encodeURIComponent(...))) pattern works but uses the deprecated unescape function. Modern browsers support TextEncoder for cleaner UTF-8 handling.

♻️ Modern base64url encoding using TextEncoder
-function encodeBodyHtml(html: string): string {
-  return btoa(unescape(encodeURIComponent(html)))
-    .replace(/\+/g, '-')
-    .replace(/\//g, '_')
-    .replace(/={1,}$/, '');
+function encodeBodyHtml(html: string): string {
+  const bytes = new TextEncoder().encode(html);
+  const binString = String.fromCodePoint(...bytes);
+  return btoa(binString)
+    .replace(/\+/g, '-')
+    .replace(/\//g, '_')
+    .replace(/={1,}$/, '');
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/app/packages/block-calendar/util/invite.ts` around lines 12 - 17, The
encodeBodyHtml function uses the deprecated unescape pattern; update
encodeBodyHtml to use TextEncoder to produce a UTF-8 Uint8Array from the html
string, convert that byte array into a binary string suitable for btoa (e.g.,
via String.fromCharCode on chunks of the Uint8Array) or use Buffer.from in Node
environments, then call btoa on that binary string and keep the existing
base64url replacements (.replace(/\+/g,'-').replace(/\//g,'_').replace(/={1,}$/,
'')). Ensure the change preserves UTF-8 correctness and works both in browser
(TextEncoder) and when running in Node (Buffer).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docker-compose.yml`:
- Around line 104-119: The calendar_service container lacks a startup dependency
on Postgres so it may attempt DB connections before the DB is ready; update the
calendar_service service block to add a depends_on entry referencing the
Postgres service (e.g., depends_on: - postgres or the project's DB service name)
and include the appropriate condition for service_healthy if you use
healthchecks (e.g., condition: service_healthy) to ensure calendar_service
(command /app/out/calendar_service) waits for the DB before starting.

In `@js/app/packages/block-calendar/component/CalendarContext.tsx`:
- Around line 143-153: sendInvites currently builds recipients from the stale
event.attendees which can omit newly-added emails; change sendInvites to use the
post-invite attendee state returned by inviteMutation.mutateAsync (capture its
result, e.g. const result = await inviteMutation.mutateAsync({ id: event.id,
emails })) and derive attendees from result.attendees (falling back to
event.attendees if absent), then map the requested emails to recipient objects
using those updated attendees (and if an email isn’t present in attendees,
include it with a blank or fallback name) before calling sendInviteEmail; update
references to inviteMutation.mutateAsync, event.attendees, sendInviteEmail, and
emails accordingly.

In `@js/app/packages/block-calendar/component/EventDialog.tsx`:
- Around line 203-207: The onChange for the end time in EventDialog currently
sets endMs from fromDatetimeLocalValue without validation, allowing endMs <
startMs; update the handler in EventDialog so after computing newEnd =
fromDatetimeLocalValue(...) you compare it with the current startMs and
clamp/adjust newEnd to be >= startMs (e.g., newEnd = Math.max(newEnd, startMs))
before calling update({ endMs: newEnd }); ensure you read the current startMs
from the same state/props used by update so the validation uses the
authoritative value.

In `@js/app/packages/block-calendar/util/ics.ts`:
- Around line 53-91: The DTSTART/DTEND lines in buildIcs currently always use
toIcsUtc (DATE-TIME), which misrepresents all-day events; detect all-day events
(e.g., event.allDay) and format those values as DATE (YYYYMMDD) instead of
DATE-TIME, add the VALUE=DATE parameter (e.g., `DTSTART;VALUE=DATE:YYYYMMDD`)
and use a new helper (e.g., toIcsDate) or extend toIcsUtc with a date-only mode
to produce the YYYYMMDD string (no 'T' or 'Z'), and update the code that builds
`DTSTART:${...}` and `DTEND:${...}` in buildIcs to use the DATE formatter when
event.allDay is true. Ensure DTSTAMP and other timed fields remain unchanged.

In `@rust/cloud-storage/calendar_service/src/config.rs`:
- Around line 5-19: The PORT value is parsed/stored as usize which allows values
outside the valid TCP port range; update the Config struct's port field to u16
and change Config::from_env to parse PORT into a u16 (failing with a clear error
via .context on parse) so invalid port numbers are rejected early; ensure any
uses of Config.port expect u16 after this change.

In `@rust/cloud-storage/calendar_service/src/main.rs`:
- Around line 65-67: The code currently uses
tokio::net::TcpListener::bind(...).await.unwrap() (creating variable listener)
which panics on bind failure; change main to return a Result (e.g., Result<(),
Box<dyn std::error::Error>> or anyhow::Result) and replace unwrap() with proper
error propagation or handling: call
tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)).await.map_err(|e| {
/* wrap with contextual message including port */ })? or match the Result, log a
contextual startup error (including port and e) and return Err; ensure
subsequent uses of listener still compile with the propagated Result from main.

In `@rust/cloud-storage/calendar/src/domain/service.rs`:
- Around line 30-83: The tracing spans currently record PII (user_id, event_id,
attendee emails); update each function's #[instrument(...)] to explicitly skip
those variables: for list_events skip(user_id), for get_event skip(user_id,
event_id), for create_event skip(user_id, request, and ensure user_id isn't
recorded), for update_event skip(user_id, event_id, request), for delete_event
skip(user_id, event_id), and for mark_invited skip(user_id, event_id, emails) so
the instrument macro on the service methods list_events, get_event,
create_event, update_event, delete_event, and mark_invited no longer captures
those sensitive arguments.

In `@rust/cloud-storage/calendar/src/outbound/repository.rs`:
- Around line 209-236: The create_event flow (and similarly update_event and
mark_invited) performs multiple dependent DB writes (INSERT then
upsert_attendees and attendees_for reads) without a transaction; wrap the
multi-step logic in a single sqlx transaction so either all writes succeed or
none are persisted: begin a Transaction via sqlx::PgPool::begin or
pool.begin().await, run the INSERT (currently in create_event) against the
transaction, call upsert_attendees and any attendee reads/updates using the same
transaction (either add transactional variants that accept &mut Transaction or
inline the SQL against the tx), and commit the transaction at the end; on any
error roll back/return without committing. Update update_event (UPDATE + delete
+ upsert) and mark_invited (ownership check + invite upserts) to use the same
transactional pattern, ensuring functions like upsert_attendees and
attendees_for are adapted to accept and use the transaction parameter (or
duplicated transactional SQL) so all steps are atomic.

In
`@rust/cloud-storage/macro_db_client/migrations/20260609120000_calendar_db_schema.sql`:
- Around line 8-37: Add DB-level constraints to enforce the documented
invariants: add a CHECK on calendar_event (e.g., constraint name like
chk_calendar_event_start_le_end) to ensure start_ms <= end_ms; add a constrained
type or CHECK on calendar_attendee.status (e.g., create type
calendar_attendee_status or constraint chk_calendar_attendee_status) to restrict
values to the allowed set ('pending','accepted','declined','tentative'); and add
a CHECK on calendar_attendee to enforce invited_ms is NULL while status =
'pending' (or equivalently that invited_ms IS NOT NULL implies status <>
'pending'). Use the existing table/column names calendar_event.start_ms,
calendar_event.end_ms, calendar_attendee.status and calendar_attendee.invited_ms
when adding the constraints so they survive refactors.

---

Nitpick comments:
In `@js/app/packages/block-calendar/component/colors.ts`:
- Around line 14-41: EVENT_COLOR_CLASSES in colors.ts currently uses raw
Tailwind palette classes (e.g., bg-blue-500, border-blue-500); update the
EVENT_COLOR_CLASSES mapping to use semantic design-system tokens instead
(replace bg-*-500 / border-*-500 / bg-*-500 dot/swatch with semantic token
classes such as bg-event-<color>-surface, border-event-<color>, text-... and
bg-event-<color>-solid for dot/swatch) so theming/branding can change centrally;
modify the entries for blue, green, purple, orange, red, pink in the
EVENT_COLOR_CLASSES object (and any referenced keys) to the agreed semantic
token names from our design system.

In `@js/app/packages/block-calendar/util/dates.ts`:
- Around line 37-53: Replace the native switch in daysForView with ts-pattern's
match to make branching exhaustive: import match from 'ts-pattern', call
match(view).with('day', () => ...).with('week', () => ...).with('list', () =>
...).exhaustive() (or .otherwise(...) if appropriate) and return the same Date[]
results; ensure you preserve use of WEEK_OPTS and the same date-fns helpers
(startOfDay, eachDayOfInterval, etc.). Apply the same refactor to the other
CalendarViewMode branching sites referenced in the review (the functions
handling view-based branching around the other ranges) so all exhaustive
switches use match and compile with no implicit any.

In `@js/app/packages/block-calendar/util/invite.ts`:
- Around line 12-17: The encodeBodyHtml function uses the deprecated unescape
pattern; update encodeBodyHtml to use TextEncoder to produce a UTF-8 Uint8Array
from the html string, convert that byte array into a binary string suitable for
btoa (e.g., via String.fromCharCode on chunks of the Uint8Array) or use
Buffer.from in Node environments, then call btoa on that binary string and keep
the existing base64url replacements
(.replace(/\+/g,'-').replace(/\//g,'_').replace(/={1,}$/, '')). Ensure the
change preserves UTF-8 correctness and works both in browser (TextEncoder) and
when running in Node (Buffer).

In
`@rust/cloud-storage/macro_db_client/migrations/20260609120000_calendar_db_schema.sql`:
- Line 39: Remove the redundant single-column index idx_calendar_attendee_event
on table calendar_attendee because it duplicates the left-most key of the
existing UNIQUE constraint UNIQUE(event_id, email); update the migration by
deleting the CREATE INDEX idx_calendar_attendee_event statement so only the
composite unique index remains, ensuring no other migration steps or constraints
reference idx_calendar_attendee_event (if they do, remove or adjust those
references accordingly).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 60cd470b-6bd7-41e6-8974-b1191ce7bfb3

📥 Commits

Reviewing files that changed from the base of the PR and between d13b660 and ae7d883.

⛔ Files ignored due to path filters (8)
  • js/app/packages/service-clients/service-calendar/generated/schemas/attendee.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-calendar/generated/schemas/attendeeInput.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-calendar/generated/schemas/calendarEvent.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-calendar/generated/schemas/createEventRequest.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-calendar/generated/schemas/index.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-calendar/generated/schemas/inviteRequest.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-calendar/generated/schemas/updateEventRequest.ts is excluded by !**/generated/**
  • rust/cloud-storage/Cargo.lock is excluded by !**/*.lock, !**/Cargo.lock
📒 Files selected for processing (45)
  • docker-compose.yml
  • js/app/packages/app/component/app-sidebar/sidebar.tsx
  • js/app/packages/app/component/split-layout/componentRegistry.tsx
  • js/app/packages/block-calendar/README.md
  • js/app/packages/block-calendar/component/Calendar.tsx
  • js/app/packages/block-calendar/component/CalendarContext.tsx
  • js/app/packages/block-calendar/component/EventDialog.tsx
  • js/app/packages/block-calendar/component/ListView.tsx
  • js/app/packages/block-calendar/component/TimeGrid.tsx
  • js/app/packages/block-calendar/component/Toolbar.tsx
  • js/app/packages/block-calendar/component/colors.ts
  • js/app/packages/block-calendar/index.ts
  • js/app/packages/block-calendar/model/types.ts
  • js/app/packages/block-calendar/package.json
  • js/app/packages/block-calendar/tsconfig.json
  • js/app/packages/block-calendar/util/dates.ts
  • js/app/packages/block-calendar/util/ics.ts
  • js/app/packages/block-calendar/util/invite.ts
  • js/app/packages/core/constant/servers.ts
  • js/app/packages/core/hotkey/tokens.ts
  • js/app/packages/icon/wide-calendar.tsx
  • js/app/packages/queries/calendar/events.ts
  • js/app/packages/queries/calendar/keys.ts
  • js/app/packages/service-clients/orval.config.ts
  • js/app/packages/service-clients/service-calendar/client.ts
  • js/app/scripts/generate-api-schema.ts
  • js/app/scripts/services.ts
  • js/app/tsconfig.json
  • rust/cloud-storage/Cargo.toml
  • rust/cloud-storage/calendar/Cargo.toml
  • rust/cloud-storage/calendar/src/domain.rs
  • rust/cloud-storage/calendar/src/domain/models.rs
  • rust/cloud-storage/calendar/src/domain/ports.rs
  • rust/cloud-storage/calendar/src/domain/service.rs
  • rust/cloud-storage/calendar/src/inbound.rs
  • rust/cloud-storage/calendar/src/inbound/http.rs
  • rust/cloud-storage/calendar/src/lib.rs
  • rust/cloud-storage/calendar/src/outbound.rs
  • rust/cloud-storage/calendar/src/outbound/repository.rs
  • rust/cloud-storage/calendar_service/Cargo.toml
  • rust/cloud-storage/calendar_service/src/config.rs
  • rust/cloud-storage/calendar_service/src/health.rs
  • rust/cloud-storage/calendar_service/src/main.rs
  • rust/cloud-storage/calendar_service/src/openapi.rs
  • rust/cloud-storage/macro_db_client/migrations/20260609120000_calendar_db_schema.sql

Comment thread docker-compose.yml
Comment on lines +104 to +119
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add Postgres startup dependency for local reliability.

calendar_service is DB-backed but has no depends_on, so it can start before Postgres and fail during initial connect. Add a Postgres dependency to match existing service startup patterns.

Suggested fix
   calendar_service:
     <<: [*common-env, *rust-services-image]
     command: ["/app/out/calendar_service"]
     ports:
       - "8101:8080"
+    depends_on:
+      - postgres
     networks:
       databases:
       services:
         aliases:
           - calendar-service
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
calendar_service:
<<: [*common-env, *rust-services-image]
command: ["/app/out/calendar_service"]
ports:
- "8101:8080"
depends_on:
- postgres
networks:
databases:
services:
aliases:
- calendar-service
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docker-compose.yml` around lines 104 - 119, The calendar_service container
lacks a startup dependency on Postgres so it may attempt DB connections before
the DB is ready; update the calendar_service service block to add a depends_on
entry referencing the Postgres service (e.g., depends_on: - postgres or the
project's DB service name) and include the appropriate condition for
service_healthy if you use healthchecks (e.g., condition: service_healthy) to
ensure calendar_service (command /app/out/calendar_service) waits for the DB
before starting.

Comment on lines +143 to +153
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,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the post-invite attendee state (or requested emails) when building recipients.

Line 145 currently filters recipients from the stale event.attendees input. If emails contains newly-added attendees, they may be omitted from sendInviteEmail, so backend invite state and outbound email delivery diverge.

Suggested fix
 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 }));
+  const invitedWire = await inviteMutation.mutateAsync({ id: event.id, emails });
+  const invitedEvent = toDomain(invitedWire);
+  const recipients = emails.map((email) => {
+    const attendee = invitedEvent.attendees.find((a) => a.email === email);
+    return { email, name: attendee?.name };
+  });
   return sendInviteEmail({
-    event,
+    event: invitedEvent,
     organizerEmail: user.email() ?? '',
     organizerName: user.author(),
-    recipients: recipients.length > 0 ? recipients : undefined,
+    recipients,
   });
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/app/packages/block-calendar/component/CalendarContext.tsx` around lines
143 - 153, sendInvites currently builds recipients from the stale
event.attendees which can omit newly-added emails; change sendInvites to use the
post-invite attendee state returned by inviteMutation.mutateAsync (capture its
result, e.g. const result = await inviteMutation.mutateAsync({ id: event.id,
emails })) and derive attendees from result.attendees (falling back to
event.attendees if absent), then map the requested emails to recipient objects
using those updated attendees (and if an email isn’t present in attendees,
include it with a blank or fallback name) before calling sendInviteEmail; update
references to inviteMutation.mutateAsync, event.attendees, sendInviteEmail, and
emails accordingly.

Comment on lines +203 to +207
onChange={(e) =>
update({
endMs: fromDatetimeLocalValue(e.currentTarget.value),
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent invalid time ranges when editing the end time.

Line 203-206 allows endMs to become earlier than startMs. This can persist invalid event intervals and break downstream rendering logic that assumes non-negative duration.

Suggested fix
-                    onChange={(e) =>
-                      update({
-                        endMs: fromDatetimeLocalValue(e.currentTarget.value),
-                      })
-                    }
+                    onChange={(e) => {
+                      const parsedEnd = fromDatetimeLocalValue(
+                        e.currentTarget.value
+                      );
+                      const start = currentDraft().startMs;
+                      if (Number.isNaN(parsedEnd)) return;
+                      update({ endMs: Math.max(parsedEnd, start) });
+                    }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onChange={(e) =>
update({
endMs: fromDatetimeLocalValue(e.currentTarget.value),
})
}
onChange={(e) => {
const parsedEnd = fromDatetimeLocalValue(
e.currentTarget.value
);
const start = currentDraft().startMs;
if (Number.isNaN(parsedEnd)) return;
update({ endMs: Math.max(parsedEnd, start) });
}}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/app/packages/block-calendar/component/EventDialog.tsx` around lines 203 -
207, The onChange for the end time in EventDialog currently sets endMs from
fromDatetimeLocalValue without validation, allowing endMs < startMs; update the
handler in EventDialog so after computing newEnd = fromDatetimeLocalValue(...)
you compare it with the current startMs and clamp/adjust newEnd to be >= startMs
(e.g., newEnd = Math.max(newEnd, startMs)) before calling update({ endMs: newEnd
}); ensure you read the current startMs from the same state/props used by update
so the validation uses the authoritative value.

Comment on lines +53 to +91
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');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

All-day events must use DATE format, not DATETIME.

RFC-5545 §3.3.4 requires all-day events to use the DATE value type (YYYYMMDD) for DTSTART and DTEND, not the DATE-TIME format (YYYYMMDDTHHMMSSZ) that toIcsUtc produces. Calendar clients will misinterpret all-day events as timed events starting at midnight UTC.

🗓️ Proposed fix to handle all-day events correctly

Add a DATE formatter and use it conditionally:

 }

+/** Format an epoch-ms instant as a DATE value (e.g. 20260612). */
+function toIcsDate(ms: number): string {
+  const d = new Date(ms);
+  return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`;
+}
+
 /** Escape per RFC-5545 §3.3.11 (commas, semicolons, backslashes, newlines). */
   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)}`,
+    event.allDay
+      ? `DTSTART;VALUE=DATE:${toIcsDate(event.startMs)}`
+      : `DTSTART:${toIcsUtc(event.startMs)}`,
+    event.allDay
+      ? `DTEND;VALUE=DATE:${toIcsDate(event.endMs)}`
+      : `DTEND:${toIcsUtc(event.endMs)}`,
     `SUMMARY:${escapeText(event.title)}`,
   ];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/app/packages/block-calendar/util/ics.ts` around lines 53 - 91, The
DTSTART/DTEND lines in buildIcs currently always use toIcsUtc (DATE-TIME), which
misrepresents all-day events; detect all-day events (e.g., event.allDay) and
format those values as DATE (YYYYMMDD) instead of DATE-TIME, add the VALUE=DATE
parameter (e.g., `DTSTART;VALUE=DATE:YYYYMMDD`) and use a new helper (e.g.,
toIcsDate) or extend toIcsUtc with a date-only mode to produce the YYYYMMDD
string (no 'T' or 'Z'), and update the code that builds `DTSTART:${...}` and
`DTEND:${...}` in buildIcs to use the DATE formatter when event.allDay is true.
Ensure DTSTAMP and other timed fields remain unchanged.

Comment on lines +5 to +19
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<Self> {
let port: usize = std::env::var("PORT")
.unwrap_or("8080".to_string())
.parse::<usize>()
.context("PORT must be a valid number")?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Constrain PORT to a valid TCP port range.

usize accepts values outside 0..=65535, so invalid ports are accepted in config and fail later during bind. Parse/store as u16 to fail fast with a clear config error.

Suggested fix
 pub struct Config {
     /// Port number to listen on.
-    pub port: usize,
+    pub port: u16,
@@
-        let port: usize = std::env::var("PORT")
+        let port: u16 = std::env::var("PORT")
             .unwrap_or("8080".to_string())
-            .parse::<usize>()
-            .context("PORT must be a valid number")?;
+            .parse::<u16>()
+            .context("PORT must be a valid TCP port (0-65535)")?;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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<Self> {
let port: usize = std::env::var("PORT")
.unwrap_or("8080".to_string())
.parse::<usize>()
.context("PORT must be a valid number")?;
pub struct Config {
/// Port number to listen on.
pub port: u16,
/// The deployment environment.
pub environment: Environment,
/// Postgres connection URL.
pub database_url: String,
}
impl Config {
pub fn from_env() -> anyhow::Result<Self> {
let port: u16 = std::env::var("PORT")
.unwrap_or("8080".to_string())
.parse::<u16>()
.context("PORT must be a valid TCP port (0-65535)")?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/cloud-storage/calendar_service/src/config.rs` around lines 5 - 19, The
PORT value is parsed/stored as usize which allows values outside the valid TCP
port range; update the Config struct's port field to u16 and change
Config::from_env to parse PORT into a u16 (failing with a clear error via
.context on parse) so invalid port numbers are rejected early; ensure any uses
of Config.port expect u16 after this change.

Comment on lines +65 to +67
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
.await
.unwrap();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid panic on listener bind failure.

Line 67 uses unwrap(), so a transient bind failure crashes the process instead of returning a contextual startup error.

Suggested fix
     let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
         .await
-        .unwrap();
+        .context("failed to bind calendar service TCP listener")?;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
.await
.unwrap();
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port))
.await
.context("failed to bind calendar service TCP listener")?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/cloud-storage/calendar_service/src/main.rs` around lines 65 - 67, The
code currently uses tokio::net::TcpListener::bind(...).await.unwrap() (creating
variable listener) which panics on bind failure; change main to return a Result
(e.g., Result<(), Box<dyn std::error::Error>> or anyhow::Result) and replace
unwrap() with proper error propagation or handling: call
tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)).await.map_err(|e| {
/* wrap with contextual message including port */ })? or match the Result, log a
contextual startup error (including port and e) and return Err; ensure
subsequent uses of listener still compile with the propagated Result from main.

Comment on lines +30 to +83
#[instrument(err, skip(self))]
async fn list_events(
&self,
user_id: &str,
start_ms: i64,
end_ms: i64,
) -> Result<Vec<CalendarEvent>, 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<Option<CalendarEvent>, 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<CalendarEvent, Report> {
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<Option<CalendarEvent>, 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<bool, Report> {
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<String>,
) -> Result<Option<CalendarEvent>, Report> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Stop recording user_id/event_id/emails in tracing spans.

On Line 30, Line 40, Line 49, Line 60, Line 72, and Line 77, current #[instrument] usage still captures PII-bearing arguments (notably user_id, and in mark_invited, attendee emails). Please skip these fields explicitly.

🔒 Proposed fix
-    #[instrument(err, skip(self))]
+    #[instrument(err, skip(self, user_id))]
@@
-    #[instrument(err, skip(self))]
+    #[instrument(err, skip(self, user_id, event_id))]
@@
-    #[instrument(err, skip(self, request))]
+    #[instrument(err, skip(self, user_id, request))]
@@
-    #[instrument(err, skip(self, request))]
+    #[instrument(err, skip(self, user_id, event_id, request))]
@@
-    #[instrument(err, skip(self))]
+    #[instrument(err, skip(self, user_id, event_id))]
@@
-    #[instrument(err, skip(self))]
+    #[instrument(err, skip(self, user_id, event_id, emails))]

Based on learnings, MacroUserIdStr is in macro|useremail.com form, so logging user_id is logging user-identifying data.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#[instrument(err, skip(self))]
async fn list_events(
&self,
user_id: &str,
start_ms: i64,
end_ms: i64,
) -> Result<Vec<CalendarEvent>, 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<Option<CalendarEvent>, 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<CalendarEvent, Report> {
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<Option<CalendarEvent>, 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<bool, Report> {
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<String>,
) -> Result<Option<CalendarEvent>, Report> {
#[instrument(err, skip(self, user_id))]
async fn list_events(
&self,
user_id: &str,
start_ms: i64,
end_ms: i64,
) -> Result<Vec<CalendarEvent>, Report> {
self.repository.list_events(user_id, start_ms, end_ms).await
}
#[instrument(err, skip(self, user_id, event_id))]
async fn get_event(
&self,
user_id: &str,
event_id: &str,
) -> Result<Option<CalendarEvent>, Report> {
self.repository.get_event(user_id, event_id).await
}
#[instrument(err, skip(self, user_id, request))]
async fn create_event(
&self,
user_id: &str,
request: CreateEventRequest,
) -> Result<CalendarEvent, Report> {
self.repository
.create_event(user_id, normalize(request))
.await
}
#[instrument(err, skip(self, user_id, event_id, request))]
async fn update_event(
&self,
user_id: &str,
event_id: &str,
request: UpdateEventRequest,
) -> Result<Option<CalendarEvent>, Report> {
self.repository
.update_event(user_id, event_id, normalize(request))
.await
}
#[instrument(err, skip(self, user_id, event_id))]
async fn delete_event(&self, user_id: &str, event_id: &str) -> Result<bool, Report> {
self.repository.delete_event(user_id, event_id).await
}
#[instrument(err, skip(self, user_id, event_id, emails))]
async fn mark_invited(
&self,
user_id: &str,
event_id: &str,
emails: Vec<String>,
) -> Result<Option<CalendarEvent>, Report> {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/cloud-storage/calendar/src/domain/service.rs` around lines 30 - 83, The
tracing spans currently record PII (user_id, event_id, attendee emails); update
each function's #[instrument(...)] to explicitly skip those variables: for
list_events skip(user_id), for get_event skip(user_id, event_id), for
create_event skip(user_id, request, and ensure user_id isn't recorded), for
update_event skip(user_id, event_id, request), for delete_event skip(user_id,
event_id), and for mark_invited skip(user_id, event_id, emails) so the
instrument macro on the service methods list_events, get_event, create_event,
update_event, delete_event, and mark_invited no longer captures those sensitive
arguments.

Source: Learnings

Comment on lines +209 to +236
async fn create_event(
&self,
user_id: &str,
request: CreateEventRequest,
) -> Result<CalendarEvent, Report> {
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))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Wrap multi-step event mutations in a DB transaction

These handlers perform dependent writes across multiple statements without a transaction. A mid-operation failure can leave partially-applied state (e.g., event row written/updated but attendee sync incomplete), and mark_invited has a check-then-write race window.

Suggested fix pattern
+let mut tx = self.db.begin().await?;
 let row = sqlx::query_as::<_, EventRow>(/* ... */)
-    .fetch_one(&self.db)
+    .fetch_one(&mut *tx)
     .await?;
-self.upsert_attendees(row.id, &request.attendees).await?;
+self.upsert_attendees_tx(&mut tx, row.id, &request.attendees).await?;
+tx.commit().await?;

Apply the same transactional pattern to update_event (update + delete + upsert) and mark_invited (ownership check + invite upserts).

Also applies to: 238-282, 296-326

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/cloud-storage/calendar/src/outbound/repository.rs` around lines 209 -
236, The create_event flow (and similarly update_event and mark_invited)
performs multiple dependent DB writes (INSERT then upsert_attendees and
attendees_for reads) without a transaction; wrap the multi-step logic in a
single sqlx transaction so either all writes succeed or none are persisted:
begin a Transaction via sqlx::PgPool::begin or pool.begin().await, run the
INSERT (currently in create_event) against the transaction, call
upsert_attendees and any attendee reads/updates using the same transaction
(either add transactional variants that accept &mut Transaction or inline the
SQL against the tx), and commit the transaction at the end; on any error roll
back/return without committing. Update update_event (UPDATE + delete + upsert)
and mark_invited (ownership check + invite upserts) to use the same
transactional pattern, ensuring functions like upsert_attendees and
attendees_for are adapted to accept and use the transaction parameter (or
duplicated transactional SQL) so all steps are atomic.

Comment on lines +8 to +37
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)
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce calendar invariants at the database layer.

On Line 8-20 and Line 26-37, critical domain rules are only documented/in code, not enforced in schema. This allows invalid persisted rows (start_ms > end_ms, arbitrary status) if writes bypass/bug the service path.

🧱 Proposed fix
 CREATE TABLE calendar_event (
@@
     start_ms    bigint NOT NULL,
     end_ms      bigint NOT NULL,
@@
-    updated_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,
+    CONSTRAINT calendar_event_time_order_chk CHECK (start_ms <= end_ms)
 );
@@
 CREATE TABLE calendar_attendee (
@@
-    status     text   NOT NULL DEFAULT 'pending',
+    status     text   NOT NULL DEFAULT 'pending'
+                    CHECK (status IN ('pending', 'accepted', 'declined', 'tentative')),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@rust/cloud-storage/macro_db_client/migrations/20260609120000_calendar_db_schema.sql`
around lines 8 - 37, Add DB-level constraints to enforce the documented
invariants: add a CHECK on calendar_event (e.g., constraint name like
chk_calendar_event_start_le_end) to ensure start_ms <= end_ms; add a constrained
type or CHECK on calendar_attendee.status (e.g., create type
calendar_attendee_status or constraint chk_calendar_attendee_status) to restrict
values to the allowed set ('pending','accepted','declined','tentative'); and add
a CHECK on calendar_attendee to enforce invited_ms is NULL while status =
'pending' (or equivalently that invited_ms IS NOT NULL implies status <>
'pending'). Use the existing table/column names calendar_event.start_ms,
calendar_event.end_ms, calendar_attendee.status and calendar_attendee.invited_ms
when adding the constraints so they survive refactors.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants