diff --git a/backend/alembic/versions/055_add_display_calendar_to_event_invitations.py b/backend/alembic/versions/055_add_display_calendar_to_event_invitations.py
index 9f81672..69753a2 100644
--- a/backend/alembic/versions/055_add_display_calendar_to_event_invitations.py
+++ b/backend/alembic/versions/055_add_display_calendar_to_event_invitations.py
@@ -22,7 +22,7 @@ def upgrade() -> None:
sa.Column(
"display_calendar_id",
sa.Integer(),
- sa.ForeignKey("calendars.id", ondelete="SET NULL"),
+ sa.ForeignKey("calendars.id", ondelete="SET NULL", name="fk_event_invitations_display_calendar_id"),
nullable=True,
),
)
diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py
index 71ee255..9036777 100644
--- a/backend/app/routers/dashboard.py
+++ b/backend/app/routers/dashboard.py
@@ -12,6 +12,7 @@ from app.models.reminder import Reminder
from app.models.project import Project
from app.models.user import User
from app.routers.auth import get_current_user, get_current_settings
+from app.models.event_invitation import EventInvitation
from app.services.calendar_sharing import get_accessible_calendar_ids, get_accessible_event_scope
router = APIRouter()
@@ -54,6 +55,22 @@ async def get_dashboard(
events_result = await db.execute(events_query)
todays_events = events_result.scalars().all()
+ # Build invitation lookup for today's events
+ invited_event_id_set = set(invited_event_ids)
+ today_inv_map: dict[int, tuple[str, int | None]] = {}
+ today_event_ids = [e.id for e in todays_events]
+ parent_ids_in_today = [e.parent_event_id for e in todays_events if e.parent_event_id and e.parent_event_id in invited_event_id_set]
+ inv_lookup_ids = list(set(today_event_ids + parent_ids_in_today) & invited_event_id_set)
+ if inv_lookup_ids:
+ inv_result = await db.execute(
+ select(EventInvitation.event_id, EventInvitation.status, EventInvitation.display_calendar_id).where(
+ EventInvitation.user_id == current_user.id,
+ EventInvitation.event_id.in_(inv_lookup_ids),
+ )
+ )
+ for eid, status, disp_cal_id in inv_result.all():
+ today_inv_map[eid] = (status, disp_cal_id)
+
# Upcoming todos (not completed, with due date from today through upcoming_days)
todos_query = select(Todo).where(
Todo.user_id == current_user.id,
@@ -129,7 +146,10 @@ async def get_dashboard(
"end_datetime": event.end_datetime,
"all_day": event.all_day,
"color": event.color,
- "is_starred": event.is_starred
+ "is_starred": event.is_starred,
+ "is_invited": (event.parent_event_id or event.id) in invited_event_id_set,
+ "invitation_status": today_inv_map.get(event.parent_event_id or event.id, (None,))[0],
+ "display_calendar_id": today_inv_map.get(event.parent_event_id or event.id, (None, None))[1],
}
for event in todays_events
],
@@ -218,6 +238,20 @@ async def get_upcoming(
reminders_result = await db.execute(reminders_query)
reminders = reminders_result.scalars().all()
+ # Build invitation lookup for upcoming events
+ invited_event_id_set_up = set(invited_event_ids)
+ upcoming_inv_map: dict[int, tuple[str, int | None]] = {}
+ up_parent_ids = list({e.parent_event_id or e.id for e in events} & invited_event_id_set_up)
+ if up_parent_ids:
+ up_inv_result = await db.execute(
+ select(EventInvitation.event_id, EventInvitation.status, EventInvitation.display_calendar_id).where(
+ EventInvitation.user_id == current_user.id,
+ EventInvitation.event_id.in_(up_parent_ids),
+ )
+ )
+ for eid, status, disp_cal_id in up_inv_result.all():
+ upcoming_inv_map[eid] = (status, disp_cal_id)
+
# Combine into unified list
upcoming_items: List[Dict[str, Any]] = []
@@ -235,6 +269,8 @@ async def get_upcoming(
for event in events:
end_dt = event.end_datetime
+ parent_id = event.parent_event_id or event.id
+ is_inv = parent_id in invited_event_id_set_up
upcoming_items.append({
"type": "event",
"id": event.id,
@@ -245,6 +281,9 @@ async def get_upcoming(
"all_day": event.all_day,
"color": event.color,
"is_starred": event.is_starred,
+ "is_invited": is_inv,
+ "invitation_status": upcoming_inv_map.get(parent_id, (None,))[0] if is_inv else None,
+ "display_calendar_id": upcoming_inv_map.get(parent_id, (None, None))[1] if is_inv else None,
})
for reminder in reminders:
diff --git a/backend/app/services/calendar_sharing.py b/backend/app/services/calendar_sharing.py
index 20bdd03..fd446fb 100644
--- a/backend/app/services/calendar_sharing.py
+++ b/backend/app/services/calendar_sharing.py
@@ -7,7 +7,7 @@ import logging
from datetime import datetime, timedelta
from fastapi import HTTPException
-from sqlalchemy import delete, select, text, update
+from sqlalchemy import delete, literal_column, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.calendar import Calendar
@@ -38,15 +38,38 @@ async def get_accessible_event_scope(
user_id: int, db: AsyncSession
) -> tuple[list[int], list[int]]:
"""
- Returns (calendar_ids, invited_parent_event_ids).
+ Returns (calendar_ids, invited_parent_event_ids) in a single DB round-trip.
calendar_ids: all calendars the user can access (owned + accepted shared).
invited_parent_event_ids: event IDs where the user has a non-declined invitation.
"""
- from app.services.event_invitation import get_invited_event_ids
+ from app.models.event_invitation import EventInvitation
- cal_ids = await get_accessible_calendar_ids(user_id, db)
- invited_event_ids = await get_invited_event_ids(db, user_id)
- return cal_ids, invited_event_ids
+ result = await db.execute(
+ select(literal_column("'c'").label("kind"), Calendar.id.label("val"))
+ .where(Calendar.user_id == user_id)
+ .union_all(
+ select(literal_column("'c'"), CalendarMember.calendar_id)
+ .where(
+ CalendarMember.user_id == user_id,
+ CalendarMember.status == "accepted",
+ )
+ )
+ .union_all(
+ select(literal_column("'i'"), EventInvitation.event_id)
+ .where(
+ EventInvitation.user_id == user_id,
+ EventInvitation.status != "declined",
+ )
+ )
+ )
+ cal_ids: list[int] = []
+ inv_ids: list[int] = []
+ for kind, val in result.all():
+ if kind == "c":
+ cal_ids.append(val)
+ else:
+ inv_ids.append(val)
+ return cal_ids, inv_ids
async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int) -> str | None:
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index fb98f21..85d709c 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -111,13 +111,6 @@ server {
include /etc/nginx/proxy-params.conf;
}
- # Event invite — rate-limited to prevent invite spam (reuse cal_invite_limit zone)
- location ~ /api/events/\d+/invitations$ {
- limit_req zone=cal_invite_limit burst=3 nodelay;
- limit_req_status 429;
- include /etc/nginx/proxy-params.conf;
- }
-
# Calendar sync — rate-limited to prevent excessive polling
location /api/shared-calendars/sync {
limit_req zone=cal_sync_limit burst=5 nodelay;
diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx
index 4817b38..4d34c47 100644
--- a/frontend/src/components/calendar/EventDetailPanel.tsx
+++ b/frontend/src/components/calendar/EventDetailPanel.tsx
@@ -237,6 +237,7 @@ export default function EventDetailPanel({
.filter((m) => m.permission === 'create_modify' || m.permission === 'full_access')
.map((m) => ({ id: m.calendar_id, name: m.calendar_name, color: m.local_color || m.calendar_color, is_default: false })),
];
+ const ownedCalendars = calendars.filter((c) => !c.is_system);
const defaultCalendar = calendars.find((c) => c.is_default);
const { data: locations = [] } = useQuery({
@@ -957,7 +958,7 @@ export default function EventDetailPanel({
{!event?.display_calendar_id && (
)}
- {selectableCalendars.map((cal) => (
+ {ownedCalendars.map((cal) => (
))}
@@ -1106,10 +1107,10 @@ export default function EventDetailPanel({
open={showLeaveDialog}
onClose={() => setShowLeaveDialog(false)}
onConfirm={() => {
- leaveInvitation(myInvitationId).then(() => {
- setShowLeaveDialog(false);
- onClose();
- });
+ leaveInvitation(myInvitationId)
+ .then(() => onClose())
+ .catch(() => {})
+ .finally(() => setShowLeaveDialog(false));
}}
eventTitle={event.title}
isRecurring={!!(event.is_recurring || event.parent_event_id)}
diff --git a/frontend/src/components/calendar/InviteeSection.tsx b/frontend/src/components/calendar/InviteeSection.tsx
index 73dfa44..e0223df 100644
--- a/frontend/src/components/calendar/InviteeSection.tsx
+++ b/frontend/src/components/calendar/InviteeSection.tsx
@@ -131,6 +131,7 @@ export function InviteSearch({ connections, existingInviteeIds, onInvite, isInvi
setSearch(e.target.value)}
+ onBlur={() => setTimeout(() => setSearch(''), 150)}
placeholder="Search connections..."
className="h-8 pl-8 text-xs"
/>
diff --git a/frontend/src/components/notifications/NotificationToaster.tsx b/frontend/src/components/notifications/NotificationToaster.tsx
index 8279b3f..64e9300 100644
--- a/frontend/src/components/notifications/NotificationToaster.tsx
+++ b/frontend/src/components/notifications/NotificationToaster.tsx
@@ -18,7 +18,7 @@ export default function NotificationToaster() {
const initializedRef = useRef(false);
const prevUnreadRef = useRef(0);
// Track in-flight request IDs so repeated clicks are blocked
- const respondingRef = useRef>(new Set());
+ const respondingRef = useRef>(new Set());
// Always call the latest respond — Sonner toasts capture closures at creation time
const respondInviteRef = useRef(respondInvite);
const respondRef = useRef(respond);
@@ -30,8 +30,9 @@ export default function NotificationToaster() {
const handleConnectionRespond = useCallback(
async (requestId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => {
// Guard against double-clicks (Sonner toasts are static, no disabled prop)
- if (respondingRef.current.has(requestId)) return;
- respondingRef.current.add(requestId);
+ const key = `conn-${requestId}`;
+ if (respondingRef.current.has(key)) return;
+ respondingRef.current.add(key);
// Immediately dismiss the custom toast and show a loading indicator
toast.dismiss(toastId);
@@ -54,7 +55,7 @@ export default function NotificationToaster() {
toast.error(getErrorMessage(err, 'Failed to respond to request'));
}
} finally {
- respondingRef.current.delete(requestId);
+ respondingRef.current.delete(key);
}
},
[],
@@ -63,8 +64,9 @@ export default function NotificationToaster() {
const handleCalendarInviteRespond = useCallback(
async (inviteId: number, action: 'accept' | 'reject', toastId: string | number, notificationId: number) => {
- if (respondingRef.current.has(inviteId + 100000)) return;
- respondingRef.current.add(inviteId + 100000);
+ const key = `cal-${inviteId}`;
+ if (respondingRef.current.has(key)) return;
+ respondingRef.current.add(key);
toast.dismiss(toastId);
const loadingId = toast.loading(
@@ -83,15 +85,16 @@ export default function NotificationToaster() {
toast.error(getErrorMessage(err, 'Failed to respond to invite'));
}
} finally {
- respondingRef.current.delete(inviteId + 100000);
+ respondingRef.current.delete(key);
}
},
[],
);
const handleEventInviteRespond = useCallback(
async (invitationId: number, status: 'accepted' | 'tentative' | 'declined', toastId: string | number, notificationId: number) => {
- if (respondingRef.current.has(invitationId + 200000)) return;
- respondingRef.current.add(invitationId + 200000);
+ const key = `event-${invitationId}`;
+ if (respondingRef.current.has(key)) return;
+ respondingRef.current.add(key);
toast.dismiss(toastId);
const statusLabel = { accepted: 'Accepting', tentative: 'Setting tentative', declined: 'Declining' };
@@ -114,7 +117,7 @@ export default function NotificationToaster() {
toast.error(getErrorMessage(err, 'Failed to respond'));
}
} finally {
- respondingRef.current.delete(invitationId + 200000);
+ respondingRef.current.delete(key);
}
},
[],