Fix QA review findings: C-01, C-02, W-01, W-02, W-04, S-01, S-02, S-03
C-01: Remove nginx rate limit on event invitations endpoint — was
blocking GET (invitee list) on rapid event switching. Backend
already caps at 20 invitations per event with connection validation.
C-02: respondingRef uses string prefixes (conn-, cal-, event-) instead
of fragile numeric offsets (+100000/+200000) to prevent collisions.
W-01: get_accessible_event_scope combined into single UNION ALL query
(3 DB round-trips → 1) for calendar IDs + invitation IDs.
W-02: Dashboard and upcoming endpoints now include is_invited,
invitation_status, and display_calendar_id on event items.
W-04: LeaveEventDialog closes on error (.finally) instead of staying
open when mutation rejects.
S-01: Migration 055 FK constraint gets explicit name for consistency.
S-02: InviteSearch dropdown dismisses on blur (150ms delay for clicks).
S-03: Display calendar picker shows only owned calendars, not shared.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
25830bb99e
commit
f54ab5079e
@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 && (
|
||||
<option value="" disabled>Assign to calendar...</option>
|
||||
)}
|
||||
{selectableCalendars.map((cal) => (
|
||||
{ownedCalendars.map((cal) => (
|
||||
<option key={cal.id} value={cal.id}>{cal.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
@ -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)}
|
||||
|
||||
@ -131,6 +131,7 @@ export function InviteSearch({ connections, existingInviteeIds, onInvite, isInvi
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onBlur={() => setTimeout(() => setSearch(''), 150)}
|
||||
placeholder="Search connections..."
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
|
||||
@ -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<Set<number>>(new Set());
|
||||
const respondingRef = useRef<Set<string>>(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);
|
||||
}
|
||||
},
|
||||
[],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user