Compare commits

..

No commits in common. "348fe8988b98a6c21c453253bd93ac99835f3ed7" and "99f70f3a41b797be4b1ff5b96b7cffa498a14f4f" have entirely different histories.

7 changed files with 69 additions and 81 deletions

View File

@ -8,11 +8,11 @@ from app.database import get_db
from app.models.settings import Settings from app.models.settings import Settings
from app.models.todo import Todo from app.models.todo import Todo
from app.models.calendar_event import CalendarEvent from app.models.calendar_event import CalendarEvent
from app.models.calendar import Calendar
from app.models.reminder import Reminder from app.models.reminder import Reminder
from app.models.project import Project from app.models.project import Project
from app.models.user import User from app.models.user import User
from app.routers.auth import get_current_user, get_current_settings from app.routers.auth import get_current_user, get_current_settings
from app.services.calendar_sharing import get_accessible_calendar_ids
router = APIRouter() router = APIRouter()
@ -35,8 +35,12 @@ async def get_dashboard(
today = client_date or date.today() today = client_date or date.today()
upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days) upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days)
# Fetch all accessible calendar IDs (owned + accepted shared memberships) # Fetch calendar IDs once as a plain list — avoids repeating the subquery
user_calendar_ids = await get_accessible_calendar_ids(current_user.id, db) # expression in each of the 3+ queries below and makes intent clearer.
calendar_id_result = await db.execute(
select(Calendar.id).where(Calendar.user_id == current_user.id)
)
user_calendar_ids = [row[0] for row in calendar_id_result.all()]
# Today's events (exclude parent templates — they are hidden, children are shown) # Today's events (exclude parent templates — they are hidden, children are shown)
today_start = datetime.combine(today, datetime.min.time()) today_start = datetime.combine(today, datetime.min.time())
@ -71,15 +75,18 @@ async def get_dashboard(
reminders_result = await db.execute(reminders_query) reminders_result = await db.execute(reminders_query)
active_reminders = reminders_result.scalars().all() active_reminders = reminders_result.scalars().all()
# Project stats — single GROUP BY query, derive total in Python # Project stats (scoped to user)
projects_by_status_result = await db.execute( total_projects_result = await db.execute(
select( select(func.count(Project.id)).where(Project.user_id == current_user.id)
Project.status,
func.count(Project.id).label("count"),
).where(Project.user_id == current_user.id).group_by(Project.status)
) )
total_projects = total_projects_result.scalar()
projects_by_status_query = select(
Project.status,
func.count(Project.id).label("count")
).where(Project.user_id == current_user.id).group_by(Project.status)
projects_by_status_result = await db.execute(projects_by_status_query)
projects_by_status = {row[0]: row[1] for row in projects_by_status_result} projects_by_status = {row[0]: row[1] for row in projects_by_status_result}
total_projects = sum(projects_by_status.values())
# Todo counts: total and incomplete in a single query # Todo counts: total and incomplete in a single query
todo_counts_result = await db.execute( todo_counts_result = await db.execute(
@ -170,8 +177,11 @@ async def get_upcoming(
overdue_floor = today - timedelta(days=30) overdue_floor = today - timedelta(days=30)
overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time()) overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time())
# Fetch all accessible calendar IDs (owned + accepted shared memberships) # Fetch calendar IDs once as a plain list (same rationale as /dashboard handler)
user_calendar_ids = await get_accessible_calendar_ids(current_user.id, db) calendar_id_result = await db.execute(
select(Calendar.id).where(Calendar.user_id == current_user.id)
)
user_calendar_ids = [row[0] for row in calendar_id_result.all()]
# Build queries — include overdue todos (up to 30 days back) and snoozed reminders # Build queries — include overdue todos (up to 30 days back) and snoozed reminders
todos_query = select(Todo).where( todos_query = select(Todo).where(

View File

@ -18,8 +18,9 @@ from app.schemas.calendar_event import (
) )
from app.routers.auth import get_current_user from app.routers.auth import get_current_user
from app.models.user import User from app.models.user import User
from app.models.calendar_member import CalendarMember
from app.services.recurrence import generate_occurrences from app.services.recurrence import generate_occurrences
from app.services.calendar_sharing import check_lock_for_edit, get_accessible_calendar_ids, require_permission from app.services.calendar_sharing import check_lock_for_edit, require_permission
router = APIRouter() router = APIRouter()
@ -144,7 +145,12 @@ async def get_events(
are what get displayed on the calendar. are what get displayed on the calendar.
""" """
# Scope events through calendar ownership + shared memberships # Scope events through calendar ownership + shared memberships
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db) user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
shared_calendar_ids = select(CalendarMember.calendar_id).where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
all_calendar_ids = user_calendar_ids.union(shared_calendar_ids)
query = ( query = (
select(CalendarEvent) select(CalendarEvent)
@ -166,7 +172,7 @@ async def get_events(
if end: if end:
query = query.where(CalendarEvent.start_datetime <= end) query = query.where(CalendarEvent.start_datetime <= end)
query = query.order_by(CalendarEvent.start_datetime.asc()).limit(2000) query = query.order_by(CalendarEvent.start_datetime.asc())
result = await db.execute(query) result = await db.execute(query)
events = result.scalars().all() events = result.scalars().all()
@ -240,7 +246,8 @@ async def create_event(
await db.flush() # assign parent.id before generating children await db.flush() # assign parent.id before generating children
children = generate_occurrences(parent) children = generate_occurrences(parent)
db.add_all(children) for child in children:
db.add(child)
await db.commit() await db.commit()
@ -281,7 +288,12 @@ async def get_event(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db) user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
shared_calendar_ids = select(CalendarMember.calendar_id).where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
all_calendar_ids = user_calendar_ids.union(shared_calendar_ids)
result = await db.execute( result = await db.execute(
select(CalendarEvent) select(CalendarEvent)
@ -306,7 +318,12 @@ async def update_event(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db) user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
shared_calendar_ids = select(CalendarMember.calendar_id).where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
all_calendar_ids = user_calendar_ids.union(shared_calendar_ids)
result = await db.execute( result = await db.execute(
select(CalendarEvent) select(CalendarEvent)
@ -405,7 +422,8 @@ async def update_event(
if event.recurrence_rule: if event.recurrence_rule:
await db.flush() await db.flush()
children = generate_occurrences(event) children = generate_occurrences(event)
db.add_all(children) for child in children:
db.add(child)
else: else:
# This IS a parent — update it and regenerate all children # This IS a parent — update it and regenerate all children
for key, value in update_data.items(): for key, value in update_data.items():
@ -421,7 +439,8 @@ async def update_event(
) )
await db.flush() await db.flush()
children = generate_occurrences(event) children = generate_occurrences(event)
db.add_all(children) for child in children:
db.add(child)
await db.commit() await db.commit()
@ -451,7 +470,12 @@ async def delete_event(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
all_calendar_ids = await get_accessible_calendar_ids(current_user.id, db) user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
shared_calendar_ids = select(CalendarMember.calendar_id).where(
CalendarMember.user_id == current_user.id,
CalendarMember.status == "accepted",
)
all_calendar_ids = user_calendar_ids.union(shared_calendar_ids)
result = await db.execute( result = await db.execute(
select(CalendarEvent).where( select(CalendarEvent).where(
@ -477,13 +501,20 @@ async def delete_event(
this_original_start = event.original_start or event.start_datetime this_original_start = event.original_start or event.start_datetime
if parent_id is not None: if parent_id is not None:
# Delete this + all future siblings (original_start is always set on children) # Delete this + all future siblings
await db.execute( await db.execute(
delete(CalendarEvent).where( delete(CalendarEvent).where(
CalendarEvent.parent_event_id == parent_id, CalendarEvent.parent_event_id == parent_id,
CalendarEvent.original_start >= this_original_start, CalendarEvent.original_start >= this_original_start,
) )
) )
# Ensure the target event itself is deleted (edge case: original_start fallback mismatch)
existing = await db.execute(
select(CalendarEvent).where(CalendarEvent.id == event_id)
)
target = existing.scalar_one_or_none()
if target:
await db.delete(target)
else: else:
# This event IS the parent — delete it and all children (CASCADE handles children) # This event IS the parent — delete it and all children (CASCADE handles children)
await db.delete(event) await db.delete(event)

View File

@ -1,6 +1,6 @@
import json as _json import json as _json
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
from datetime import datetime from datetime import datetime
from typing import Literal, Optional from typing import Literal, Optional
@ -17,20 +17,6 @@ class RecurrenceRule(BaseModel):
# monthly_date # monthly_date
day: Optional[int] = Field(None, ge=1, le=31) day: Optional[int] = Field(None, ge=1, le=31)
@model_validator(mode="after")
def validate_required_fields(self):
"""Enforce required fields per rule type."""
if self.type == "every_n_days" and self.interval is None:
raise ValueError("every_n_days rule requires 'interval'")
if self.type == "weekly" and self.weekday is None:
raise ValueError("weekly rule requires 'weekday'")
if self.type == "monthly_nth_weekday":
if self.week is None or self.weekday is None:
raise ValueError("monthly_nth_weekday rule requires both 'week' and 'weekday'")
if self.type == "monthly_date" and self.day is None:
raise ValueError("monthly_date rule requires 'day'")
return self
def _coerce_recurrence_rule(v): def _coerce_recurrence_rule(v):
"""Accept None, dict, RecurrenceRule, or JSON/legacy strings gracefully.""" """Accept None, dict, RecurrenceRule, or JSON/legacy strings gracefully."""
@ -61,10 +47,10 @@ class CalendarEventCreate(BaseModel):
end_datetime: datetime end_datetime: datetime
all_day: bool = False all_day: bool = False
color: Optional[str] = Field(None, max_length=20) color: Optional[str] = Field(None, max_length=20)
location_id: Optional[int] = Field(None, ge=1, le=2147483647) location_id: Optional[int] = None
recurrence_rule: Optional[RecurrenceRule] = None recurrence_rule: Optional[RecurrenceRule] = None
is_starred: bool = False is_starred: bool = False
calendar_id: Optional[int] = Field(None, ge=1, le=2147483647) calendar_id: Optional[int] = None # If None, server assigns default calendar
@field_validator("recurrence_rule", mode="before") @field_validator("recurrence_rule", mode="before")
@classmethod @classmethod
@ -81,10 +67,10 @@ class CalendarEventUpdate(BaseModel):
end_datetime: Optional[datetime] = None end_datetime: Optional[datetime] = None
all_day: Optional[bool] = None all_day: Optional[bool] = None
color: Optional[str] = Field(None, max_length=20) color: Optional[str] = Field(None, max_length=20)
location_id: Optional[int] = Field(None, ge=1, le=2147483647) location_id: Optional[int] = None
recurrence_rule: Optional[RecurrenceRule] = None recurrence_rule: Optional[RecurrenceRule] = None
is_starred: Optional[bool] = None is_starred: Optional[bool] = None
calendar_id: Optional[int] = Field(None, ge=1, le=2147483647) calendar_id: Optional[int] = None
# Controls which occurrences an edit applies to; absent = non-recurring or whole-series # Controls which occurrences an edit applies to; absent = non-recurring or whole-series
edit_scope: Optional[Literal["this", "this_and_future"]] = None edit_scope: Optional[Literal["this", "this_and_future"]] = None

View File

@ -20,20 +20,6 @@ PERMISSION_RANK = {"read_only": 1, "create_modify": 2, "full_access": 3}
LOCK_DURATION_MINUTES = 5 LOCK_DURATION_MINUTES = 5
async def get_accessible_calendar_ids(user_id: int, db: AsyncSession) -> list[int]:
"""Return all calendar IDs the user can access (owned + accepted shared memberships)."""
result = await db.execute(
select(Calendar.id).where(Calendar.user_id == user_id)
.union(
select(CalendarMember.calendar_id).where(
CalendarMember.user_id == user_id,
CalendarMember.status == "accepted",
)
)
)
return [r[0] for r in result.all()]
async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int) -> str | None: async def get_user_permission(db: AsyncSession, calendar_id: int, user_id: int) -> str | None:
""" """
Returns "owner" if the user owns the calendar, the permission string Returns "owner" if the user owns the calendar, the permission string

View File

@ -10,9 +10,6 @@ from typing import Optional
from app.models.calendar_event import CalendarEvent from app.models.calendar_event import CalendarEvent
# Hard cap: never generate more than 730 child events regardless of horizon_days.
MAX_OCCURRENCES = 730
def _nth_weekday_of_month(year: int, month: int, weekday: int, week: int) -> Optional[datetime]: def _nth_weekday_of_month(year: int, month: int, weekday: int, week: int) -> Optional[datetime]:
""" """
@ -102,12 +99,8 @@ def generate_occurrences(
interval: int = _rule_int(rule, "interval", 1) interval: int = _rule_int(rule, "interval", 1)
if interval < 1: if interval < 1:
interval = 1 interval = 1
# Adaptive horizon: cap daily-ish events (interval < 7) to 90 days
effective_horizon = horizon if interval >= 7 else min(horizon, parent_start + timedelta(days=90))
current = parent_start + timedelta(days=interval) current = parent_start + timedelta(days=interval)
while current < effective_horizon: while current < horizon:
if len(occurrences) >= MAX_OCCURRENCES:
break
occurrences.append(_make_child(current)) occurrences.append(_make_child(current))
current += timedelta(days=interval) current += timedelta(days=interval)
@ -119,8 +112,6 @@ def generate_occurrences(
days_ahead = 7 days_ahead = 7
current = parent_start + timedelta(days=days_ahead) current = parent_start + timedelta(days=days_ahead)
while current < horizon: while current < horizon:
if len(occurrences) >= MAX_OCCURRENCES:
break
occurrences.append(_make_child(current)) occurrences.append(_make_child(current))
current += timedelta(weeks=1) current += timedelta(weeks=1)
@ -135,8 +126,6 @@ def generate_occurrences(
month = 1 month = 1
year += 1 year += 1
while True: while True:
if len(occurrences) >= MAX_OCCURRENCES:
break
target = _nth_weekday_of_month(year, month, weekday, week) target = _nth_weekday_of_month(year, month, weekday, week)
if target is None: if target is None:
# Skip months where Nth weekday doesn't exist # Skip months where Nth weekday doesn't exist
@ -168,8 +157,6 @@ def generate_occurrences(
month = 1 month = 1
year += 1 year += 1
while True: while True:
if len(occurrences) >= MAX_OCCURRENCES:
break
# Some months don't have day 29-31 # Some months don't have day 29-31
try: try:
occ_start = datetime( occ_start = datetime(

View File

@ -10,8 +10,6 @@ limit_req_zone $binary_remote_addr zone=cal_sync_limit:10m rate=15r/m;
# Connection endpoints prevent search enumeration and request spam # Connection endpoints prevent search enumeration and request spam
limit_req_zone $binary_remote_addr zone=conn_search_limit:10m rate=10r/m; limit_req_zone $binary_remote_addr zone=conn_search_limit:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=conn_request_limit:10m rate=3r/m; limit_req_zone $binary_remote_addr zone=conn_request_limit:10m rate=3r/m;
# Event creation recurrence amplification means 1 POST = up to 90-365 child rows
limit_req_zone $binary_remote_addr zone=event_create_limit:10m rate=30r/m;
# Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access # Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access
map $http_x_forwarded_proto $forwarded_proto { map $http_x_forwarded_proto $forwarded_proto {
@ -124,15 +122,6 @@ server {
include /etc/nginx/proxy-params.conf; include /etc/nginx/proxy-params.conf;
} }
# Event creation rate-limited to prevent DB flooding via recurrence amplification.
# Note: exact match applies to GET+POST; 30r/m with burst=10 is generous enough
# for polling (2r/m) and won't affect reads even with multiple tabs.
location = /api/events {
limit_req zone=event_create_limit burst=10 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# API proxy (catch-all for non-rate-limited endpoints) # API proxy (catch-all for non-rate-limited endpoints)
location /api { location /api {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;

View File

@ -231,7 +231,6 @@ export default function CalendarPage() {
}, },
// AW-3: Reduce from 5s to 30s — personal organiser doesn't need 12 calls/min // AW-3: Reduce from 5s to 30s — personal organiser doesn't need 12 calls/min
refetchInterval: 30_000, refetchInterval: 30_000,
staleTime: 30_000,
}); });
const selectedEvent = useMemo( const selectedEvent = useMemo(