-
Notifications
You must be signed in to change notification settings - Fork 2
Add calendar event management API and UI components #334
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
Add a comprehensive calendar system to PageSpace with the following features: Database Schema: - calendarEvents table with support for drive-scoped and personal events - eventAttendees table for tracking invitations and RSVP status - Recurrence rules support (DAILY, WEEKLY, MONTHLY, YEARLY) - Event visibility levels (DRIVE, ATTENDEES_ONLY, PRIVATE) API Routes: - GET/POST /api/calendar/events - list and create events - GET/PATCH/DELETE /api/calendar/events/[eventId] - event CRUD - GET/POST/PATCH/DELETE /api/calendar/events/[eventId]/attendees - attendee management UI Components: - CalendarView with Month, Week, Day, and Agenda views - EventModal for creating and editing events - Task overlay integration (tasks appear muted behind events) - Color-coded events with multiple category options - Responsive design (agenda view on mobile) Page Routes: - /dashboard/calendar - personal calendar (aggregates all events) - /dashboard/[driveId]/calendar - drive-specific calendar Real-time Updates: - Socket.IO broadcasts for calendar events - Broadcasts to drive channel and individual attendee channels https://claude.ai/code/session_014UwUdTDf7hSk3nuC1rTnBU
📝 WalkthroughWalkthroughThis PR introduces a complete calendar feature including database schema for events and attendees, RESTful API endpoints for calendar CRUD operations with attendee management, frontend pages and UI components for multiple calendar views (month, week, day, agenda), an event modal for creation/editing, and real-time WebSocket broadcasting for calendar updates. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client<br/>(Browser)
participant Server as API Server<br/>/api/calendar/events
participant DB as Database
participant WS as WebSocket<br/>Broadcaster
rect rgba(100, 150, 200, 0.5)
Note over Client,WS: Event Creation Flow
Client->>Server: POST /api/calendar/events<br/>(title, startAt, endAt,<br/>attendeeIds, driveId)
Server->>Server: Validate auth & body
Server->>DB: Insert calendar_events<br/>with createdById
Server->>DB: Insert event_attendees<br/>(creator as organizer,<br/>others as PENDING)
DB-->>Server: Return created event<br/>with relations
Server->>WS: Broadcast 'created' event<br/>to drive & attendees
WS-->>WS: Publish to drive:${driveId}:calendar<br/>and user:${attendeeId}:calendar
Server-->>Client: Return 201 + event
Client->>Client: Update local state via SWR
end
sequenceDiagram
participant Client as Client<br/>(Browser)
participant Hook as useCalendarData<br/>Hook
participant Server as API Server<br/>/api/calendar/events
participant DB as Database
rect rgba(100, 150, 200, 0.5)
Note over Client,DB: Event Fetching & Display Flow
Client->>Hook: useCalendarData(context,<br/>driveId, dateRange)
Hook->>Hook: Compute buffered date range<br/>& build query URLs
Hook->>Server: SWR fetch GET /api/calendar/events?<br/>context, driveId, startDate, endDate
Server->>Server: Validate context & auth
alt User Context
Server->>DB: Query personal events +<br/>drive-scoped events +<br/>attendee events
else Drive Context
Server->>DB: Query drive-scoped events<br/>within date range
end
DB-->>Server: Return events with relations<br/>(createdBy, attendees, page, drive)
Server-->>Hook: Return JSON events
Hook-->>Client: Return {events, tasks,<br/>loading, error, ...mutations}
Client->>Client: Render CalendarView<br/>with current view mode
Note over Client: Display events in<br/>MonthView/WeekView/DayView/AgendaView
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bb927cff4a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const event = await db.query.calendarEvents.findFirst({ | ||
| where: and( | ||
| eq(calendarEvents.id, eventId), | ||
| eq(calendarEvents.isTrashed, false) | ||
| ), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Enforce access checks before returning attendee list
The attendees endpoint returns the full attendee list (including emails) for any existing event without verifying that the caller can access the event. Because only isTrashed and id are checked here, any authenticated user who can guess or obtain an eventId can enumerate attendees for private or attendees-only events. This is a privacy leak; the handler should apply the same access rules used by the event GET (creator/attendee or drive member with DRIVE visibility) before returning attendee details.
Useful? React with 👍 / 👎.
| where: and( | ||
| eq(calendarEvents.driveId, params.driveId), | ||
| eq(calendarEvents.isTrashed, false), | ||
| lte(calendarEvents.startAt, params.endDate), | ||
| gte(calendarEvents.endAt, params.startDate) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Filter drive calendar results by event visibility
In drive context, the listing query returns all events in the drive regardless of visibility. That means events marked ATTENDEES_ONLY or PRIVATE become visible to every drive member who hits the drive calendar view. This bypasses the visibility model described elsewhere in the API (where only attendees/creator can see those events). The drive listing should restrict to visibility = DRIVE or include only events where the user is an attendee/creator.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/db/src/schema.ts (1)
25-79:⚠️ Potential issue | 🔴 CriticalGenerate and commit migrations for the new calendar schema.
The calendar schema defines two tables (
calendarEventsandeventAttendees) with enums, indexes, and relations, but no corresponding migration exists inpackages/db/drizzle/. Runpnpm db:generateto auto-generate the required migration files before merging.
🤖 Fix all issues with AI agents
In `@apps/web/src/app/api/calendar/events/`[eventId]/attendees/route.ts:
- Around line 44-69: The GET handler currently reads auth.userId into _userId
but never enforces access controls; after fetching the event (calendarEvents)
check its visibility field and enforce that if event.visibility === 'PRIVATE' or
'ATTENDEES_ONLY' the requester is either the event.creatorId or is listed as an
attendee: query eventAttendees.findFirst({ where: and(eq(eventAttendees.eventId,
eventId), eq(eventAttendees.userId, _userId)) }) and if neither condition holds
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); only then
proceed to fetch and return the attendees.
- Around line 145-154: Before calling db.insert on eventAttendees with
newUserIds, validate that each userId exists in the users table: query the users
table (e.g., select id from users where id in newUserIds) to get existing IDs,
compute missingIds = newUserIds - existingIds, and if missingIds is non-empty
return a 400 response listing the invalid user IDs; only call
db.insert(eventAttendees).values(...) with the verified existing IDs (or abort
with the clear error) so the handler avoids a DB foreign-key error and provides
a precise client-facing message.
In `@apps/web/src/app/api/calendar/events/route.ts`:
- Around line 92-135: The drive-context branch (when params.context === 'drive')
returns all drive events and thus exposes ATTENDEES_ONLY/PRIVATE events; update
the db.query.calendarEvents.findMany where clause to enforce visibility: only
include events with calendarEvents.visibility === 'DRIVE' unless the requester
is the event creator (createdBy.id === userId) or is listed as an
attendee/organizer. Implement this by adding an OR group to the existing
and(...) predicate that permits events where visibility is DRIVE OR
(createdBy.id == userId) OR (exists an attendee record for userId with role
organizer/attendee), using the calendarEvents.attendees relation (or an
exists/subquery) to test membership; keep the with: attendees/createdBy
projections and preserve other filters (driveId, isTrashed, date range) and
ordering.
- Around line 1-15: The conditions array is currently untyped (const conditions
= []) causing an implicit any[]; import the SQL type from '@pagespace/db' and
declare the array as const conditions: SQL[] = [] so TypeScript knows the
element type. Update the import list to include SQL (alongside db,
calendarEvents, etc.) and replace the untyped declaration (conditions) with the
explicitly typed one to satisfy no-any rules.
- Around line 264-336: The event creation should deduplicate attendeeIds and
perform all attendee inserts atomically to avoid unique constraint failures and
partial state: before inserting into eventAttendees, create a deduped array
(remove duplicates and the creating userId) from data.attendeeIds, then run the
creator insert and the other attendee inserts inside a single transaction (use
the existing db.transaction / db.transaction(async (tx) => ...) or the project's
transactional API) using tx.insert on eventAttendees and tx.insert on
calendarEvents (or insert calendarEvents inside the same transaction), commit
before fetching completeEvent (db.query.calendarEvents.findFirst) and before
calling broadcastCalendarEvent so that broadcasting only happens after a
successful transaction; ensure you still set the creator as organizer
(isOrganizer: true) and add other attendees with status 'PENDING'.
In `@apps/web/src/components/calendar/EventModal.tsx`:
- Around line 161-166: The code mutates React state by calling
startDate.setHours(...) and endDate.setHours(...); instead, clone the Date
objects before modifying them so state isn't mutated. Update the startAt/endAt
logic to create new Date instances from startDate and endDate (e.g., new
Date(startDate) / new Date(endDate)) and call setHours on those clones when
allDay is true, or otherwise continue using buildDateTime(startDate, startTime)
and buildDateTime(endDate, endTime); ensure references in the startAt and endAt
expressions (allDay, startDate, endDate, startTime, endTime, buildDateTime) are
used without mutating the original Date objects.
In `@packages/db/src/schema/calendar.ts`:
- Around line 1-5: The new tables calendarEvents and eventAttendees are defined
in the schema (symbols: calendarEvents, eventAttendees in
packages/db/src/schema/calendar.ts) but no SQL migrations were generated; run
the migration generator (pnpm db:generate) from the repo root so Drizzle outputs
the corresponding migration SQL files into packages/db/drizzle/, verify the
generated migration includes the table definitions and any indexes/enums used,
and add/commit those migration files to the PR so the DB schema and migrations
remain in sync.
🧹 Nitpick comments (8)
apps/web/src/components/calendar/useCalendarData.ts (2)
17-23: MakedriveIdrequired whencontextis"drive".
This prevents accidental drive events being created with anulldriveId and improves call-site safety.♻️ Suggested typing upgrade
-interface UseCalendarDataOptions { - context: 'user' | 'drive'; - driveId?: string; - currentDate: Date; - includePersonal?: boolean; - includeTasks?: boolean; -} +type UseCalendarDataOptions = + | { + context: 'user'; + currentDate: Date; + includePersonal?: boolean; + includeTasks?: boolean; + driveId?: never; + } + | { + context: 'drive'; + driveId: string; + currentDate: Date; + includePersonal?: boolean; + includeTasks?: boolean; + };
47-110: AlignisPausedwith the standard editing-protection pattern.
UsingisEditingActive()keeps pause behavior consistent and avoids stale values if the store implementation changes.
As per coding guidelines, for SWR data fetching with editing protection, useisPaused: () => hasLoadedRef.current && isEditingActive()to allow initial fetch and only pause after, withrevalidateOnFocus: false.♻️ Suggested adjustment
- const isAnyActive = useEditingStore((state) => state.isAnyActive()); + const isEditingActive = useEditingStore((state) => state.isEditingActive); @@ - isPaused: () => hasLoadedRef.current && isAnyActive, + isPaused: () => hasLoadedRef.current && isEditingActive(), @@ - isPaused: () => hasLoadedRef.current && isAnyActive, + isPaused: () => hasLoadedRef.current && isEditingActive(),apps/web/src/components/calendar/MonthView.tsx (1)
201-227: TaskPill completion indicator inconsistent with AgendaView.The TaskPill always displays
☐regardless of completion status, while AgendaView's TaskCard shows✓for completed tasks (line 271 in AgendaView.tsx). Consider aligning the visual indicators for consistency across views.♻️ Proposed fix for consistency
return ( <button className={cn( 'w-full text-left px-1.5 py-0.5 rounded text-xs truncate border-l-2', TASK_OVERLAY_STYLE.bg, TASK_OVERLAY_STYLE.border, TASK_OVERLAY_STYLE.opacity, isCompleted && 'line-through', 'hover:opacity-100 transition-opacity' )} onClick={onClick} > - <span className="mr-1">☐</span> + <span className="mr-1">{isCompleted ? '✓' : '☐'}</span> {task.title} </button> );apps/web/src/components/calendar/DayView.tsx (2)
185-202: CurrentTimeIndicator does not update in real-time.The indicator calculates position using
new Date()at render time but won't update as time passes. Users keeping the view open for extended periods will see a stale position.♻️ Proposed fix using interval
+import { useState, useEffect, useMemo, useRef } from 'react'; // Current time indicator function CurrentTimeIndicator() { - const now = new Date(); - const minutes = now.getHours() * 60 + now.getMinutes(); + const [now, setNow] = useState(new Date()); + + useEffect(() => { + const interval = setInterval(() => setNow(new Date()), 60000); + return () => clearInterval(interval); + }, []); + + const minutes = now.getHours() * 60 + now.getMinutes(); const top = (minutes / 60) * HOUR_HEIGHT;
298-326: TaskCard completion indicator inconsistent.Same issue as MonthView - the checkbox always shows
☐regardless of completion status whileline-throughis applied. Consider showing✓for completed tasks to match AgendaView.apps/web/src/components/calendar/EventModal.tsx (1)
355-367: Color picker buttons should have explicittype="button".Without
type="button", these buttons default totype="submit"inside a form context, which could cause unintended form submission.♻️ Proposed fix
<button key={colorKey} + type="button" className={cn( 'w-8 h-8 rounded-full transition-all',apps/web/src/app/api/calendar/events/[eventId]/route.ts (1)
44-76: Consider using centralized permission functions.The coding guidelines recommend using centralized permission functions from
@pagespace/lib/permissions. The localcanAccessEventandcanEditEventhelpers implement custom logic that could potentially be consolidated with existing permission patterns for consistency. As per coding guidelines: "Use centralized permission functions from@pagespace/lib/permissions".apps/web/src/lib/websocket/calendar-events.ts (1)
43-98: Verify Socket.IO alignment and avoid sequential fan‑out.
This module posts to/api/broadcastviafetch. Please confirm this is the sanctioned Socket.IO gateway; otherwise, align with the Socket.IO client from the realtime service. Also, attendee broadcasts are sequential and can block request paths for large attendee lists—consider deduping and running requests in parallel.⚡ Optional fan‑out optimization
- // Broadcast to individual attendee channels (for personal calendar views) - for (const attendeeId of payload.attendeeIds) { - const userRequestBody = JSON.stringify({ - channelId: `user:${attendeeId}:calendar`, - event: `calendar:${payload.operation}`, - payload, - }); - - await fetch(`${realtimeUrl}/api/broadcast`, { - method: 'POST', - headers: createSignedBroadcastHeaders(userRequestBody), - body: userRequestBody, - }); - } + const uniqueAttendeeIds = [...new Set(payload.attendeeIds)]; + + // Broadcast to individual attendee channels (for personal calendar views) + await Promise.all( + uniqueAttendeeIds.map(async (attendeeId) => { + const userRequestBody = JSON.stringify({ + channelId: `user:${attendeeId}:calendar`, + event: `calendar:${payload.operation}`, + payload, + }); + + await fetch(`${realtimeUrl}/api/broadcast`, { + method: 'POST', + headers: createSignedBroadcastHeaders(userRequestBody), + body: userRequestBody, + }); + }) + ); @@ - attendeeCount: payload.attendeeIds.length, + attendeeCount: uniqueAttendeeIds.length,As per coding guidelines, Use Socket.IO for real-time collaboration features - imported from the realtime service at port 3001.
| const _userId = auth.userId; | ||
|
|
||
| try { | ||
| // Verify event exists and user has access | ||
| const event = await db.query.calendarEvents.findFirst({ | ||
| where: and( | ||
| eq(calendarEvents.id, eventId), | ||
| eq(calendarEvents.isTrashed, false) | ||
| ), | ||
| }); | ||
|
|
||
| if (!event) { | ||
| return NextResponse.json({ error: 'Event not found' }, { status: 404 }); | ||
| } | ||
|
|
||
| // Get attendees with user info | ||
| const attendees = await db.query.eventAttendees.findMany({ | ||
| where: eq(eventAttendees.eventId, eventId), | ||
| with: { | ||
| user: { | ||
| columns: { id: true, name: true, email: true, image: true }, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| return NextResponse.json({ attendees }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
GET handler missing access control check.
The handler fetches _userId (line 44) but doesn't verify if the authenticated user has permission to view this event's attendees. For events with PRIVATE or ATTENDEES_ONLY visibility, unauthorized users could enumerate attendee lists.
🛡️ Proposed fix to add access verification
- const _userId = auth.userId;
+ const userId = auth.userId;
try {
// Verify event exists and user has access
const event = await db.query.calendarEvents.findFirst({
where: and(
eq(calendarEvents.id, eventId),
eq(calendarEvents.isTrashed, false)
),
});
if (!event) {
return NextResponse.json({ error: 'Event not found' }, { status: 404 });
}
+ // Verify user has access to this event
+ const isCreator = event.createdById === userId;
+ const isAttendee = await db.query.eventAttendees.findFirst({
+ where: and(
+ eq(eventAttendees.eventId, eventId),
+ eq(eventAttendees.userId, userId)
+ ),
+ });
+
+ if (!isCreator && !isAttendee) {
+ return NextResponse.json({ error: 'Access denied' }, { status: 403 });
+ }🤖 Prompt for AI Agents
In `@apps/web/src/app/api/calendar/events/`[eventId]/attendees/route.ts around
lines 44 - 69, The GET handler currently reads auth.userId into _userId but
never enforces access controls; after fetching the event (calendarEvents) check
its visibility field and enforce that if event.visibility === 'PRIVATE' or
'ATTENDEES_ONLY' the requester is either the event.creatorId or is listed as an
attendee: query eventAttendees.findFirst({ where: and(eq(eventAttendees.eventId,
eventId), eq(eventAttendees.userId, _userId)) }) and if neither condition holds
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); only then
proceed to fetch and return the attendees.
| // Add new attendees | ||
| await db.insert(eventAttendees).values( | ||
| newUserIds.map(attendeeId => ({ | ||
| eventId, | ||
| userId: attendeeId, | ||
| status: 'PENDING' as const, | ||
| isOrganizer: false, | ||
| isOptional, | ||
| })) | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
cat -n apps/web/src/app/api/calendar/events/\[eventId\]/attendees/route.tsRepository: 2witstudios/PageSpace
Length of output: 12551
🏁 Script executed:
find . -type f -name "*.ts" -path "*db*" | grep -E "(schema|migration)" | head -20Repository: 2witstudios/PageSpace
Length of output: 832
🏁 Script executed:
cat packages/db/src/schema/calendar.tsRepository: 2witstudios/PageSpace
Length of output: 6417
🏁 Script executed:
rg -A 5 "references.*users\.id" packages/db/src/schema/ | head -40Repository: 2witstudios/PageSpace
Length of output: 2750
Add application-level validation for user IDs before inserting attendees.
While the database foreign key constraint on eventAttendees.userId prevents orphaned records, the POST handler should validate that the provided user IDs exist before attempting insertion. Currently, invalid user IDs will cause a database constraint error caught by the generic error handler, returning a 500 response with "Failed to add event attendees"—leaving the client without clarity on which users don't exist. Validate user existence before the insert at line 145 to provide clearer error feedback.
🤖 Prompt for AI Agents
In `@apps/web/src/app/api/calendar/events/`[eventId]/attendees/route.ts around
lines 145 - 154, Before calling db.insert on eventAttendees with newUserIds,
validate that each userId exists in the users table: query the users table
(e.g., select id from users where id in newUserIds) to get existing IDs, compute
missingIds = newUserIds - existingIds, and if missingIds is non-empty return a
400 response listing the invalid user IDs; only call
db.insert(eventAttendees).values(...) with the verified existing IDs (or abort
with the clear error) so the handler avoids a DB foreign-key error and provides
a precise client-facing message.
| import { NextResponse } from 'next/server'; | ||
| import { z } from 'zod'; | ||
| import { | ||
| db, | ||
| calendarEvents, | ||
| eventAttendees, | ||
| eq, | ||
| and, | ||
| or, | ||
| gte, | ||
| lte, | ||
| inArray, | ||
| isNull, | ||
| desc, | ||
| } from '@pagespace/db'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
wc -l apps/web/src/app/api/calendar/events/route.tsRepository: 2witstudios/PageSpace
Length of output: 114
🏁 Script executed:
head -20 apps/web/src/app/api/calendar/events/route.tsRepository: 2witstudios/PageSpace
Length of output: 540
🏁 Script executed:
sed -n '140,180p' apps/web/src/app/api/calendar/events/route.tsRepository: 2witstudios/PageSpace
Length of output: 1216
🏁 Script executed:
sed -n '175,210p' apps/web/src/app/api/calendar/events/route.tsRepository: 2witstudios/PageSpace
Length of output: 986
🏁 Script executed:
rg "or\(" apps/web/src/app/api/calendar/events/route.ts -A 2 -B 2Repository: 2witstudios/PageSpace
Length of output: 1098
🏁 Script executed:
rg "import.*SQL" apps/web/src --type ts --type tsxRepository: 2witstudios/PageSpace
Length of output: 92
🏁 Script executed:
rg "SQL\[" apps/web/src --type ts --type tsx -B 2 -A 2Repository: 2witstudios/PageSpace
Length of output: 92
🏁 Script executed:
rg "conditions.*:" apps/web/src/app/api --type ts -B 2 -A 2Repository: 2witstudios/PageSpace
Length of output: 6235
🏁 Script executed:
sed -n '1,50p' apps/web/src/app/api/search/route.tsRepository: 2witstudios/PageSpace
Length of output: 1659
🏁 Script executed:
rg "const conditions" apps/web/src/app/api --type ts -B 2 -A 8Repository: 2witstudios/PageSpace
Length of output: 8667
🏁 Script executed:
grep -n "SQL" apps/web/src/app/api/calendar/events/route.tsRepository: 2witstudios/PageSpace
Length of output: 47
🏁 Script executed:
head -50 apps/web/src/app/api/admin/audit-logs/route.tsRepository: 2witstudios/PageSpace
Length of output: 1395
🏁 Script executed:
grep -n "export.*SQL" packages/db/src/index.tsRepository: 2witstudios/PageSpace
Length of output: 143
🏁 Script executed:
cat packages/db/src/index.ts | head -100Repository: 2witstudios/PageSpace
Length of output: 2373
🏁 Script executed:
sed -n '1,20p' apps/web/src/app/api/calendar/events/route.ts | head -20Repository: 2witstudios/PageSpace
Length of output: 540
Add explicit SQL[] type to conditions array to avoid implicit any[].
const conditions = []; infers any[] under typical TypeScript settings, which violates the no-any rule. Import SQL from @pagespace/db and add explicit typing.
🛠️ Suggested type fix
import {
db,
calendarEvents,
eventAttendees,
eq,
and,
or,
gte,
lte,
inArray,
isNull,
desc,
+ type SQL,
} from '@pagespace/db';
...
- const conditions = [];
+ const conditions: SQL[] = [];🤖 Prompt for AI Agents
In `@apps/web/src/app/api/calendar/events/route.ts` around lines 1 - 15, The
conditions array is currently untyped (const conditions = []) causing an
implicit any[]; import the SQL type from '@pagespace/db' and declare the array
as const conditions: SQL[] = [] so TypeScript knows the element type. Update the
import list to include SQL (alongside db, calendarEvents, etc.) and replace the
untyped declaration (conditions) with the explicitly typed one to satisfy no-any
rules.
| if (params.context === 'drive') { | ||
| if (!params.driveId) { | ||
| return NextResponse.json( | ||
| { error: 'driveId is required for drive context' }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| const canView = await isUserDriveMember(userId, params.driveId); | ||
| if (!canView) { | ||
| return NextResponse.json( | ||
| { error: 'Unauthorized - you do not have access to this drive' }, | ||
| { status: 403 } | ||
| ); | ||
| } | ||
|
|
||
| // Fetch drive events within date range | ||
| const events = await db.query.calendarEvents.findMany({ | ||
| where: and( | ||
| eq(calendarEvents.driveId, params.driveId), | ||
| eq(calendarEvents.isTrashed, false), | ||
| lte(calendarEvents.startAt, params.endDate), | ||
| gte(calendarEvents.endAt, params.startDate) | ||
| ), | ||
| with: { | ||
| createdBy: { | ||
| columns: { id: true, name: true, image: true }, | ||
| }, | ||
| attendees: { | ||
| with: { | ||
| user: { | ||
| columns: { id: true, name: true, image: true }, | ||
| }, | ||
| }, | ||
| }, | ||
| page: { | ||
| columns: { id: true, title: true, type: true }, | ||
| }, | ||
| }, | ||
| orderBy: [desc(calendarEvents.startAt)], | ||
| }); | ||
|
|
||
| return NextResponse.json({ events }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Enforce visibility rules in drive-context listings.
Drive-context queries currently return every event in the drive, which exposes ATTENDEES_ONLY/PRIVATE events to non-attendees. Filter to DRIVE visibility unless the requester is an attendee/organizer (or creator), similar to the user-context logic.
🔒 Suggested filtering approach
+ const attendeeEvents = await db
+ .select({ eventId: eventAttendees.eventId })
+ .from(eventAttendees)
+ .where(eq(eventAttendees.userId, userId));
+ const attendeeEventIds = attendeeEvents.map(e => e.eventId);
+ const visibilityFilter =
+ attendeeEventIds.length > 0
+ ? or(
+ eq(calendarEvents.visibility, 'DRIVE'),
+ inArray(calendarEvents.id, attendeeEventIds)
+ )
+ : eq(calendarEvents.visibility, 'DRIVE');
+
const events = await db.query.calendarEvents.findMany({
where: and(
eq(calendarEvents.driveId, params.driveId),
eq(calendarEvents.isTrashed, false),
+ visibilityFilter,
lte(calendarEvents.startAt, params.endDate),
gte(calendarEvents.endAt, params.startDate)
),🤖 Prompt for AI Agents
In `@apps/web/src/app/api/calendar/events/route.ts` around lines 92 - 135, The
drive-context branch (when params.context === 'drive') returns all drive events
and thus exposes ATTENDEES_ONLY/PRIVATE events; update the
db.query.calendarEvents.findMany where clause to enforce visibility: only
include events with calendarEvents.visibility === 'DRIVE' unless the requester
is the event creator (createdBy.id === userId) or is listed as an
attendee/organizer. Implement this by adding an OR group to the existing
and(...) predicate that permits events where visibility is DRIVE OR
(createdBy.id == userId) OR (exists an attendee record for userId with role
organizer/attendee), using the calendarEvents.attendees relation (or an
exists/subquery) to test membership; keep the with: attendees/createdBy
projections and preserve other filters (driveId, isTrashed, date range) and
ordering.
| // Create the event | ||
| const [event] = await db | ||
| .insert(calendarEvents) | ||
| .values({ | ||
| driveId: data.driveId ?? null, | ||
| createdById: userId, | ||
| pageId: data.pageId ?? null, | ||
| title: data.title, | ||
| description: data.description ?? null, | ||
| location: data.location ?? null, | ||
| startAt: data.startAt, | ||
| endAt: data.endAt, | ||
| allDay: data.allDay, | ||
| timezone: data.timezone, | ||
| recurrenceRule: data.recurrenceRule ?? null, | ||
| visibility: data.visibility, | ||
| color: data.color, | ||
| updatedAt: new Date(), | ||
| }) | ||
| .returning(); | ||
|
|
||
| // Add creator as organizer attendee | ||
| await db.insert(eventAttendees).values({ | ||
| eventId: event.id, | ||
| userId: userId, | ||
| status: 'ACCEPTED', | ||
| isOrganizer: true, | ||
| respondedAt: new Date(), | ||
| }); | ||
|
|
||
| // Add other attendees if provided | ||
| if (data.attendeeIds && data.attendeeIds.length > 0) { | ||
| const otherAttendees = data.attendeeIds.filter(id => id !== userId); | ||
| if (otherAttendees.length > 0) { | ||
| await db.insert(eventAttendees).values( | ||
| otherAttendees.map(attendeeId => ({ | ||
| eventId: event.id, | ||
| userId: attendeeId, | ||
| status: 'PENDING' as const, | ||
| isOrganizer: false, | ||
| })) | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| // Fetch the complete event with relations | ||
| const completeEvent = await db.query.calendarEvents.findFirst({ | ||
| where: eq(calendarEvents.id, event.id), | ||
| with: { | ||
| createdBy: { | ||
| columns: { id: true, name: true, image: true }, | ||
| }, | ||
| attendees: { | ||
| with: { | ||
| user: { | ||
| columns: { id: true, name: true, image: true }, | ||
| }, | ||
| }, | ||
| }, | ||
| page: { | ||
| columns: { id: true, title: true, type: true }, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| // Broadcast event creation | ||
| await broadcastCalendarEvent({ | ||
| eventId: event.id, | ||
| driveId: data.driveId ?? null, | ||
| operation: 'created', | ||
| userId, | ||
| attendeeIds: [userId, ...(data.attendeeIds ?? [])], | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# Find database schema files
fd -e sql -e ts | grep -E "(schema|drizzle|event)" | head -20Repository: 2witstudios/PageSpace
Length of output: 1000
🏁 Script executed:
# Search for eventAttendees table definition
rg "eventAttendees" --type ts --type tsx -A 5 -B 2 | head -50Repository: 2witstudios/PageSpace
Length of output: 92
🏁 Script executed:
# Look for unique constraints on eventAttendees
rg "unique|constraint" --type ts --type tsx | grep -i eventRepository: 2witstudios/PageSpace
Length of output: 92
🏁 Script executed:
# Check how transactions are used in the codebase
rg "db\.transaction|transaction\(" --type ts --type tsx | head -20Repository: 2witstudios/PageSpace
Length of output: 92
🏁 Script executed:
# Find schema definition files in packages
fd -type f -name "*.ts" | grep -E "schema|db" | grep packages/db | head -20Repository: 2witstudios/PageSpace
Length of output: 235
🏁 Script executed:
# Look for eventAttendees definition
rg "eventAttendees" -A 10 | head -100Repository: 2witstudios/PageSpace
Length of output: 7190
🏁 Script executed:
# Check the schema in packages/db
ls -la packages/db/Repository: 2witstudios/PageSpace
Length of output: 670
🏁 Script executed:
# Look for table definitions
fd -type f -name "*.ts" packages/db/ | xargs grep -l "eventAttendees\|createTable" | head -10Repository: 2witstudios/PageSpace
Length of output: 235
🏁 Script executed:
cat -n packages/db/src/schema/calendar.ts | grep -A 30 "export const eventAttendees = pgTable"Repository: 2witstudios/PageSpace
Length of output: 1631
🏁 Script executed:
# Check for unique constraints in calendar schema
grep -n "unique\|constraint" packages/db/src/schema/calendar.tsRepository: 2witstudios/PageSpace
Length of output: 265
🏁 Script executed:
# Check how transactions are used in the route handler file
grep -n "transaction\|db\." apps/web/src/app/api/calendar/events/route.ts | head -40Repository: 2witstudios/PageSpace
Length of output: 367
🏁 Script executed:
# Get more context around the code - see full POST handler
sed -n '264,340p' apps/web/src/app/api/calendar/events/route.tsRepository: 2witstudios/PageSpace
Length of output: 2307
🏁 Script executed:
# Check if there are imports for transaction
grep -n "transaction\|import" apps/web/src/app/api/calendar/events/route.ts | head -20Repository: 2witstudios/PageSpace
Length of output: 429
🏁 Script executed:
# Look for transaction usage patterns in the codebase
rg "\.transaction\(" apps/web/src --A 5 | head -50Repository: 2witstudios/PageSpace
Length of output: 470
🏁 Script executed:
# Check the database export to see what APIs are available
grep -n "export\|transaction" packages/db/src/index.ts | head -30Repository: 2witstudios/PageSpace
Length of output: 924
🏁 Script executed:
# Check if db.transaction is available and how it's used
grep -rn "\.transaction" packages/db/src --include="*.ts"Repository: 2witstudios/PageSpace
Length of output: 251
🏁 Script executed:
# Check the drizzle config and db setup
cat packages/db/src/index.tsRepository: 2witstudios/PageSpace
Length of output: 2373
🏁 Script executed:
# Look for any transaction functions exported
fd transaction packages/dbRepository: 2witstudios/PageSpace
Length of output: 209
🏁 Script executed:
# Let me verify how the duplicate scenario would fail by checking the broadcast call
sed -n '330,340p' apps/web/src/app/api/calendar/events/route.tsRepository: 2witstudios/PageSpace
Length of output: 425
🏁 Script executed:
# Check if there are any other similar patterns in the codebase that handle this
rg "attendeeIds" apps/web/src/app/api/calendar/events/route.tsRepository: 2witstudios/PageSpace
Length of output: 303
🏁 Script executed:
# Verify the complete POST handler to see error handling
sed -n '240,350p' apps/web/src/app/api/calendar/events/route.tsRepository: 2witstudios/PageSpace
Length of output: 3020
🏁 Script executed:
# Check if there are similar multi-insert patterns elsewhere to see if they handle deduplication
rg "\.insert.*\.values\(" apps/web/src/app/api/calendar -A 3 | head -50Repository: 2witstudios/PageSpace
Length of output: 1104
Deduplicate attendee IDs and wrap attendee inserts in a transaction to ensure atomicity.
The eventAttendees table enforces a unique constraint on (eventId, userId). If data.attendeeIds contains duplicates, the code filters out userId but doesn't deduplicate the array itself. The second insert would then fail with a constraint violation after the event has already been created, leaving the database in an inconsistent state and returning a 500 error with partial data.
Wrapping the inserts in a transaction with deduplication prevents both the constraint violation and partial data insertion.
🧱 Suggested transaction + dedupe
- // Create the event
- const [event] = await db
- .insert(calendarEvents)
- .values({
- driveId: data.driveId ?? null,
- createdById: userId,
- pageId: data.pageId ?? null,
- title: data.title,
- description: data.description ?? null,
- location: data.location ?? null,
- startAt: data.startAt,
- endAt: data.endAt,
- allDay: data.allDay,
- timezone: data.timezone,
- recurrenceRule: data.recurrenceRule ?? null,
- visibility: data.visibility,
- color: data.color,
- updatedAt: new Date(),
- })
- .returning();
-
- // Add creator as organizer attendee
- await db.insert(eventAttendees).values({
- eventId: event.id,
- userId: userId,
- status: 'ACCEPTED',
- isOrganizer: true,
- respondedAt: new Date(),
- });
-
- // Add other attendees if provided
- if (data.attendeeIds && data.attendeeIds.length > 0) {
- const otherAttendees = data.attendeeIds.filter(id => id !== userId);
- if (otherAttendees.length > 0) {
- await db.insert(eventAttendees).values(
- otherAttendees.map(attendeeId => ({
- eventId: event.id,
- userId: attendeeId,
- status: 'PENDING' as const,
- isOrganizer: false,
- }))
- );
- }
- }
+ const uniqueAttendeeIds = [...new Set(data.attendeeIds ?? [])].filter(
+ attendeeId => attendeeId !== userId
+ );
+
+ const event = await db.transaction(async (tx) => {
+ const [newEvent] = await tx
+ .insert(calendarEvents)
+ .values({
+ driveId: data.driveId ?? null,
+ createdById: userId,
+ pageId: data.pageId ?? null,
+ title: data.title,
+ description: data.description ?? null,
+ location: data.location ?? null,
+ startAt: data.startAt,
+ endAt: data.endAt,
+ allDay: data.allDay,
+ timezone: data.timezone,
+ recurrenceRule: data.recurrenceRule ?? null,
+ visibility: data.visibility,
+ color: data.color,
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ await tx.insert(eventAttendees).values({
+ eventId: newEvent.id,
+ userId,
+ status: 'ACCEPTED',
+ isOrganizer: true,
+ respondedAt: new Date(),
+ });
+
+ if (uniqueAttendeeIds.length > 0) {
+ await tx.insert(eventAttendees).values(
+ uniqueAttendeeIds.map(attendeeId => ({
+ eventId: newEvent.id,
+ userId: attendeeId,
+ status: 'PENDING' as const,
+ isOrganizer: false,
+ }))
+ );
+ }
+
+ return newEvent;
+ });
@@
- attendeeIds: [userId, ...(data.attendeeIds ?? [])],
+ attendeeIds: [userId, ...uniqueAttendeeIds],📝 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.
| // Create the event | |
| const [event] = await db | |
| .insert(calendarEvents) | |
| .values({ | |
| driveId: data.driveId ?? null, | |
| createdById: userId, | |
| pageId: data.pageId ?? null, | |
| title: data.title, | |
| description: data.description ?? null, | |
| location: data.location ?? null, | |
| startAt: data.startAt, | |
| endAt: data.endAt, | |
| allDay: data.allDay, | |
| timezone: data.timezone, | |
| recurrenceRule: data.recurrenceRule ?? null, | |
| visibility: data.visibility, | |
| color: data.color, | |
| updatedAt: new Date(), | |
| }) | |
| .returning(); | |
| // Add creator as organizer attendee | |
| await db.insert(eventAttendees).values({ | |
| eventId: event.id, | |
| userId: userId, | |
| status: 'ACCEPTED', | |
| isOrganizer: true, | |
| respondedAt: new Date(), | |
| }); | |
| // Add other attendees if provided | |
| if (data.attendeeIds && data.attendeeIds.length > 0) { | |
| const otherAttendees = data.attendeeIds.filter(id => id !== userId); | |
| if (otherAttendees.length > 0) { | |
| await db.insert(eventAttendees).values( | |
| otherAttendees.map(attendeeId => ({ | |
| eventId: event.id, | |
| userId: attendeeId, | |
| status: 'PENDING' as const, | |
| isOrganizer: false, | |
| })) | |
| ); | |
| } | |
| } | |
| // Fetch the complete event with relations | |
| const completeEvent = await db.query.calendarEvents.findFirst({ | |
| where: eq(calendarEvents.id, event.id), | |
| with: { | |
| createdBy: { | |
| columns: { id: true, name: true, image: true }, | |
| }, | |
| attendees: { | |
| with: { | |
| user: { | |
| columns: { id: true, name: true, image: true }, | |
| }, | |
| }, | |
| }, | |
| page: { | |
| columns: { id: true, title: true, type: true }, | |
| }, | |
| }, | |
| }); | |
| // Broadcast event creation | |
| await broadcastCalendarEvent({ | |
| eventId: event.id, | |
| driveId: data.driveId ?? null, | |
| operation: 'created', | |
| userId, | |
| attendeeIds: [userId, ...(data.attendeeIds ?? [])], | |
| }); | |
| const uniqueAttendeeIds = [...new Set(data.attendeeIds ?? [])].filter( | |
| attendeeId => attendeeId !== userId | |
| ); | |
| const event = await db.transaction(async (tx) => { | |
| const [newEvent] = await tx | |
| .insert(calendarEvents) | |
| .values({ | |
| driveId: data.driveId ?? null, | |
| createdById: userId, | |
| pageId: data.pageId ?? null, | |
| title: data.title, | |
| description: data.description ?? null, | |
| location: data.location ?? null, | |
| startAt: data.startAt, | |
| endAt: data.endAt, | |
| allDay: data.allDay, | |
| timezone: data.timezone, | |
| recurrenceRule: data.recurrenceRule ?? null, | |
| visibility: data.visibility, | |
| color: data.color, | |
| updatedAt: new Date(), | |
| }) | |
| .returning(); | |
| await tx.insert(eventAttendees).values({ | |
| eventId: newEvent.id, | |
| userId, | |
| status: 'ACCEPTED', | |
| isOrganizer: true, | |
| respondedAt: new Date(), | |
| }); | |
| if (uniqueAttendeeIds.length > 0) { | |
| await tx.insert(eventAttendees).values( | |
| uniqueAttendeeIds.map(attendeeId => ({ | |
| eventId: newEvent.id, | |
| userId: attendeeId, | |
| status: 'PENDING' as const, | |
| isOrganizer: false, | |
| })) | |
| ); | |
| } | |
| return newEvent; | |
| }); | |
| // Fetch the complete event with relations | |
| const completeEvent = await db.query.calendarEvents.findFirst({ | |
| where: eq(calendarEvents.id, event.id), | |
| with: { | |
| createdBy: { | |
| columns: { id: true, name: true, image: true }, | |
| }, | |
| attendees: { | |
| with: { | |
| user: { | |
| columns: { id: true, name: true, image: true }, | |
| }, | |
| }, | |
| }, | |
| page: { | |
| columns: { id: true, title: true, type: true }, | |
| }, | |
| }, | |
| }); | |
| // Broadcast event creation | |
| await broadcastCalendarEvent({ | |
| eventId: event.id, | |
| driveId: data.driveId ?? null, | |
| operation: 'created', | |
| userId, | |
| attendeeIds: [userId, ...uniqueAttendeeIds], | |
| }); |
🤖 Prompt for AI Agents
In `@apps/web/src/app/api/calendar/events/route.ts` around lines 264 - 336, The
event creation should deduplicate attendeeIds and perform all attendee inserts
atomically to avoid unique constraint failures and partial state: before
inserting into eventAttendees, create a deduped array (remove duplicates and the
creating userId) from data.attendeeIds, then run the creator insert and the
other attendee inserts inside a single transaction (use the existing
db.transaction / db.transaction(async (tx) => ...) or the project's
transactional API) using tx.insert on eventAttendees and tx.insert on
calendarEvents (or insert calendarEvents inside the same transaction), commit
before fetching completeEvent (db.query.calendarEvents.findFirst) and before
calling broadcastCalendarEvent so that broadcasting only happens after a
successful transaction; ensure you still set the creator as organizer
(isOrganizer: true) and add other attendees with status 'PENDING'.
| const startAt = allDay | ||
| ? new Date(startDate.setHours(0, 0, 0, 0)) | ||
| : buildDateTime(startDate, startTime); | ||
| const endAt = allDay | ||
| ? new Date(endDate.setHours(23, 59, 59, 999)) | ||
| : buildDateTime(endDate, endTime); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
State mutation bug in all-day date handling.
startDate.setHours() and endDate.setHours() mutate the Date objects stored in React state directly instead of creating new instances. This violates React's immutability principle and can cause stale closure bugs or missed re-renders.
🐛 Proposed fix to avoid state mutation
const startAt = allDay
- ? new Date(startDate.setHours(0, 0, 0, 0))
+ ? new Date(new Date(startDate).setHours(0, 0, 0, 0))
: buildDateTime(startDate, startTime);
const endAt = allDay
- ? new Date(endDate.setHours(23, 59, 59, 999))
+ ? new Date(new Date(endDate).setHours(23, 59, 59, 999))
: buildDateTime(endDate, endTime);📝 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.
| const startAt = allDay | |
| ? new Date(startDate.setHours(0, 0, 0, 0)) | |
| : buildDateTime(startDate, startTime); | |
| const endAt = allDay | |
| ? new Date(endDate.setHours(23, 59, 59, 999)) | |
| : buildDateTime(endDate, endTime); | |
| const startAt = allDay | |
| ? new Date(new Date(startDate).setHours(0, 0, 0, 0)) | |
| : buildDateTime(startDate, startTime); | |
| const endAt = allDay | |
| ? new Date(new Date(endDate).setHours(23, 59, 59, 999)) | |
| : buildDateTime(endDate, endTime); |
🤖 Prompt for AI Agents
In `@apps/web/src/components/calendar/EventModal.tsx` around lines 161 - 166, The
code mutates React state by calling startDate.setHours(...) and
endDate.setHours(...); instead, clone the Date objects before modifying them so
state isn't mutated. Update the startAt/endAt logic to create new Date instances
from startDate and endDate (e.g., new Date(startDate) / new Date(endDate)) and
call setHours on those clones when allDay is true, or otherwise continue using
buildDateTime(startDate, startTime) and buildDateTime(endDate, endTime); ensure
references in the startAt and endAt expressions (allDay, startDate, endDate,
startTime, endTime, buildDateTime) are used without mutating the original Date
objects.
| import { pgTable, text, timestamp, boolean, pgEnum, index, unique, jsonb } from 'drizzle-orm/pg-core'; | ||
| import { relations } from 'drizzle-orm'; | ||
| import { users } from './auth'; | ||
| import { drives, pages } from './core'; | ||
| import { createId } from '@paralleldrive/cuid2'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check if migration files exist and reference the new calendar tables
echo "=== Listing migration files in packages/db/drizzle/ ==="
ls -la packages/db/drizzle/ 2>/dev/null | head -20
echo -e "\n=== Searching for calendar_events and event_attendees references ==="
rg -l "calendar_events|event_attendees" packages/db/drizzle/ 2>/dev/null || echo "No matches found"
echo -e "\n=== Show content of any recent migration files mentioning calendar ==="
rg -A 5 "calendar_events|event_attendees" packages/db/drizzle/ 2>/dev/null || echo "Pattern not found in migrations"Repository: 2witstudios/PageSpace
Length of output: 1718
🏁 Script executed:
#!/bin/bash
# Check the content of calendar.ts to confirm table definitions
echo "=== Checking calendar.ts for table definitions ==="
rg -n "calendar_events|event_attendees|pgTable" packages/db/src/schema/calendar.ts | head -20Repository: 2witstudios/PageSpace
Length of output: 1424
Generate migrations for the new calendar tables using pnpm db:generate.
The schema defines calendarEvents and eventAttendees tables, but corresponding migrations are missing from packages/db/drizzle/. Run the migration generation command to create the SQL migrations for these tables.
🤖 Prompt for AI Agents
In `@packages/db/src/schema/calendar.ts` around lines 1 - 5, The new tables
calendarEvents and eventAttendees are defined in the schema (symbols:
calendarEvents, eventAttendees in packages/db/src/schema/calendar.ts) but no SQL
migrations were generated; run the migration generator (pnpm db:generate) from
the repo root so Drizzle outputs the corresponding migration SQL files into
packages/db/drizzle/, verify the generated migration includes the table
definitions and any indexes/enums used, and add/commit those migration files to
the PR so the DB schema and migrations remain in sync.
Summary
This PR introduces a complete calendar event management system with REST API endpoints and React UI components. Users can create, read, update, and delete calendar events with support for attendees, RSVP management, recurrence rules, and real-time WebSocket broadcasting.
Key Changes
API Endpoints
POST /api/calendar/events- Create new calendar events with optional attendees and recurrence rulesGET /api/calendar/events- List events with filtering by date range and context (user/drive)GET /api/calendar/events/[eventId]- Fetch individual event details with full relationsPATCH /api/calendar/events/[eventId]- Update event details (creator only)DELETE /api/calendar/events/[eventId]- Soft delete events (move to trash)GET /api/calendar/events/[eventId]/attendees- List event attendeesPOST /api/calendar/events/[eventId]/attendees- Add attendees to event (creator only)PATCH /api/calendar/events/[eventId]/attendees- Update RSVP status for current userDELETE /api/calendar/events/[eventId]/attendees- Remove attendees from eventUI Components
CalendarView- Main calendar component supporting multiple view modes (month, week, day, agenda)MonthView- Traditional month grid with event indicatorsWeekView- Week-based timeline view with hourly slotsDayView- Detailed day view with time-based event placementAgendaView- List-based view grouped by dateEventModal- Create/edit event dialog with form validationEventDetailsModal- View event details and manage RSVPPages
/dashboard/calendar- User's personal calendar view/dashboard/[driveId]/calendar- Drive-specific calendar viewNotable Implementation Details
https://claude.ai/code/session_014UwUdTDf7hSk3nuC1rTnBU
Summary by CodeRabbit
Release Notes