Adds nullable FK to calendars, index, and backfills accepted/tentative
invitations with each user's default calendar.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Invitees no longer see the event owner's calendar name/color,
preventing minor information disclosure (CWE-200).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- C-02: flush invitations before creating notifications so invitation_id
is available in notification data; eliminates extra pending fetch
- C-03: skip RSVP notification when status hasn't changed
- C-01: add defensive comments on update/delete endpoints
- W-01: add ge=1, le=2147483647 per-element validation on user_ids
- W-04: deduplicate invited_event_ids query via get_invited_event_ids()
- W-06: replace Python False with sa_false() in or_() clauses
- Frontend: extract resolveInvitationId helper, prefer data.invitation_id
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Full-stack implementation of event invitations allowing users to invite connected
contacts to calendar events. Invitees can respond Going/Tentative/Declined, with
per-occurrence overrides for recurring series. Invited events appear on the invitee's
calendar with a Users icon indicator. LeaveEventDialog replaces delete for invited events.
Backend: Migration 054 (2 tables + notification types), EventInvitation model with
lazy="raise", service layer, dual-router (events + event-invitations), cascade on
disconnect, events/dashboard queries extended with OR for invited events.
Frontend: Types, useEventInvitations hook, InviteeSection (view list + RSVP buttons +
invite search), LeaveEventDialog, event invite toast with 3 response buttons, calendar
eventContent render with Users icon for invited events.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Starred events should appear in the countdown widget regardless of how
far in the future they are. The _not_parent_template filter still
excludes recurring parent templates while allowing starred children.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
W-01: Consolidate get_accessible_calendar_ids to single UNION query
instead of two separate DB round-trips.
W-02: Document that nginx rate limit on /api/events applies to all
methods (30r/m generous enough for GET polling at 2r/m).
W-03: Add weekly rule validation for consistency with other rule types.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Phase 1: Recurrence safety — MAX_OCCURRENCES=730 hard cap, adaptive 90-day
horizon for daily events (interval<7), RecurrenceRule cross-field validation,
ID bounds on location_id/calendar_id schemas.
Phase 2: Dashboard correctness — shared calendar events now included in
/dashboard and /upcoming via get_accessible_calendar_ids helper. Project stats
consolidated into single GROUP BY query (saves 1 DB round-trip).
Phase 3: Write performance — bulk db.add_all() for child events, removed
redundant SELECT in this_and_future delete path.
Phase 4: Frontend query efficiency — staleTime: 30_000 on calendar events
query eliminates redundant refetches on mount/view switch. Backend LIMIT 2000
safety guard on events endpoint.
Phase 5: Rate limiting — nginx limit_req zone on /api/events (30r/m) to
prevent DB flooding via recurrence amplification.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove migration 054 (duplicate of 035 which already has all 3 indexes,
including a superior partial index for starred events)
- Fix handleEventDidMount indentation and missing semicolons
- Replace eventClassNames arrow function with static UMBRA_EVENT_CLASSES array
- Correct misleading subquery comment in dashboard.py
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Migration 054: three indexes on calendar_events table:
- (calendar_id, start_datetime) for range queries
- (parent_event_id) for recurrence bulk operations
- (calendar_id, is_starred, start_datetime) for starred widget
Dashboard: replaced correlated subquery with single materialized
list fetch for user_calendar_ids in both /dashboard and /upcoming
handlers — eliminates 2 redundant subquery evaluations per request.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Starred events query had no upper date bound — a starred recurring
event would fill all 5 countdown slots with successive occurrences
beyond the user's configured range. Now capped to upcoming_cutoff_dt.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Convert hasSharingRef from useRef to useState in useCalendars so
refetchInterval reacts immediately when sharing is detected (P-01)
- Add extra="forbid" to ReorderItem schema to prevent mass-assignment (S-03)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Phase 1 fixes:
- W-01: Add start_period: 30s to backend healthcheck for migration window
- W-03: Narrow .dockerignore *.md to specific files (preserve alembic/README)
Phase 2 fixes:
- C-01: Wrap Argon2id calls in totp.py (disable, regenerate, backup verify,
backup store) — missed in initial AC-2 pass
- S-01: Extract async wrappers (ahash_password, averify_password,
averify_password_with_upgrade) into services/auth.py, refactor all
callers to use them instead of manual run_in_executor boilerplate
- W-01: Fix ntfy dedup regression — commit per category instead of per-user
to preserve dedup records if a later category fails
Phase 4 fixes:
- C-01: Fix optimistic drag-and-drop cache key to include date range
- C-02: Replace toISOString() with format() to avoid UTC date shift in
visible range calculation
- W-02: Initialize visibleRange from current month to eliminate unscoped
first fetch + immediate refetch
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- AW-1: Add composite index on calendar_members(user_id, status) for the
hot shared-calendar polling query
- AS-6: Add composite index on ntfy_sent(user_id, sent_at) for dedup lookups
- AW-5: Combine get_user_permission into single LEFT JOIN query instead of
2 sequential queries (called twice per event edit)
- AC-5: Batch cascade_on_disconnect — single GROUP BY + bulk UPDATE instead
of N per-calendar checks when a connection is severed
- AW-6: Collapse admin dashboard 5 COUNT queries into single conditional
aggregation using COUNT().filter()
- AC-3: Cache get_current_settings in request.state to avoid redundant
queries when multiple dependencies need settings in the same request
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- AC-1: Merge get_current_user into single JOIN query (session + user in
one round-trip instead of two sequential queries per request)
- AC-2: Wrap all Argon2id hash/verify calls in run_in_executor to avoid
blocking the async event loop (~150ms per operation)
- AW-7: Add connection pool config (pool_size=10, pool_pre_ping=True,
pool_recycle=1800) to prevent connection exhaustion under load
- AC-4: Batch-fetch tasks in reorder_tasks with IN clause instead of
N sequential queries during Kanban drag operations
- AW-4: Bulk NtfySent inserts with single commit per user instead of
per-notification commits in the dispatch job
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add .dockerignore for backend and frontend (DC-1: eliminates node_modules/
and .env from build context)
- Delete start.sh with --reload flag (DC-2: superseded by Dockerfile CMD)
- Create entrypoint.sh with exec uvicorn (DW-5: proper PID 1 signal handling)
- Pin base images to patch-level tags (DW-1: reproducible builds)
- Reorder Dockerfile: create appuser before COPY, use --chown (DW-2)
- Switch to npm ci for lockfile-enforced installs (DW-3)
- Add network segmentation: backend_net + frontend_net (DW-4: db unreachable
from frontend container)
- Add deploy.resources limits to all services (DW-6: OOM protection)
- Refactor proxy-params.conf to include security headers, deduplicate from
nginx.conf location blocks (DW-7)
- Add image/svg+xml to gzip_types (DS-1)
- Add wget healthcheck for frontend service (DS-2)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Gate dashboard rendering on isLockResolved to prevent content flash
before lock state is known. Remove animate-fade-in from LockOverlay
so it renders instantly. Always write accent color to localStorage
(even default cyan) to prevent theme flash on reload. Resolve lock
state on auth query error to avoid permanent blank screen. Lift
mobileOpen state above lock gate to survive lock/unlock cycles.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Critical: Lock state was purely React useState — refreshing the page reset it.
Now persisted server-side via is_locked/locked_at columns on user_sessions.
POST /auth/lock sets the flag, /auth/verify-password clears it, and
GET /auth/status returns is_locked so the frontend initializes correctly.
UI: Cache accent color in localStorage and apply via inline script in
index.html before React hydrates to eliminate the cyan flash on load.
UI: Increase TanStack Query gcTime from 5min to 30min so page data
survives component unmount/remount across tab switches without skeleton.
UI: Move Projects nav onClick from the icon element to the full-width
container div so the entire row is clickable when the sidebar is collapsed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
C-01: Add 30-day lower bound on overdue todo/reminder queries to
prevent fetching entire history.
C-02: Remove dead include_past query param — past-event filtering
is handled client-side.
W-01: Add onError toast handlers to all three inline mutations.
W-02: Snooze dropdown opens upward (bottom-full) to avoid clipping
inside the ScrollArea overflow container.
S-06: Clamp getMinutesUntilTomorrowMorning() to max 1440 to stay
within ReminderSnooze schema bounds.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Backend: Include overdue todos and snoozed reminders in /upcoming response,
add end_datetime/snoozed_until/is_overdue fields, widen snooze schema to
accept 1-1440 minutes for 1h/3h/tomorrow options.
Frontend: Full UpcomingWidget rewrite with sticky day separators (Today
highlighted in accent), collapsible groups, past-event toggle, focus mode
(Today + Tomorrow), color-coded left borders, compact type pills, relative
time for today's items, item count badge, and inline quick actions (complete
todo, snooze/dismiss reminder on hover). Card fills available height with
no dead space. DashboardPage always renders widget (no duplicate empty state).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
C-01: sync_birthday_to_contacts now accepts (share_birthday, date_of_birth)
directly — no internal re-query, no stale-read risk with autoflush.
W-01: Eliminated redundant User/Settings SELECTs inside the service.
W-02: Removed scalar_one() on User query (no longer queries internally).
W-03: Settings router only syncs when share_birthday value actually changes.
S-02: Added logger.info with rowcount for observability.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a user updates their date of birth or toggles share_birthday,
all linked Person records (where linked_user_id matches) are updated.
If share_birthday is off, the birthday is cleared on linked records.
Virtual birthday events auto-reflect the change on next calendar poll.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The _build_member_response helper tried to access member.user.preferred_name
but User model has no preferred_name field (it's on Settings). With lazy="raise"
this caused a 500 on GET /shared-calendars/{id}/members. Reverted to None —
the list_members endpoint already patches preferred_name from Settings.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
W-03: invite_member now verifies the target user has a reciprocal
UserConnection row before sending the invite.
W-04: CalendarMember relationships changed from lazy="selectin" to
lazy="raise". All queries that access .user, .calendar, or .inviter
already use explicit selectinload() — verified across all routers
and services.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Critical:
- C-01: Populate member_count in GET /calendars for shared calendars
- C-02: Differentiate 423 lock errors in drag-drop onError (show lock-specific toast)
- C-03: Add expired lock purge to APScheduler housekeeping job
Warnings:
- W-01: Replace setattr loop with explicit field assignment in update_member
- W-02: Cap sync `since` param to 7 days to prevent unbounded scans
- W-05: Remove cosmetic isShared toggle (is_shared is auto-managed by invite flow)
- W-06: Populate preferred_name in _build_member_response from user model
- W-07: Add releaseMutation to release callback dependency array
Suggestion:
- S-06: Remove unused ConvertToSharedRequest schema
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
SC-01: unlock_event now verifies caller has access to the calendar before
revealing lock state. Previously any authenticated user could probe event
existence via 404/204/403 response differences.
SC-02: acquire_lock no longer overwrites permanent locks. If the owner holds
a permanent lock and clicks Edit, the existing lock is returned as-is instead
of being downgraded to a 5-minute temporary lock.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Bug 1 (lock banner): Owners bypassed lock acquisition entirely, so no DB lock
was created when an owner edited a shared event. Members polling GET
/shared-calendars/events/{id}/lock correctly saw `locked: false`. Fix: remove
the `myPermission !== 'owner'` guard in handleEditStart so owners also acquire
a temporary 5-min edit lock when editing shared events, making the banner
visible to all other members.
Bug 2 (calendar not found on save): PUT /events/{id} called
_verify_calendar_ownership whenever calendar_id appeared in the payload, even
when it was unchanged. For shared-calendar members this always 404'd because
they don't own the calendar. Fix: add `update_data["calendar_id"] !=
event.calendar_id` to the guard — ownership is only verified when the calendar
is actually being changed (existing M-01 guard handles the move-off-shared case).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
M-01: Unify registration error messages to prevent username/email
enumeration — both now return the same generic message.
Bug fix: Contact detail panel header and initials now use shared_fields
for umbral contacts instead of stale Person record first_name/last_name.
The table list already did this via sf() helper; the detail panel header
and getPersonInitialsName were bypassing it.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
W-02: Purge accepted connection requests after 90 days (rejected/cancelled stay at 30)
W-04: Rename shadowed `type` parameter to `notification_type` with alias
W-05: Extract notification type string literals to constants in connection service
W-06: Match notification list polling interval to unread count (15s when visible)
W-07: Add filter_to_shareable defence-in-depth gate on resolve_shared_profile output
W-03: Verified false positive — no double person lookup exists in accept flow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
C-01: ConnectionRequestCard now uses local isResponding state instead of
shared hook boolean, so accepting one card doesn't disable all others.
C-03: detach_umbral_contact no longer wipes person data (name, email,
phone, etc.) when a connection is severed. The person becomes a standard
contact with all data preserved, preventing data loss for pre-existing
contacts that were linked to connections.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The QA fix incorrectly changed datetime.now() to datetime.now(timezone.utc),
but the DB uses TIMESTAMP WITHOUT TIME ZONE (naive). Passing a tz-aware
datetime to a naive column causes asyncpg DataError. Also removes the
temporary diagnostic wrapper and builds response before commit.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Exposes actual exception type and message in 500 response detail
to identify the unhandled error. Will be removed after diagnosis.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
After db.commit(), all ORM objects are expired. Accessing their attributes
in _build_request_response triggered lazy loads which fail in async
SQLAlchemy with MissingGreenlet. Move response construction before commit.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Username validation allows dots ([a-z0-9_.\-]+) but the connection
search and umbral name validators used [a-zA-Z0-9_-] which rejected
dots. This caused a 422 on any search for users with dots in their
username (e.g. rca.account01), silently showing "User not found".
Fixed regex in both connection.py schema and auth.py ProfileUpdate
to include dots: [a-zA-Z0-9_.-]
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- W-01: Wrap accept flow db.commit() in IntegrityError handler (409)
- W-03: Remove refetchIntervalInBackground from unread count polling
- W-04: detach_umbral_contact now clears all shared fields on Person
- W-05: sf() callers no longer fall through via ?? to stale local data
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Toast accept button captured a stale `respond` reference from the
Sonner closure. Use respondRef pattern so clicks always dispatch
through the current mutation. Backend respond endpoint now catches
unhandled exceptions and returns proper JSON with detail field
instead of plain-text 500s.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- C-01: Wrap accept flow flush/commit in IntegrityError handling (409)
- C-02: Use separate remote_timestamps dict instead of pop() on shared profile
- W-01: Add birthday sync in Link conversion path (existing person → umbral)
- W-02: Add None guard on max(updated_at) comparison in get_person
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Override updated_at on PersonResponse with max(Person, User, Settings)
so the panel reflects when the connected user last changed their profile.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Inject umbral_name into shared_fields for umbral contacts (always visible)
- Show @umbralname subtitle in detail panel header
- Add preferred_name to panel fields with synced label for umbral contacts
- Add Link button on standard contacts to tie to umbral user via connection request
- Migration 046: person_id FK on connection_requests with index
- Validate person_id ownership on send, re-validate + convert on accept
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Bug 1: _to_settings_response() was missing share_first_name and
share_last_name — the response always returned false (Pydantic default),
causing the frontend to sync toggles back to off after save.
Bug 2: Table column renderers read from stale Person record fields.
Added sf() helper that overlays shared_fields for umbral contacts,
applied to name, phone, email, role, and birthday columns. The table
now shows live shared profile data matching the detail panel.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Delete person now severs the bidirectional connection when the person
is an umbral contact — removes both UserConnection rows and detaches
the counterpart's Person record. Fixes "Already connected" error
when trying to reconnect after deleting an umbral contact.
New PUT /people/{id}/unlink endpoint converts an umbral contact to a
standard contact (detaches linked fields) while also severing the
bidirectional connection, keeping the Person in the contact list.
Frontend: EntityDetailPanel gains extraActions prop. PeoplePage renders
an "Unlink" button in the panel footer for umbral contacts. Delete
mutation now also invalidates connections query.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Notifications: enable refetchIntervalInBackground on unread count
query so notifications appear in background tabs without requiring
a tab switch to trigger refetchOnWindowFocus.
Name sharing: add share_first_name and share_last_name to the full
sharing pipeline — migration 045, Settings model/schema, SHAREABLE_FIELDS,
resolve_shared_profile, create_person_from_connection (now populates
first_name + last_name + computed display name), SharingOverrideUpdate,
frontend types and SettingsPage toggles.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>