Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ celerybeat.pid

# Environments
.env
.env.*
.envrc
.venv
env/
Expand Down Expand Up @@ -239,5 +240,8 @@ backend/src/services/linear/api/resolvers.py.backup
backend/FILES_CREATED.md
third_party/
slack_credentials.md
.env
.env.local
local_experiments/
.claude/settings.local.json
.claude/**
!.claude/CLAUDE.md
!.claude/settings.json
81 changes: 73 additions & 8 deletions backend/src/services/calendar/api/methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import json
import logging
from datetime import timezone
from typing import Any, Callable, Awaitable, Optional
from functools import wraps

Expand Down Expand Up @@ -105,9 +106,73 @@
generate_resource_id,
etags_match,
REPLICA_NOW_RFC3339,
parse_rfc3339,
)


# ============================================================================
# WATCH EXPIRATION PARSING
# ============================================================================


def parse_watch_expiration(expiration: Any) -> Optional[int]:
"""
Parse expiration value for watch channels.

Google Calendar API accepts expiration as milliseconds since Unix epoch.
Some clients may send ISO date strings which need to be converted.

Args:
expiration: The expiration value (int, string int, or ISO date string)

Returns:
Expiration in milliseconds since epoch, or None if not provided

Raises:
ValidationError: If the expiration format is invalid
"""
if expiration is None:
return None

# If already an int, return as-is
if isinstance(expiration, int):
return expiration
Comment on lines +137 to +139
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n backend/src/services/calendar/api/methods.py | sed -n '120,160p'

Repository: hubert-marek/agent-diff

Length of output: 1592


🏁 Script executed:

cat -n backend/src/services/calendar/api/methods.py | sed -n '160,180p'

Repository: hubert-marek/agent-diff

Length of output: 838


Reject boolean expirations explicitly.

bool is a subclass of int in Python, so passing True or False will be accepted and stored as 1 or 0 milliseconds (representing 1970-01-01 00:00:00.001 UTC and Unix epoch respectively). These are not valid expiration times for calendar watch subscriptions. Tighten the isinstance check to reject booleans explicitly so they fall through to the existing ValidationError at line 170.

🔧 Suggested fix
-    if isinstance(expiration, int):
+    if isinstance(expiration, int) and not isinstance(expiration, bool):
         return expiration
🤖 Prompt for AI Agents
In `@backend/src/services/calendar/api/methods.py` around lines 137 - 139, The
current isinstance(expiration, int) check accepts booleans because bool
subclasses int; update the check in the expiration-handling block (the one that
currently reads "if isinstance(expiration, int): return expiration") to
explicitly exclude bools so True/False are not treated as ints (e.g., use "if
isinstance(expiration, int) and not isinstance(expiration, bool): return
expiration" or "if type(expiration) is int: return expiration") so boolean
values fall through to the existing ValidationError path around line 170.


# If it's a string, try to parse it
if isinstance(expiration, str):
expiration = expiration.strip()
if not expiration:
return None

# Try parsing as integer (milliseconds)
try:
return int(expiration)
except ValueError:
pass

# Try parsing as ISO date string
try:
dt = parse_rfc3339(expiration)
# If datetime is naive, assume UTC
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
# Convert to milliseconds since epoch
return int(dt.timestamp() * 1000)
except (ValueError, AttributeError):
pass

# Invalid format
raise ValidationError(
f"Invalid expiration format: {expiration}. "
"Expected milliseconds since epoch or ISO 8601 date string."
)

raise ValidationError(
f"Invalid expiration type: {type(expiration).__name__}. "
"Expected integer or string."
)


# ============================================================================
# REQUEST UTILITIES
# ============================================================================
Expand Down Expand Up @@ -1036,15 +1101,15 @@ async def calendar_list_watch(request: Request) -> JSONResponse:
from ..database.schema import Channel

resource_id = generate_resource_id()
expiration = body.get("expiration")
expiration_ms = parse_watch_expiration(body.get("expiration"))

channel = Channel(
id=channel_id,
resource_id=resource_id,
resource_uri=f"/users/me/calendarList",
type=channel_type,
address=address,
expiration=int(expiration) if expiration else None,
expiration=expiration_ms,
token=body.get("token"),
params=body.get("params"),
payload=body.get("payload", False),
Expand Down Expand Up @@ -1961,15 +2026,15 @@ async def events_watch(request: Request) -> JSONResponse:
from ..database.schema import Channel

resource_id = generate_resource_id()
expiration = body.get("expiration")
expiration_ms = parse_watch_expiration(body.get("expiration"))

channel = Channel(
id=channel_id,
resource_id=resource_id,
resource_uri=f"/calendars/{calendar_id}/events",
type=channel_type,
address=address,
expiration=int(expiration) if expiration else None,
expiration=expiration_ms,
token=body.get("token"),
params=body.get("params"),
payload=body.get("payload", False),
Expand Down Expand Up @@ -2400,15 +2465,15 @@ async def acl_watch(request: Request) -> JSONResponse:
from ..database.schema import Channel

resource_id = generate_resource_id()
expiration = body.get("expiration")
expiration_ms = parse_watch_expiration(body.get("expiration"))

channel = Channel(
id=channel_id,
resource_id=resource_id,
resource_uri=f"/calendars/{calendar_id}/acl",
type=channel_type,
address=address,
expiration=int(expiration) if expiration else None,
expiration=expiration_ms,
token=body.get("token"),
user_id=user_id, # Track ownership
params=body.get("params"),
Expand Down Expand Up @@ -2681,15 +2746,15 @@ async def settings_watch(request: Request) -> JSONResponse:
from ..database.schema import Channel

resource_id = generate_resource_id()
expiration = body.get("expiration")
expiration_ms = parse_watch_expiration(body.get("expiration"))

channel = Channel(
id=channel_id,
resource_id=resource_id,
resource_uri=f"/users/{user_id}/settings",
type=channel_type,
address=address,
expiration=int(expiration) if expiration else None,
expiration=expiration_ms,
token=body.get("token"),
params=body.get("params"),
payload=body.get("payload", False),
Expand Down
59 changes: 44 additions & 15 deletions backend/src/services/calendar/database/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,13 @@ def create_event(
start_date = start.get("date")
end_date = end.get("date")

# Extract organizer/creator fields from kwargs to allow override (e.g., for import)
# Use provided values if present, otherwise default to current user
# This matches Google Calendar API behavior where imports preserve original organizer
organizer_email = kwargs.pop("organizer_email", None) or user.email
organizer_display_name = kwargs.pop("organizer_display_name", None) or user.display_name
organizer_self = organizer_email == user.email

event = Event(
id=event_id,
calendar_id=calendar.id,
Expand All @@ -623,17 +630,20 @@ def create_event(
creator_display_name=user.display_name,
creator_self=True,
organizer_id=user_id,
organizer_email=user.email,
organizer_display_name=user.display_name,
organizer_self=True,
organizer_email=organizer_email,
organizer_display_name=organizer_display_name,
organizer_self=organizer_self,
etag=generate_etag(f"{event_id}:1"),
**{k: v for k, v in kwargs.items() if hasattr(Event, k)},
)
session.add(event)

# Add attendees
if attendees:
for attendee_data in attendees:
for idx, attendee_data in enumerate(attendees):
# Validate email is required for attendees (per Google Calendar API)
if "email" not in attendee_data or not attendee_data["email"]:
raise RequiredFieldError(f"attendees[{idx}].email")
attendee = EventAttendee(
event_id=event_id,
email=attendee_data["email"],
Expand Down Expand Up @@ -1030,13 +1040,21 @@ def list_events(

all_events.append(instance)

# Helper to normalize datetime to timezone-aware for comparison
def _normalize_dt(dt: Optional[datetime]) -> datetime:
if dt is None:
return datetime.min.replace(tzinfo=timezone.utc)
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt

# Sort combined results
if order_by == "startTime":
all_events.sort(key=lambda e: (e.start_datetime or datetime.min.replace(tzinfo=timezone.utc), e.id))
all_events.sort(key=lambda e: (_normalize_dt(e.start_datetime), e.id))
elif order_by == "updated":
all_events.sort(key=lambda e: (e.updated_at or datetime.min.replace(tzinfo=timezone.utc), e.id), reverse=True)
all_events.sort(key=lambda e: (_normalize_dt(e.updated_at), e.id), reverse=True)
else:
all_events.sort(key=lambda e: (e.start_datetime or datetime.min.replace(tzinfo=timezone.utc), e.id))
all_events.sort(key=lambda e: (_normalize_dt(e.start_datetime), e.id))

# Apply pagination to combined results (offset already decoded above)
paginated_events = all_events[offset:offset + max_results + 1]
Expand Down Expand Up @@ -1127,7 +1145,10 @@ def update_event(
)
# Add new attendees
user = session.get(User, user_id)
for attendee_data in kwargs["attendees"]:
for idx, attendee_data in enumerate(kwargs["attendees"]):
# Validate email is required for attendees (per Google Calendar API)
if "email" not in attendee_data or not attendee_data["email"]:
raise RequiredFieldError(f"attendees[{idx}].email")
attendee = EventAttendee(
event_id=event_id,
email=attendee_data["email"],
Expand Down Expand Up @@ -2009,13 +2030,21 @@ def get_event_instances(
all_instances.append(exc)

# Expand recurrence rules to get instance dates
instance_dates = expand_recurrence(
recurrence=master.recurrence,
start=start_dt,
time_min=min_dt,
time_max=max_dt,
max_instances=max_results,
)
try:
instance_dates = expand_recurrence(
recurrence=master.recurrence,
start=start_dt,
time_min=min_dt,
time_max=max_dt,
max_instances=max_results,
)
except Exception as e:
# Log and return empty if recurrence expansion fails
# Keep broad exception to maintain graceful degradation (matching Google's behavior)
logger.warning(
"Failed to expand recurrence for event %s in get_instances: %s", master.id, e
)
return [], None, None

# Calculate event duration
duration = timedelta(hours=1) # Default
Expand Down
5 changes: 3 additions & 2 deletions backend/src/services/calendar/database/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
String,
Text,
Integer,
BigInteger,
Boolean,
DateTime,
ForeignKey,
Expand Down Expand Up @@ -630,8 +631,8 @@ class Channel(Base):
String(1000), nullable=False
) # Webhook callback URL
expiration: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True
) # Unix timestamp ms
BigInteger, nullable=True
) # Unix timestamp ms (requires BigInteger for ms since epoch)
token: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
params: Mapped[Optional[dict[str, Any]]] = mapped_column(JSONB, nullable=True)
# Whether payload is wanted for notifications
Expand Down
Loading
Loading