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); } }, [],