Compare commits
22 Commits
b650a94bb8
...
f0e8f450f3
| Author | SHA1 | Date | |
|---|---|---|---|
| f0e8f450f3 | |||
| ff81ef7c14 | |||
| 59c89c904c | |||
| 1bc1e37518 | |||
| cdbf3175aa | |||
| dd862bfa48 | |||
| 206144d20d | |||
| 8f777dd15a | |||
| 3dcf9d1671 | |||
| e62503424c | |||
| c55af91c60 | |||
| 38334b77a3 | |||
| a2f4d297a3 | |||
| 1b36e6b6a7 | |||
| b401fd9392 | |||
| 14fc085009 | |||
| f45b7a2115 | |||
| e5690625eb | |||
| eedfaaf859 | |||
| 4e3fd35040 | |||
| e6e81c59e7 | |||
| e4b45763b4 |
23
backend/alembic/versions/047_add_is_shared_to_calendars.py
Normal file
23
backend/alembic/versions/047_add_is_shared_to_calendars.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""Add is_shared to calendars
|
||||
|
||||
Revision ID: 047
|
||||
Revises: 046
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "047"
|
||||
down_revision = "046"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"calendars",
|
||||
sa.Column("is_shared", sa.Boolean(), nullable=False, server_default="false"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("calendars", "is_shared")
|
||||
47
backend/alembic/versions/048_create_calendar_members.py
Normal file
47
backend/alembic/versions/048_create_calendar_members.py
Normal file
@ -0,0 +1,47 @@
|
||||
"""Create calendar_members table
|
||||
|
||||
Revision ID: 048
|
||||
Revises: 047
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "048"
|
||||
down_revision = "047"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"calendar_members",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("calendar_id", sa.Integer(), sa.ForeignKey("calendars.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("invited_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("permission", sa.String(20), nullable=False),
|
||||
sa.Column("can_add_others", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("local_color", sa.String(20), nullable=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||
sa.Column("invited_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("accepted_at", sa.DateTime(), nullable=True),
|
||||
sa.UniqueConstraint("calendar_id", "user_id", name="uq_calendar_members_cal_user"),
|
||||
sa.CheckConstraint(
|
||||
"permission IN ('read_only', 'create_modify', 'full_access')",
|
||||
name="ck_calendar_members_permission",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('pending', 'accepted', 'rejected')",
|
||||
name="ck_calendar_members_status",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_calendar_members_user_id", "calendar_members", ["user_id"])
|
||||
op.create_index("ix_calendar_members_calendar_id", "calendar_members", ["calendar_id"])
|
||||
op.create_index("ix_calendar_members_status", "calendar_members", ["status"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_calendar_members_status", table_name="calendar_members")
|
||||
op.drop_index("ix_calendar_members_calendar_id", table_name="calendar_members")
|
||||
op.drop_index("ix_calendar_members_user_id", table_name="calendar_members")
|
||||
op.drop_table("calendar_members")
|
||||
30
backend/alembic/versions/049_create_event_locks.py
Normal file
30
backend/alembic/versions/049_create_event_locks.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""Create event_locks table
|
||||
|
||||
Revision ID: 049
|
||||
Revises: 048
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "049"
|
||||
down_revision = "048"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"event_locks",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("event_id", sa.Integer(), sa.ForeignKey("calendar_events.id", ondelete="CASCADE"), nullable=False, unique=True),
|
||||
sa.Column("locked_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("locked_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("expires_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("is_permanent", sa.Boolean(), nullable=False, server_default="false"),
|
||||
)
|
||||
op.create_index("ix_event_locks_expires_at", "event_locks", ["expires_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_event_locks_expires_at", table_name="event_locks")
|
||||
op.drop_table("event_locks")
|
||||
@ -0,0 +1,33 @@
|
||||
"""Expand notification type CHECK for calendar invite types
|
||||
|
||||
Revision ID: 050
|
||||
Revises: 049
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
revision = "050"
|
||||
down_revision = "049"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_OLD_TYPES = (
|
||||
"connection_request", "connection_accepted", "connection_rejected",
|
||||
"info", "warning", "reminder", "system",
|
||||
)
|
||||
_NEW_TYPES = _OLD_TYPES + (
|
||||
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
|
||||
)
|
||||
|
||||
|
||||
def _check_sql(types: tuple) -> str:
|
||||
return f"type IN ({', '.join(repr(t) for t in types)})"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
|
||||
op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_NEW_TYPES))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_notifications_type", "notifications", type_="check")
|
||||
op.create_check_constraint("ck_notifications_type", "notifications", _check_sql(_OLD_TYPES))
|
||||
@ -0,0 +1,34 @@
|
||||
"""Add updated_by to calendar_events and ensure updated_at index
|
||||
|
||||
Revision ID: 051
|
||||
Revises: 050
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "051"
|
||||
down_revision = "050"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"calendar_events",
|
||||
sa.Column(
|
||||
"updated_by",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_calendar_events_updated_at",
|
||||
"calendar_events",
|
||||
["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_calendar_events_updated_at", table_name="calendar_events")
|
||||
op.drop_column("calendar_events", "updated_by")
|
||||
@ -21,6 +21,7 @@ from app.models.notification import Notification as AppNotification
|
||||
from app.models.reminder import Reminder
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
from app.models.calendar import Calendar
|
||||
from app.models.event_lock import EventLock
|
||||
from app.models.todo import Todo
|
||||
from app.models.project import Project
|
||||
from app.models.ntfy_sent import NtfySent
|
||||
@ -300,6 +301,18 @@ async def _purge_resolved_requests(db: AsyncSession) -> None:
|
||||
await db.commit()
|
||||
|
||||
|
||||
|
||||
async def _purge_expired_locks(db: AsyncSession) -> None:
|
||||
"""Remove non-permanent event locks that have expired."""
|
||||
await db.execute(
|
||||
delete(EventLock).where(
|
||||
EventLock.is_permanent == False, # noqa: E712
|
||||
EventLock.expires_at < datetime.now(),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def run_notification_dispatch() -> None:
|
||||
@ -343,6 +356,7 @@ async def run_notification_dispatch() -> None:
|
||||
await _purge_expired_sessions(db)
|
||||
await _purge_old_notifications(db)
|
||||
await _purge_resolved_requests(db)
|
||||
await _purge_expired_locks(db)
|
||||
|
||||
except Exception:
|
||||
# Broad catch: job failure must never crash the scheduler or the app
|
||||
|
||||
@ -7,7 +7,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from app.config import settings
|
||||
from app.database import engine
|
||||
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
|
||||
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router
|
||||
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router
|
||||
from app.jobs.notifications import run_notification_dispatch
|
||||
|
||||
# Import models so Alembic's autogenerate can discover them
|
||||
@ -20,6 +20,8 @@ from app.models import audit_log as _audit_log_model # noqa: F401
|
||||
from app.models import notification as _notification_model # noqa: F401
|
||||
from app.models import connection_request as _connection_request_model # noqa: F401
|
||||
from app.models import user_connection as _user_connection_model # noqa: F401
|
||||
from app.models import calendar_member as _calendar_member_model # noqa: F401
|
||||
from app.models import event_lock as _event_lock_model # noqa: F401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -134,6 +136,7 @@ app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
|
||||
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
||||
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
|
||||
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
|
||||
app.include_router(shared_calendars_router.router, prefix="/api/shared-calendars", tags=["Shared Calendars"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@ -18,6 +18,8 @@ from app.models.audit_log import AuditLog
|
||||
from app.models.notification import Notification
|
||||
from app.models.connection_request import ConnectionRequest
|
||||
from app.models.user_connection import UserConnection
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.models.event_lock import EventLock
|
||||
|
||||
__all__ = [
|
||||
"Settings",
|
||||
@ -40,4 +42,6 @@ __all__ = [
|
||||
"Notification",
|
||||
"ConnectionRequest",
|
||||
"UserConnection",
|
||||
"CalendarMember",
|
||||
"EventLock",
|
||||
]
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
from sqlalchemy import String, Boolean, Integer, ForeignKey, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from typing import List, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.database import Base
|
||||
|
||||
|
||||
@ -17,7 +20,9 @@ class Calendar(Base):
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
is_system: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
is_visible: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
is_shared: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
events: Mapped[List["CalendarEvent"]] = relationship(back_populates="calendar")
|
||||
members: Mapped[List["CalendarMember"]] = relationship(back_populates="calendar", cascade="all, delete-orphan")
|
||||
|
||||
@ -32,6 +32,11 @@ class CalendarEvent(Base):
|
||||
# original_start: the originally computed occurrence datetime (children only)
|
||||
original_start: Mapped[Optional[datetime]] = mapped_column(nullable=True)
|
||||
|
||||
updated_by: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
53
backend/app/models/calendar_member.py
Normal file
53
backend/app/models/calendar_member.py
Normal file
@ -0,0 +1,53 @@
|
||||
from sqlalchemy import (
|
||||
Boolean, CheckConstraint, DateTime, Integer, ForeignKey, Index,
|
||||
String, UniqueConstraint, func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class CalendarMember(Base):
|
||||
__tablename__ = "calendar_members"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("calendar_id", "user_id", name="uq_calendar_members_cal_user"),
|
||||
CheckConstraint(
|
||||
"permission IN ('read_only', 'create_modify', 'full_access')",
|
||||
name="ck_calendar_members_permission",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('pending', 'accepted', 'rejected')",
|
||||
name="ck_calendar_members_status",
|
||||
),
|
||||
Index("ix_calendar_members_user_id", "user_id"),
|
||||
Index("ix_calendar_members_calendar_id", "calendar_id"),
|
||||
Index("ix_calendar_members_status", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
calendar_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("calendars.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
invited_by: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
permission: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
can_add_others: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
local_color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||
invited_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), server_default=func.now()
|
||||
)
|
||||
accepted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
calendar: Mapped["Calendar"] = relationship(back_populates="members", lazy="raise")
|
||||
user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="raise")
|
||||
inviter: Mapped[Optional["User"]] = relationship(
|
||||
foreign_keys=[invited_by], lazy="raise"
|
||||
)
|
||||
31
backend/app/models/event_lock.py
Normal file
31
backend/app/models/event_lock.py
Normal file
@ -0,0 +1,31 @@
|
||||
from sqlalchemy import Boolean, DateTime, Integer, ForeignKey, Index, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class EventLock(Base):
|
||||
__tablename__ = "event_locks"
|
||||
__table_args__ = (Index("ix_event_locks_expires_at", "expires_at"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
event_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("calendar_events.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
unique=True,
|
||||
)
|
||||
locked_by: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
locked_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), server_default=func.now()
|
||||
)
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
is_permanent: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, server_default="false"
|
||||
)
|
||||
|
||||
event: Mapped["CalendarEvent"] = relationship(lazy="selectin")
|
||||
holder: Mapped["User"] = relationship(foreign_keys=[locked_by], lazy="selectin")
|
||||
@ -5,10 +5,9 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
from app.database import Base
|
||||
|
||||
# Active: connection_request, connection_accepted
|
||||
# Reserved: connection_rejected, info, warning, reminder, system
|
||||
_NOTIFICATION_TYPES = (
|
||||
"connection_request", "connection_accepted", "connection_rejected",
|
||||
"calendar_invite", "calendar_invite_accepted", "calendar_invite_rejected",
|
||||
"info", "warning", "reminder", "system",
|
||||
)
|
||||
|
||||
|
||||
@ -22,6 +22,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.calendar import Calendar
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.models.backup_code import BackupCode
|
||||
from app.models.session import UserSession
|
||||
from app.models.settings import Settings
|
||||
@ -618,6 +620,56 @@ async def list_user_sessions(
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /users/{user_id}/sharing-stats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/users/{user_id}/sharing-stats")
|
||||
async def get_user_sharing_stats(
|
||||
user_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_actor: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return sharing statistics for a user."""
|
||||
result = await db.execute(sa.select(User).where(User.id == user_id))
|
||||
if not result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Calendars owned that are shared
|
||||
shared_owned = await db.scalar(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(Calendar)
|
||||
.where(Calendar.user_id == user_id, Calendar.is_shared == True)
|
||||
) or 0
|
||||
|
||||
# Calendars the user is a member of (accepted)
|
||||
member_of = await db.scalar(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(CalendarMember)
|
||||
.where(CalendarMember.user_id == user_id, CalendarMember.status == "accepted")
|
||||
) or 0
|
||||
|
||||
# Pending invites sent by this user
|
||||
pending_sent = await db.scalar(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(CalendarMember)
|
||||
.where(CalendarMember.invited_by == user_id, CalendarMember.status == "pending")
|
||||
) or 0
|
||||
|
||||
# Pending invites received by this user
|
||||
pending_received = await db.scalar(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(CalendarMember)
|
||||
.where(CalendarMember.user_id == user_id, CalendarMember.status == "pending")
|
||||
) or 0
|
||||
|
||||
return {
|
||||
"shared_calendars_owned": shared_owned,
|
||||
"calendars_member_of": member_of,
|
||||
"pending_invites_sent": pending_sent,
|
||||
"pending_invites_received": pending_received,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import func, select, update
|
||||
from typing import List
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.calendar import Calendar
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.schemas.calendar import CalendarCreate, CalendarUpdate, CalendarResponse
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
@ -23,7 +24,28 @@ async def get_calendars(
|
||||
.where(Calendar.user_id == current_user.id)
|
||||
.order_by(Calendar.is_default.desc(), Calendar.name.asc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
calendars = result.scalars().all()
|
||||
|
||||
# Populate member_count for shared calendars
|
||||
cal_ids = [c.id for c in calendars if c.is_shared]
|
||||
count_map: dict[int, int] = {}
|
||||
if cal_ids:
|
||||
counts = await db.execute(
|
||||
select(CalendarMember.calendar_id, func.count())
|
||||
.where(
|
||||
CalendarMember.calendar_id.in_(cal_ids),
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
.group_by(CalendarMember.calendar_id)
|
||||
)
|
||||
count_map = dict(counts.all())
|
||||
|
||||
return [
|
||||
CalendarResponse.model_validate(c, from_attributes=True).model_copy(
|
||||
update={"member_count": count_map.get(c.id, 0)}
|
||||
)
|
||||
for c in calendars
|
||||
]
|
||||
|
||||
|
||||
@router.post("/", response_model=CalendarResponse, status_code=201)
|
||||
|
||||
@ -49,6 +49,7 @@ from app.services.connection import (
|
||||
resolve_shared_profile,
|
||||
send_connection_ntfy,
|
||||
)
|
||||
from app.services.calendar_sharing import cascade_on_disconnect
|
||||
from app.services.notification import create_notification
|
||||
|
||||
router = APIRouter()
|
||||
@ -823,6 +824,9 @@ async def remove_connection(
|
||||
if reverse_conn:
|
||||
await db.delete(reverse_conn)
|
||||
|
||||
# Cascade: remove calendar memberships and event locks between these users
|
||||
await cascade_on_disconnect(db, current_user.id, counterpart_id)
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
action="connection.removed",
|
||||
|
||||
@ -18,7 +18,9 @@ from app.schemas.calendar_event import (
|
||||
)
|
||||
from app.routers.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.services.recurrence import generate_occurrences
|
||||
from app.services.calendar_sharing import check_lock_for_edit, require_permission
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@ -142,13 +144,18 @@ async def get_events(
|
||||
recurrence_rule IS NOT NULL) are excluded — their materialised children
|
||||
are what get displayed on the calendar.
|
||||
"""
|
||||
# Scope events through calendar ownership
|
||||
# Scope events through calendar ownership + shared memberships
|
||||
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 = (
|
||||
select(CalendarEvent)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
.where(CalendarEvent.calendar_id.in_(user_calendar_ids))
|
||||
.where(CalendarEvent.calendar_id.in_(all_calendar_ids))
|
||||
)
|
||||
|
||||
# Exclude parent template rows — they are not directly rendered
|
||||
@ -219,8 +226,13 @@ async def create_event(
|
||||
if not data.get("calendar_id"):
|
||||
data["calendar_id"] = await _get_default_calendar_id(db, current_user.id)
|
||||
else:
|
||||
# SEC-04: verify the target calendar belongs to the requesting user
|
||||
await _verify_calendar_ownership(db, data["calendar_id"], current_user.id)
|
||||
# SEC-04: verify ownership OR shared calendar permission
|
||||
cal_ownership_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == data["calendar_id"], Calendar.user_id == current_user.id)
|
||||
)
|
||||
if not cal_ownership_result.scalar_one_or_none():
|
||||
# Not owned — check shared calendar permission
|
||||
await require_permission(db, data["calendar_id"], current_user.id, "create_modify")
|
||||
|
||||
# Serialize RecurrenceRule object to JSON string for DB storage
|
||||
# Exclude None values so defaults in recurrence service work correctly
|
||||
@ -229,7 +241,7 @@ async def create_event(
|
||||
|
||||
if rule_json:
|
||||
# Parent template: is_recurring=True, no parent_event_id
|
||||
parent = CalendarEvent(**data, recurrence_rule=rule_json, is_recurring=True)
|
||||
parent = CalendarEvent(**data, recurrence_rule=rule_json, is_recurring=True, updated_by=current_user.id)
|
||||
db.add(parent)
|
||||
await db.flush() # assign parent.id before generating children
|
||||
|
||||
@ -258,7 +270,7 @@ async def create_event(
|
||||
return result.scalar_one()
|
||||
|
||||
else:
|
||||
new_event = CalendarEvent(**data, recurrence_rule=None)
|
||||
new_event = CalendarEvent(**data, recurrence_rule=None, updated_by=current_user.id)
|
||||
db.add(new_event)
|
||||
await db.commit()
|
||||
|
||||
@ -277,13 +289,18 @@ async def get_event(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
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(
|
||||
select(CalendarEvent)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
.where(
|
||||
CalendarEvent.id == event_id,
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
||||
)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
@ -302,13 +319,18 @@ async def update_event(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
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(
|
||||
select(CalendarEvent)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
.where(
|
||||
CalendarEvent.id == event_id,
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
||||
)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
@ -316,6 +338,10 @@ async def update_event(
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Calendar event not found")
|
||||
|
||||
# Shared calendar: require create_modify+ and check lock
|
||||
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
|
||||
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
|
||||
|
||||
update_data = event_update.model_dump(exclude_unset=True)
|
||||
|
||||
# Extract scope before applying fields to the model
|
||||
@ -327,9 +353,23 @@ async def update_event(
|
||||
update_data["recurrence_rule"] = json.dumps({k: v for k, v in rule_obj.items() if v is not None}) if rule_obj else None
|
||||
|
||||
# SEC-04: if calendar_id is being changed, verify the target belongs to the user
|
||||
if "calendar_id" in update_data and update_data["calendar_id"] is not None:
|
||||
# Only verify ownership when the calendar is actually changing — members submitting
|
||||
# an unchanged calendar_id must not be rejected just because they aren't the owner.
|
||||
if "calendar_id" in update_data and update_data["calendar_id"] is not None and update_data["calendar_id"] != event.calendar_id:
|
||||
await _verify_calendar_ownership(db, update_data["calendar_id"], current_user.id)
|
||||
|
||||
# M-01: Block non-owners from moving events off shared calendars
|
||||
if "calendar_id" in update_data and update_data["calendar_id"] != event.calendar_id:
|
||||
source_cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == event.calendar_id)
|
||||
)
|
||||
source_cal = source_cal_result.scalar_one_or_none()
|
||||
if source_cal and source_cal.is_shared and source_cal.user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Only the calendar owner can move events between calendars",
|
||||
)
|
||||
|
||||
start = update_data.get("start_datetime", event.start_datetime)
|
||||
end_dt = update_data.get("end_datetime", event.end_datetime)
|
||||
if end_dt is not None and end_dt < start:
|
||||
@ -342,6 +382,7 @@ async def update_event(
|
||||
# Detach from parent so it's an independent event going forward
|
||||
event.parent_event_id = None
|
||||
event.is_recurring = False
|
||||
event.updated_by = current_user.id
|
||||
await db.commit()
|
||||
|
||||
elif scope == "this_and_future":
|
||||
@ -371,6 +412,7 @@ async def update_event(
|
||||
event.parent_event_id = None
|
||||
event.is_recurring = True
|
||||
event.original_start = None
|
||||
event.updated_by = current_user.id
|
||||
|
||||
# Inherit parent's recurrence_rule if none was provided in update
|
||||
if not event.recurrence_rule and parent_rule:
|
||||
@ -386,6 +428,7 @@ async def update_event(
|
||||
# This IS a parent — update it and regenerate all children
|
||||
for key, value in update_data.items():
|
||||
setattr(event, key, value)
|
||||
event.updated_by = current_user.id
|
||||
|
||||
# Delete all existing children and regenerate
|
||||
if event.recurrence_rule:
|
||||
@ -405,6 +448,7 @@ async def update_event(
|
||||
# No scope — plain update (non-recurring events or full-series metadata)
|
||||
for key, value in update_data.items():
|
||||
setattr(event, key, value)
|
||||
event.updated_by = current_user.id
|
||||
await db.commit()
|
||||
|
||||
result = await db.execute(
|
||||
@ -427,11 +471,16 @@ async def delete_event(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
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(
|
||||
select(CalendarEvent).where(
|
||||
CalendarEvent.id == event_id,
|
||||
CalendarEvent.calendar_id.in_(user_calendar_ids),
|
||||
CalendarEvent.calendar_id.in_(all_calendar_ids),
|
||||
)
|
||||
)
|
||||
event = result.scalar_one_or_none()
|
||||
@ -439,6 +488,10 @@ async def delete_event(
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Calendar event not found")
|
||||
|
||||
# Shared calendar: require full_access+ and check lock
|
||||
await require_permission(db, event.calendar_id, current_user.id, "full_access")
|
||||
await check_lock_for_edit(db, event_id, current_user.id, event.calendar_id)
|
||||
|
||||
if scope == "this":
|
||||
# Delete just this one occurrence
|
||||
await db.delete(event)
|
||||
|
||||
871
backend/app/routers/shared_calendars.py
Normal file
871
backend/app/routers/shared_calendars.py
Normal file
@ -0,0 +1,871 @@
|
||||
"""
|
||||
Shared calendars router — invites, membership, locks, sync.
|
||||
|
||||
All endpoints live under /api/shared-calendars.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
||||
from sqlalchemy import delete, func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.calendar import Calendar
|
||||
from app.models.calendar_event import CalendarEvent
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.models.event_lock import EventLock
|
||||
from app.models.settings import Settings
|
||||
from app.models.user import User
|
||||
from app.models.user_connection import UserConnection
|
||||
from app.routers.auth import get_current_user
|
||||
from app.schemas.shared_calendar import (
|
||||
CalendarInviteResponse,
|
||||
CalendarMemberResponse,
|
||||
InviteMemberRequest,
|
||||
LockStatusResponse,
|
||||
RespondInviteRequest,
|
||||
SyncResponse,
|
||||
UpdateLocalColorRequest,
|
||||
UpdateMemberRequest,
|
||||
)
|
||||
from app.services.audit import get_client_ip, log_audit_event
|
||||
from app.services.calendar_sharing import (
|
||||
PERMISSION_RANK,
|
||||
acquire_lock,
|
||||
get_user_permission,
|
||||
release_lock,
|
||||
require_permission,
|
||||
)
|
||||
from app.services.notification import create_notification
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PENDING_INVITE_CAP = 10
|
||||
|
||||
|
||||
# -- Helpers ---------------------------------------------------------------
|
||||
|
||||
async def _get_settings_for_user(db: AsyncSession, user_id: int) -> Settings | None:
|
||||
result = await db.execute(select(Settings).where(Settings.user_id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
def _build_member_response(member: CalendarMember) -> dict:
|
||||
return {
|
||||
"id": member.id,
|
||||
"calendar_id": member.calendar_id,
|
||||
"user_id": member.user_id,
|
||||
"umbral_name": member.user.umbral_name if member.user else "",
|
||||
"preferred_name": None,
|
||||
"permission": member.permission,
|
||||
"can_add_others": member.can_add_others,
|
||||
"local_color": member.local_color,
|
||||
"status": member.status,
|
||||
"invited_at": member.invited_at,
|
||||
"accepted_at": member.accepted_at,
|
||||
}
|
||||
|
||||
|
||||
# -- GET / — List accepted memberships ------------------------------------
|
||||
|
||||
@router.get("/")
|
||||
async def list_shared_calendars(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List calendars the current user has accepted membership in."""
|
||||
result = await db.execute(
|
||||
select(CalendarMember)
|
||||
.where(
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
.options(selectinload(CalendarMember.calendar))
|
||||
.order_by(CalendarMember.accepted_at.desc())
|
||||
)
|
||||
members = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": m.id,
|
||||
"calendar_id": m.calendar_id,
|
||||
"calendar_name": m.calendar.name if m.calendar else "",
|
||||
"calendar_color": m.calendar.color if m.calendar else "",
|
||||
"local_color": m.local_color,
|
||||
"permission": m.permission,
|
||||
"can_add_others": m.can_add_others,
|
||||
"is_owner": False,
|
||||
}
|
||||
for m in members
|
||||
]
|
||||
|
||||
|
||||
# -- POST /{cal_id}/invite — Invite via connection_id ---------------------
|
||||
|
||||
@router.post("/{cal_id}/invite", status_code=201)
|
||||
async def invite_member(
|
||||
body: InviteMemberRequest,
|
||||
request: Request,
|
||||
cal_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Invite a connected user to a shared calendar."""
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == cal_id)
|
||||
)
|
||||
calendar = cal_result.scalar_one_or_none()
|
||||
if not calendar:
|
||||
raise HTTPException(status_code=404, detail="Calendar not found")
|
||||
|
||||
is_owner = calendar.user_id == current_user.id
|
||||
inviter_perm = "owner" if is_owner else None
|
||||
|
||||
if not is_owner:
|
||||
member_result = await db.execute(
|
||||
select(CalendarMember).where(
|
||||
CalendarMember.calendar_id == cal_id,
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Calendar not found")
|
||||
if not member.can_add_others:
|
||||
raise HTTPException(status_code=403, detail="You do not have permission to invite others")
|
||||
if PERMISSION_RANK.get(member.permission, 0) < PERMISSION_RANK.get("create_modify", 0):
|
||||
raise HTTPException(status_code=403, detail="Read-only members cannot invite others")
|
||||
inviter_perm = member.permission
|
||||
|
||||
# Permission ceiling
|
||||
if inviter_perm != "owner":
|
||||
if PERMISSION_RANK.get(body.permission, 0) > PERMISSION_RANK.get(inviter_perm, 0):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Cannot grant a permission level higher than your own",
|
||||
)
|
||||
|
||||
# Resolve connection_id -> connected user
|
||||
conn_result = await db.execute(
|
||||
select(UserConnection).where(
|
||||
UserConnection.id == body.connection_id,
|
||||
UserConnection.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
connection = conn_result.scalar_one_or_none()
|
||||
if not connection:
|
||||
raise HTTPException(status_code=404, detail="Connection not found")
|
||||
|
||||
target_user_id = connection.connected_user_id
|
||||
|
||||
# W-03: Verify bidirectional connection still active
|
||||
reverse_conn = await db.execute(
|
||||
select(UserConnection.id).where(
|
||||
UserConnection.user_id == target_user_id,
|
||||
UserConnection.connected_user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
if not reverse_conn.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Connection is no longer active")
|
||||
|
||||
if target_user_id == calendar.user_id:
|
||||
raise HTTPException(status_code=400, detail="Cannot invite the calendar owner")
|
||||
|
||||
target_result = await db.execute(
|
||||
select(User).where(User.id == target_user_id)
|
||||
)
|
||||
target = target_result.scalar_one_or_none()
|
||||
if not target or not target.is_active:
|
||||
raise HTTPException(status_code=404, detail="Target user not found or inactive")
|
||||
|
||||
existing = await db.execute(
|
||||
select(CalendarMember).where(
|
||||
CalendarMember.calendar_id == cal_id,
|
||||
CalendarMember.user_id == target_user_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail="User already invited or is a member")
|
||||
|
||||
pending_count = await db.scalar(
|
||||
select(func.count())
|
||||
.select_from(CalendarMember)
|
||||
.where(
|
||||
CalendarMember.calendar_id == cal_id,
|
||||
CalendarMember.status == "pending",
|
||||
)
|
||||
) or 0
|
||||
if pending_count >= PENDING_INVITE_CAP:
|
||||
raise HTTPException(status_code=429, detail="Too many pending invites for this calendar")
|
||||
|
||||
if not calendar.is_shared:
|
||||
calendar.is_shared = True
|
||||
|
||||
new_member = CalendarMember(
|
||||
calendar_id=cal_id,
|
||||
user_id=target_user_id,
|
||||
invited_by=current_user.id,
|
||||
permission=body.permission,
|
||||
can_add_others=body.can_add_others,
|
||||
status="pending",
|
||||
)
|
||||
db.add(new_member)
|
||||
await db.flush()
|
||||
|
||||
inviter_settings = await _get_settings_for_user(db, current_user.id)
|
||||
inviter_display = (inviter_settings.preferred_name if inviter_settings else None) or current_user.umbral_name
|
||||
|
||||
cal_name = calendar.name
|
||||
await create_notification(
|
||||
db,
|
||||
user_id=target_user_id,
|
||||
type="calendar_invite",
|
||||
title="Calendar Invite",
|
||||
message=f"{inviter_display} invited you to '{cal_name}'",
|
||||
data={"calendar_id": cal_id, "calendar_name": cal_name},
|
||||
source_type="calendar_invite",
|
||||
source_id=new_member.id,
|
||||
)
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
action="calendar.invite_sent",
|
||||
actor_id=current_user.id,
|
||||
target_id=target_user_id,
|
||||
detail={
|
||||
"calendar_id": cal_id,
|
||||
"calendar_name": cal_name,
|
||||
"permission": body.permission,
|
||||
},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
response = {
|
||||
"message": "Invite sent",
|
||||
"member_id": new_member.id,
|
||||
"calendar_id": cal_id,
|
||||
}
|
||||
|
||||
await db.commit()
|
||||
return response
|
||||
|
||||
|
||||
# -- PUT /invites/{id}/respond — Accept or reject -------------------------
|
||||
|
||||
@router.put("/invites/{invite_id}/respond")
|
||||
async def respond_to_invite(
|
||||
body: RespondInviteRequest,
|
||||
request: Request,
|
||||
invite_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Accept or reject a calendar invite."""
|
||||
result = await db.execute(
|
||||
select(CalendarMember)
|
||||
.where(
|
||||
CalendarMember.id == invite_id,
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "pending",
|
||||
)
|
||||
.options(selectinload(CalendarMember.calendar))
|
||||
)
|
||||
invite = result.scalar_one_or_none()
|
||||
if not invite:
|
||||
raise HTTPException(status_code=404, detail="Invite not found or already resolved")
|
||||
|
||||
calendar_name = invite.calendar.name if invite.calendar else "Unknown"
|
||||
calendar_owner_id = invite.calendar.user_id if invite.calendar else None
|
||||
inviter_id = invite.invited_by
|
||||
|
||||
if body.action == "accept":
|
||||
invite.status = "accepted"
|
||||
invite.accepted_at = datetime.now()
|
||||
|
||||
notify_user_id = inviter_id or calendar_owner_id
|
||||
if notify_user_id:
|
||||
user_settings = await _get_settings_for_user(db, current_user.id)
|
||||
display = (user_settings.preferred_name if user_settings else None) or current_user.umbral_name
|
||||
await create_notification(
|
||||
db,
|
||||
user_id=notify_user_id,
|
||||
type="calendar_invite_accepted",
|
||||
title="Invite Accepted",
|
||||
message=f"{display} accepted your invite to '{calendar_name}'",
|
||||
data={"calendar_id": invite.calendar_id},
|
||||
source_type="calendar_invite",
|
||||
source_id=invite.id,
|
||||
)
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
action="calendar.invite_accepted",
|
||||
actor_id=current_user.id,
|
||||
detail={"calendar_id": invite.calendar_id, "calendar_name": calendar_name},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Invite accepted"}
|
||||
|
||||
else:
|
||||
member_id = invite.id
|
||||
calendar_id = invite.calendar_id
|
||||
|
||||
notify_user_id = inviter_id or calendar_owner_id
|
||||
if notify_user_id:
|
||||
user_settings = await _get_settings_for_user(db, current_user.id)
|
||||
display = (user_settings.preferred_name if user_settings else None) or current_user.umbral_name
|
||||
await create_notification(
|
||||
db,
|
||||
user_id=notify_user_id,
|
||||
type="calendar_invite_rejected",
|
||||
title="Invite Rejected",
|
||||
message=f"{display} declined your invite to '{calendar_name}'",
|
||||
data={"calendar_id": calendar_id},
|
||||
source_type="calendar_invite",
|
||||
source_id=member_id,
|
||||
)
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
action="calendar.invite_rejected",
|
||||
actor_id=current_user.id,
|
||||
detail={"calendar_id": calendar_id, "calendar_name": calendar_name},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
await db.delete(invite)
|
||||
await db.commit()
|
||||
return {"message": "Invite rejected"}
|
||||
|
||||
|
||||
# -- GET /invites/incoming — Pending invites -------------------------------
|
||||
|
||||
@router.get("/invites/incoming", response_model=list[CalendarInviteResponse])
|
||||
async def get_incoming_invites(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List pending calendar invites for the current user."""
|
||||
offset = (page - 1) * per_page
|
||||
result = await db.execute(
|
||||
select(CalendarMember)
|
||||
.where(
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "pending",
|
||||
)
|
||||
.options(
|
||||
selectinload(CalendarMember.calendar),
|
||||
selectinload(CalendarMember.inviter),
|
||||
)
|
||||
.order_by(CalendarMember.invited_at.desc())
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
)
|
||||
invites = result.scalars().all()
|
||||
|
||||
# Batch-fetch owner names to avoid N+1
|
||||
owner_ids = list({inv.calendar.user_id for inv in invites if inv.calendar})
|
||||
if owner_ids:
|
||||
owner_result = await db.execute(
|
||||
select(User.id, User.umbral_name).where(User.id.in_(owner_ids))
|
||||
)
|
||||
owner_names = {row.id: row.umbral_name for row in owner_result.all()}
|
||||
else:
|
||||
owner_names = {}
|
||||
|
||||
responses = []
|
||||
for inv in invites:
|
||||
owner_name = owner_names.get(inv.calendar.user_id, "") if inv.calendar else ""
|
||||
|
||||
responses.append(CalendarInviteResponse(
|
||||
id=inv.id,
|
||||
calendar_id=inv.calendar_id,
|
||||
calendar_name=inv.calendar.name if inv.calendar else "",
|
||||
calendar_color=inv.calendar.color if inv.calendar else "",
|
||||
owner_umbral_name=owner_name,
|
||||
inviter_umbral_name=inv.inviter.umbral_name if inv.inviter else "",
|
||||
permission=inv.permission,
|
||||
invited_at=inv.invited_at,
|
||||
))
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
# -- GET /{cal_id}/members — Member list -----------------------------------
|
||||
|
||||
@router.get("/{cal_id}/members", response_model=list[CalendarMemberResponse])
|
||||
async def list_members(
|
||||
cal_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List all members of a shared calendar. Requires membership or ownership."""
|
||||
perm = await get_user_permission(db, cal_id, current_user.id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Calendar not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarMember)
|
||||
.where(CalendarMember.calendar_id == cal_id)
|
||||
.options(selectinload(CalendarMember.user))
|
||||
.order_by(CalendarMember.invited_at.asc())
|
||||
)
|
||||
members = result.scalars().all()
|
||||
|
||||
user_ids = [m.user_id for m in members]
|
||||
if user_ids:
|
||||
settings_result = await db.execute(
|
||||
select(Settings.user_id, Settings.preferred_name)
|
||||
.where(Settings.user_id.in_(user_ids))
|
||||
)
|
||||
pref_names = {row.user_id: row.preferred_name for row in settings_result.all()}
|
||||
else:
|
||||
pref_names = {}
|
||||
|
||||
responses = []
|
||||
for m in members:
|
||||
resp = _build_member_response(m)
|
||||
resp["preferred_name"] = pref_names.get(m.user_id)
|
||||
responses.append(resp)
|
||||
|
||||
return responses
|
||||
|
||||
|
||||
# -- PUT /{cal_id}/members/{mid} — Update permission (owner only) ----------
|
||||
|
||||
@router.put("/{cal_id}/members/{member_id}")
|
||||
async def update_member(
|
||||
body: UpdateMemberRequest,
|
||||
request: Request,
|
||||
cal_id: int = Path(ge=1, le=2147483647),
|
||||
member_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update a member permission or can_add_others. Owner only."""
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == cal_id, Calendar.user_id == current_user.id)
|
||||
)
|
||||
if not cal_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Only the calendar owner can update members")
|
||||
|
||||
member_result = await db.execute(
|
||||
select(CalendarMember).where(
|
||||
CalendarMember.id == member_id,
|
||||
CalendarMember.calendar_id == cal_id,
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
if not update_data:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
if "permission" in update_data:
|
||||
member.permission = update_data["permission"]
|
||||
if "can_add_others" in update_data:
|
||||
member.can_add_others = update_data["can_add_others"]
|
||||
|
||||
await log_audit_event(
|
||||
db,
|
||||
action="calendar.member_updated",
|
||||
actor_id=current_user.id,
|
||||
target_id=member.user_id,
|
||||
detail={"calendar_id": cal_id, "member_id": member_id, "changes": update_data},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Member updated"}
|
||||
|
||||
|
||||
# -- DELETE /{cal_id}/members/{mid} — Remove member or leave ---------------
|
||||
|
||||
@router.delete("/{cal_id}/members/{member_id}", status_code=204)
|
||||
async def remove_member(
|
||||
request: Request,
|
||||
cal_id: int = Path(ge=1, le=2147483647),
|
||||
member_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Remove a member or leave a shared calendar."""
|
||||
member_result = await db.execute(
|
||||
select(CalendarMember).where(
|
||||
CalendarMember.id == member_id,
|
||||
CalendarMember.calendar_id == cal_id,
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == cal_id)
|
||||
)
|
||||
calendar = cal_result.scalar_one_or_none()
|
||||
|
||||
is_self = member.user_id == current_user.id
|
||||
is_owner = calendar and calendar.user_id == current_user.id
|
||||
|
||||
if not is_self and not is_owner:
|
||||
raise HTTPException(status_code=403, detail="Only the calendar owner can remove other members")
|
||||
|
||||
target_user_id = member.user_id
|
||||
|
||||
await db.execute(
|
||||
delete(EventLock).where(
|
||||
EventLock.locked_by == target_user_id,
|
||||
EventLock.event_id.in_(
|
||||
select(CalendarEvent.id).where(CalendarEvent.calendar_id == cal_id)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
await db.delete(member)
|
||||
|
||||
remaining = await db.execute(
|
||||
select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1)
|
||||
)
|
||||
if not remaining.scalar_one_or_none() and calendar:
|
||||
calendar.is_shared = False
|
||||
|
||||
action = "calendar.member_left" if is_self else "calendar.member_removed"
|
||||
await log_audit_event(
|
||||
db,
|
||||
action=action,
|
||||
actor_id=current_user.id,
|
||||
target_id=target_user_id,
|
||||
detail={"calendar_id": cal_id, "member_id": member_id},
|
||||
ip=get_client_ip(request),
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
|
||||
# -- PUT /{cal_id}/members/me/color — Update local color -------------------
|
||||
|
||||
@router.put("/{cal_id}/members/me/color")
|
||||
async def update_local_color(
|
||||
body: UpdateLocalColorRequest,
|
||||
cal_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update the current user local color for a shared calendar."""
|
||||
member_result = await db.execute(
|
||||
select(CalendarMember).where(
|
||||
CalendarMember.calendar_id == cal_id,
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
)
|
||||
member = member_result.scalar_one_or_none()
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Membership not found")
|
||||
|
||||
member.local_color = body.local_color
|
||||
await db.commit()
|
||||
return {"message": "Color updated"}
|
||||
|
||||
|
||||
# -- GET /sync — Sync endpoint ---------------------------------------------
|
||||
|
||||
@router.get("/sync", response_model=SyncResponse)
|
||||
async def sync_shared_calendars(
|
||||
since: datetime = Query(...),
|
||||
calendar_ids: Optional[str] = Query(None, description="Comma-separated calendar IDs"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Sync events and member changes since a given timestamp. Cap 500 events."""
|
||||
MAX_EVENTS = 500
|
||||
|
||||
# Cap since to 7 days ago to prevent unbounded scans
|
||||
floor = datetime.now() - timedelta(days=7)
|
||||
if since < floor:
|
||||
since = floor
|
||||
|
||||
cal_id_list: list[int] = []
|
||||
if calendar_ids:
|
||||
for part in calendar_ids.split(","):
|
||||
part = part.strip()
|
||||
if part.isdigit():
|
||||
cal_id_list.append(int(part))
|
||||
cal_id_list = cal_id_list[:50] # Cap to prevent unbounded IN clause
|
||||
|
||||
owned_ids_result = await db.execute(
|
||||
select(Calendar.id).where(Calendar.user_id == current_user.id)
|
||||
)
|
||||
owned_ids = {row[0] for row in owned_ids_result.all()}
|
||||
|
||||
member_ids_result = await db.execute(
|
||||
select(CalendarMember.calendar_id).where(
|
||||
CalendarMember.user_id == current_user.id,
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
)
|
||||
member_ids = {row[0] for row in member_ids_result.all()}
|
||||
|
||||
accessible = owned_ids | member_ids
|
||||
if cal_id_list:
|
||||
accessible = accessible & set(cal_id_list)
|
||||
|
||||
if not accessible:
|
||||
return SyncResponse(events=[], member_changes=[], server_time=datetime.now())
|
||||
|
||||
accessible_list = list(accessible)
|
||||
|
||||
events_result = await db.execute(
|
||||
select(CalendarEvent)
|
||||
.where(
|
||||
CalendarEvent.calendar_id.in_(accessible_list),
|
||||
CalendarEvent.updated_at >= since,
|
||||
)
|
||||
.options(selectinload(CalendarEvent.calendar))
|
||||
.order_by(CalendarEvent.updated_at.desc())
|
||||
.limit(MAX_EVENTS + 1)
|
||||
)
|
||||
events = events_result.scalars().all()
|
||||
truncated = len(events) > MAX_EVENTS
|
||||
events = events[:MAX_EVENTS]
|
||||
|
||||
event_dicts = []
|
||||
for e in events:
|
||||
event_dicts.append({
|
||||
"id": e.id,
|
||||
"title": e.title,
|
||||
"start_datetime": e.start_datetime.isoformat() if e.start_datetime else None,
|
||||
"end_datetime": e.end_datetime.isoformat() if e.end_datetime else None,
|
||||
"all_day": e.all_day,
|
||||
"calendar_id": e.calendar_id,
|
||||
"calendar_name": e.calendar.name if e.calendar else "",
|
||||
"updated_at": e.updated_at.isoformat() if e.updated_at else None,
|
||||
"updated_by": e.updated_by,
|
||||
})
|
||||
|
||||
members_result = await db.execute(
|
||||
select(CalendarMember)
|
||||
.where(
|
||||
CalendarMember.calendar_id.in_(accessible_list),
|
||||
(CalendarMember.invited_at >= since) | (CalendarMember.accepted_at >= since),
|
||||
)
|
||||
.options(selectinload(CalendarMember.user))
|
||||
)
|
||||
member_changes = members_result.scalars().all()
|
||||
|
||||
member_dicts = []
|
||||
for m in member_changes:
|
||||
member_dicts.append({
|
||||
"id": m.id,
|
||||
"calendar_id": m.calendar_id,
|
||||
"user_id": m.user_id,
|
||||
"umbral_name": m.user.umbral_name if m.user else "",
|
||||
"permission": m.permission,
|
||||
"status": m.status,
|
||||
"invited_at": m.invited_at.isoformat() if m.invited_at else None,
|
||||
"accepted_at": m.accepted_at.isoformat() if m.accepted_at else None,
|
||||
})
|
||||
|
||||
return SyncResponse(
|
||||
events=event_dicts,
|
||||
member_changes=member_dicts,
|
||||
server_time=datetime.now(),
|
||||
truncated=truncated,
|
||||
)
|
||||
|
||||
|
||||
# -- Event Lock Endpoints --------------------------------------------------
|
||||
|
||||
@router.post("/events/{event_id}/lock")
|
||||
async def lock_event(
|
||||
event_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Acquire a 5-minute editing lock on an event."""
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
await require_permission(db, event.calendar_id, current_user.id, "create_modify")
|
||||
|
||||
lock = await acquire_lock(db, event_id, current_user.id)
|
||||
|
||||
# Build response BEFORE commit — ORM objects expire after commit
|
||||
response = {
|
||||
"locked": True,
|
||||
"locked_by_name": current_user.umbral_name,
|
||||
"expires_at": lock.expires_at,
|
||||
"is_permanent": lock.is_permanent,
|
||||
}
|
||||
|
||||
await db.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/events/{event_id}/lock", status_code=204)
|
||||
async def unlock_event(
|
||||
event_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Release a lock. Only the holder or calendar owner can release."""
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
# SC-01: Verify caller has access to this calendar before revealing lock state
|
||||
perm = await get_user_permission(db, event.calendar_id, current_user.id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
lock_result = await db.execute(
|
||||
select(EventLock).where(EventLock.event_id == event_id)
|
||||
)
|
||||
lock = lock_result.scalar_one_or_none()
|
||||
if not lock:
|
||||
return None
|
||||
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == event.calendar_id)
|
||||
)
|
||||
calendar = cal_result.scalar_one_or_none()
|
||||
is_owner = calendar and calendar.user_id == current_user.id
|
||||
|
||||
if lock.locked_by != current_user.id and not is_owner:
|
||||
raise HTTPException(status_code=403, detail="Only the lock holder or calendar owner can release")
|
||||
|
||||
await db.delete(lock)
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/events/{event_id}/lock", response_model=LockStatusResponse)
|
||||
async def get_lock_status(
|
||||
event_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get the lock status of an event."""
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
perm = await get_user_permission(db, event.calendar_id, current_user.id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
lock_result = await db.execute(
|
||||
select(EventLock)
|
||||
.where(EventLock.event_id == event_id)
|
||||
.options(selectinload(EventLock.holder))
|
||||
)
|
||||
lock = lock_result.scalar_one_or_none()
|
||||
|
||||
if not lock:
|
||||
return LockStatusResponse(locked=False)
|
||||
|
||||
now = datetime.now()
|
||||
if not lock.is_permanent and lock.expires_at and lock.expires_at < now:
|
||||
await db.delete(lock)
|
||||
await db.commit()
|
||||
return LockStatusResponse(locked=False)
|
||||
|
||||
return LockStatusResponse(
|
||||
locked=True,
|
||||
locked_by_name=lock.holder.umbral_name if lock.holder else None,
|
||||
expires_at=lock.expires_at,
|
||||
is_permanent=lock.is_permanent,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/events/{event_id}/owner-lock")
|
||||
async def set_permanent_lock(
|
||||
event_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Set a permanent lock on an event. Calendar owner only."""
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == event.calendar_id, Calendar.user_id == current_user.id)
|
||||
)
|
||||
if not cal_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Only the calendar owner can set permanent locks")
|
||||
|
||||
now = datetime.now()
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO event_locks (event_id, locked_by, locked_at, expires_at, is_permanent)
|
||||
VALUES (:event_id, :user_id, :now, NULL, true)
|
||||
ON CONFLICT (event_id)
|
||||
DO UPDATE SET
|
||||
locked_by = :user_id,
|
||||
locked_at = :now,
|
||||
expires_at = NULL,
|
||||
is_permanent = true
|
||||
"""),
|
||||
{"event_id": event_id, "user_id": current_user.id, "now": now},
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Permanent lock set", "is_permanent": True}
|
||||
|
||||
|
||||
@router.delete("/events/{event_id}/owner-lock", status_code=204)
|
||||
async def remove_permanent_lock(
|
||||
event_id: int = Path(ge=1, le=2147483647),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Remove a permanent lock. Calendar owner only."""
|
||||
event_result = await db.execute(
|
||||
select(CalendarEvent).where(CalendarEvent.id == event_id)
|
||||
)
|
||||
event = event_result.scalar_one_or_none()
|
||||
if not event:
|
||||
raise HTTPException(status_code=404, detail="Event not found")
|
||||
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == event.calendar_id, Calendar.user_id == current_user.id)
|
||||
)
|
||||
if not cal_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Only the calendar owner can remove permanent locks")
|
||||
|
||||
await db.execute(
|
||||
delete(EventLock).where(
|
||||
EventLock.event_id == event_id,
|
||||
EventLock.is_permanent == True,
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return None
|
||||
@ -25,7 +25,13 @@ class CalendarResponse(BaseModel):
|
||||
is_default: bool
|
||||
is_system: bool
|
||||
is_visible: bool
|
||||
is_shared: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
owner_umbral_name: Optional[str] = None
|
||||
my_permission: Optional[str] = None
|
||||
my_can_add_others: bool = False
|
||||
my_local_color: Optional[str] = None
|
||||
member_count: int = 0
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
76
backend/app/schemas/shared_calendar.py
Normal file
76
backend/app/schemas/shared_calendar.py
Normal file
@ -0,0 +1,76 @@
|
||||
import re
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from typing import Optional, Literal
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class InviteMemberRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
connection_id: int = Field(ge=1, le=2147483647)
|
||||
permission: Literal["read_only", "create_modify", "full_access"]
|
||||
can_add_others: bool = False
|
||||
|
||||
|
||||
class RespondInviteRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
action: Literal["accept", "reject"]
|
||||
|
||||
|
||||
class UpdateMemberRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
permission: Optional[Literal["read_only", "create_modify", "full_access"]] = None
|
||||
can_add_others: Optional[bool] = None
|
||||
|
||||
|
||||
class UpdateLocalColorRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
local_color: Optional[str] = Field(None, max_length=20)
|
||||
|
||||
@field_validator("local_color")
|
||||
@classmethod
|
||||
def validate_color(cls, v: Optional[str]) -> Optional[str]:
|
||||
if v is not None and not re.match(r"^#[0-9a-fA-F]{6}$", v):
|
||||
raise ValueError("Color must be a hex color code (#RRGGBB)")
|
||||
return v
|
||||
|
||||
|
||||
|
||||
class CalendarMemberResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
calendar_id: int
|
||||
user_id: int
|
||||
umbral_name: str
|
||||
preferred_name: Optional[str] = None
|
||||
permission: str
|
||||
can_add_others: bool
|
||||
local_color: Optional[str] = None
|
||||
status: str
|
||||
invited_at: datetime
|
||||
accepted_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class CalendarInviteResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
calendar_id: int
|
||||
calendar_name: str
|
||||
calendar_color: str
|
||||
owner_umbral_name: str
|
||||
inviter_umbral_name: str
|
||||
permission: str
|
||||
invited_at: datetime
|
||||
|
||||
|
||||
class LockStatusResponse(BaseModel):
|
||||
locked: bool
|
||||
locked_by_name: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
is_permanent: bool = False
|
||||
|
||||
|
||||
class SyncResponse(BaseModel):
|
||||
events: list[dict]
|
||||
member_changes: list[dict]
|
||||
server_time: datetime
|
||||
truncated: bool = False
|
||||
217
backend/app/services/calendar_sharing.py
Normal file
217
backend/app/services/calendar_sharing.py
Normal file
@ -0,0 +1,217 @@
|
||||
"""
|
||||
Calendar sharing service — permission checks, lock management, disconnect cascade.
|
||||
|
||||
All functions accept an AsyncSession and do NOT commit — callers manage transactions.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import delete, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.calendar import Calendar
|
||||
from app.models.calendar_member import CalendarMember
|
||||
from app.models.event_lock import EventLock
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PERMISSION_RANK = {"read_only": 1, "create_modify": 2, "full_access": 3}
|
||||
LOCK_DURATION_MINUTES = 5
|
||||
|
||||
|
||||
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
|
||||
if they are an accepted member, or None if they have no access.
|
||||
"""
|
||||
cal = await db.execute(
|
||||
select(Calendar).where(Calendar.id == calendar_id)
|
||||
)
|
||||
calendar = cal.scalar_one_or_none()
|
||||
if not calendar:
|
||||
return None
|
||||
if calendar.user_id == user_id:
|
||||
return "owner"
|
||||
|
||||
member = await db.execute(
|
||||
select(CalendarMember).where(
|
||||
CalendarMember.calendar_id == calendar_id,
|
||||
CalendarMember.user_id == user_id,
|
||||
CalendarMember.status == "accepted",
|
||||
)
|
||||
)
|
||||
row = member.scalar_one_or_none()
|
||||
return row.permission if row else None
|
||||
|
||||
|
||||
async def require_permission(
|
||||
db: AsyncSession, calendar_id: int, user_id: int, min_level: str
|
||||
) -> str:
|
||||
"""
|
||||
Raises 403 if the user lacks at least min_level permission.
|
||||
Returns the actual permission string (or "owner").
|
||||
"""
|
||||
perm = await get_user_permission(db, calendar_id, user_id)
|
||||
if perm is None:
|
||||
raise HTTPException(status_code=404, detail="Calendar not found")
|
||||
if perm == "owner":
|
||||
return "owner"
|
||||
if PERMISSION_RANK.get(perm, 0) < PERMISSION_RANK.get(min_level, 0):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permission on this calendar")
|
||||
return perm
|
||||
|
||||
|
||||
async def acquire_lock(db: AsyncSession, event_id: int, user_id: int) -> EventLock:
|
||||
"""
|
||||
Atomic INSERT ON CONFLICT — acquires a 5-minute lock on the event.
|
||||
Only succeeds if no unexpired lock exists or the existing lock is held by the same user.
|
||||
Permanent locks are never overwritten — if the same user holds one, it is returned as-is.
|
||||
Returns the lock or raises 423 Locked.
|
||||
"""
|
||||
# Check for existing permanent lock first
|
||||
existing = await db.execute(
|
||||
select(EventLock).where(EventLock.event_id == event_id)
|
||||
)
|
||||
existing_lock = existing.scalar_one_or_none()
|
||||
if existing_lock and existing_lock.is_permanent:
|
||||
if existing_lock.locked_by == user_id:
|
||||
# Owner holds permanent lock — return it without downgrading
|
||||
return existing_lock
|
||||
raise HTTPException(status_code=423, detail="Event is permanently locked by the calendar owner")
|
||||
|
||||
now = datetime.now()
|
||||
expires = now + timedelta(minutes=LOCK_DURATION_MINUTES)
|
||||
|
||||
result = await db.execute(
|
||||
text("""
|
||||
INSERT INTO event_locks (event_id, locked_by, locked_at, expires_at, is_permanent)
|
||||
VALUES (:event_id, :user_id, :now, :expires, false)
|
||||
ON CONFLICT (event_id)
|
||||
DO UPDATE SET
|
||||
locked_by = :user_id,
|
||||
locked_at = :now,
|
||||
expires_at = :expires,
|
||||
is_permanent = false
|
||||
WHERE event_locks.expires_at < :now
|
||||
OR event_locks.locked_by = :user_id
|
||||
RETURNING id, event_id, locked_by, locked_at, expires_at, is_permanent
|
||||
"""),
|
||||
{"event_id": event_id, "user_id": user_id, "now": now, "expires": expires},
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=423, detail="Event is locked by another user")
|
||||
|
||||
lock_result = await db.execute(
|
||||
select(EventLock).where(EventLock.id == row.id)
|
||||
)
|
||||
return lock_result.scalar_one()
|
||||
|
||||
|
||||
async def release_lock(db: AsyncSession, event_id: int, user_id: int) -> None:
|
||||
"""Delete the lock only if held by this user."""
|
||||
await db.execute(
|
||||
delete(EventLock).where(
|
||||
EventLock.event_id == event_id,
|
||||
EventLock.locked_by == user_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def check_lock_for_edit(
|
||||
db: AsyncSession, event_id: int, user_id: int, calendar_id: int
|
||||
) -> None:
|
||||
"""
|
||||
For shared calendars: verify no active lock by another user blocks this edit.
|
||||
For personal (non-shared) calendars: no-op.
|
||||
"""
|
||||
cal_result = await db.execute(
|
||||
select(Calendar.is_shared).where(Calendar.id == calendar_id)
|
||||
)
|
||||
is_shared = cal_result.scalar_one_or_none()
|
||||
if not is_shared:
|
||||
return
|
||||
|
||||
lock_result = await db.execute(
|
||||
select(EventLock).where(EventLock.event_id == event_id)
|
||||
)
|
||||
lock = lock_result.scalar_one_or_none()
|
||||
if not lock:
|
||||
return
|
||||
now = datetime.now()
|
||||
if lock.is_permanent and lock.locked_by != user_id:
|
||||
raise HTTPException(status_code=423, detail="Event is permanently locked by the calendar owner")
|
||||
if lock.locked_by != user_id and (lock.expires_at is None or lock.expires_at > now):
|
||||
raise HTTPException(status_code=423, detail="Event is locked by another user")
|
||||
|
||||
|
||||
async def cascade_on_disconnect(db: AsyncSession, user_a_id: int, user_b_id: int) -> None:
|
||||
"""
|
||||
When a connection is severed:
|
||||
1. Delete CalendarMember rows where one user is a member of the other's calendars
|
||||
2. Delete EventLock rows held by the disconnected user on affected calendars
|
||||
3. Reset is_shared=False on calendars with no remaining members
|
||||
"""
|
||||
# Find calendars owned by each user
|
||||
a_cal_ids_result = await db.execute(
|
||||
select(Calendar.id).where(Calendar.user_id == user_a_id)
|
||||
)
|
||||
a_cal_ids = [row[0] for row in a_cal_ids_result.all()]
|
||||
|
||||
b_cal_ids_result = await db.execute(
|
||||
select(Calendar.id).where(Calendar.user_id == user_b_id)
|
||||
)
|
||||
b_cal_ids = [row[0] for row in b_cal_ids_result.all()]
|
||||
|
||||
# Delete user_b's memberships on user_a's calendars + locks
|
||||
if a_cal_ids:
|
||||
await db.execute(
|
||||
delete(CalendarMember).where(
|
||||
CalendarMember.calendar_id.in_(a_cal_ids),
|
||||
CalendarMember.user_id == user_b_id,
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
text("""
|
||||
DELETE FROM event_locks
|
||||
WHERE locked_by = :user_id
|
||||
AND event_id IN (
|
||||
SELECT id FROM calendar_events WHERE calendar_id = ANY(:cal_ids)
|
||||
)
|
||||
"""),
|
||||
{"user_id": user_b_id, "cal_ids": a_cal_ids},
|
||||
)
|
||||
|
||||
# Delete user_a's memberships on user_b's calendars + locks
|
||||
if b_cal_ids:
|
||||
await db.execute(
|
||||
delete(CalendarMember).where(
|
||||
CalendarMember.calendar_id.in_(b_cal_ids),
|
||||
CalendarMember.user_id == user_a_id,
|
||||
)
|
||||
)
|
||||
await db.execute(
|
||||
text("""
|
||||
DELETE FROM event_locks
|
||||
WHERE locked_by = :user_id
|
||||
AND event_id IN (
|
||||
SELECT id FROM calendar_events WHERE calendar_id = ANY(:cal_ids)
|
||||
)
|
||||
"""),
|
||||
{"user_id": user_a_id, "cal_ids": b_cal_ids},
|
||||
)
|
||||
|
||||
# Reset is_shared on calendars with no remaining members
|
||||
all_cal_ids = a_cal_ids + b_cal_ids
|
||||
for cal_id in all_cal_ids:
|
||||
remaining = await db.execute(
|
||||
select(CalendarMember.id).where(CalendarMember.calendar_id == cal_id).limit(1)
|
||||
)
|
||||
if not remaining.scalar_one_or_none():
|
||||
cal_result = await db.execute(
|
||||
select(Calendar).where(Calendar.id == cal_id)
|
||||
)
|
||||
cal = cal_result.scalar_one_or_none()
|
||||
if cal:
|
||||
cal.is_shared = False
|
||||
@ -4,6 +4,9 @@ limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m;
|
||||
limit_req_zone $binary_remote_addr zone=register_limit:10m rate=5r/m;
|
||||
# Admin API — generous for legitimate use but still guards against scraping/brute-force
|
||||
limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=30r/m;
|
||||
# Calendar sharing endpoints
|
||||
limit_req_zone $binary_remote_addr zone=cal_invite_limit:10m rate=5r/m;
|
||||
limit_req_zone $binary_remote_addr zone=cal_sync_limit:10m rate=15r/m;
|
||||
# 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_request_limit:10m rate=3r/m;
|
||||
@ -99,6 +102,19 @@ server {
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
|
||||
# Calendar invite — rate-limited to prevent invite spam
|
||||
location ~ /api/shared-calendars/\d+/invite {
|
||||
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;
|
||||
limit_req_status 429;
|
||||
include /etc/nginx/proxy-params.conf;
|
||||
}
|
||||
# Admin API — rate-limited separately from general /api traffic
|
||||
location /api/admin/ {
|
||||
limit_req zone=admin_limit burst=10 nodelay;
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { X, User, ShieldCheck, Loader2 } from 'lucide-react';
|
||||
import { X, User, ShieldCheck, Share2, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAdminUserDetail, useUpdateRole, getErrorMessage } from '@/hooks/useAdmin';
|
||||
import { useAdminUserDetail, useAdminSharingStats, useUpdateRole, getErrorMessage } from '@/hooks/useAdmin';
|
||||
import { getRelativeTime } from '@/lib/date-utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UserRole } from '@/types';
|
||||
@ -57,6 +57,7 @@ function MfaBadge({ enabled, pending }: { enabled: boolean; pending: boolean })
|
||||
export default function UserDetailSection({ userId, onClose }: UserDetailSectionProps) {
|
||||
const { data: user, isLoading, error } = useAdminUserDetail(userId);
|
||||
const updateRole = useUpdateRole();
|
||||
const { data: sharingStats } = useAdminSharingStats(userId);
|
||||
|
||||
const handleRoleChange = async (newRole: UserRole) => {
|
||||
if (!user || newRole === user.role) return;
|
||||
@ -218,6 +219,24 @@ export default function UserDetailSection({ userId, onClose }: UserDetailSection
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sharing Stats */}
|
||||
<Card className="col-span-1">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-accent/10">
|
||||
<Share2 className="h-3.5 w-3.5 text-accent" />
|
||||
</div>
|
||||
<CardTitle className="text-sm">Sharing</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-0.5">
|
||||
<DetailRow label="Calendars Shared" value={String(sharingStats?.shared_calendars_owned ?? 0)} />
|
||||
<DetailRow label="Member Of" value={String(sharingStats?.calendars_member_of ?? 0)} />
|
||||
<DetailRow label="Invites Sent" value={String(sharingStats?.pending_invites_sent ?? 0)} />
|
||||
<DetailRow label="Invites Received" value={String(sharingStats?.pending_invites_received ?? 0)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, FormEvent, useCallback } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import type { Calendar } from '@/types';
|
||||
import type { Calendar, CalendarMemberInfo, CalendarPermission, Connection } from '@/types';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -14,6 +14,11 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import PermissionToggle from './PermissionToggle';
|
||||
import { useConnections } from '@/hooks/useConnections';
|
||||
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
|
||||
import CalendarMemberSearch from './CalendarMemberSearch';
|
||||
import CalendarMemberList from './CalendarMemberList';
|
||||
|
||||
interface CalendarFormProps {
|
||||
calendar: Calendar | null;
|
||||
@ -21,14 +26,8 @@ interface CalendarFormProps {
|
||||
}
|
||||
|
||||
const colorSwatches = [
|
||||
'#3b82f6', // blue
|
||||
'#ef4444', // red
|
||||
'#f97316', // orange
|
||||
'#eab308', // yellow
|
||||
'#22c55e', // green
|
||||
'#8b5cf6', // purple
|
||||
'#ec4899', // pink
|
||||
'#06b6d4', // cyan
|
||||
'#3b82f6', '#ef4444', '#f97316', '#eab308',
|
||||
'#22c55e', '#8b5cf6', '#ec4899', '#06b6d4',
|
||||
];
|
||||
|
||||
export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
|
||||
@ -36,6 +35,23 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
|
||||
const [name, setName] = useState(calendar?.name || '');
|
||||
const [color, setColor] = useState(calendar?.color || '#3b82f6');
|
||||
|
||||
const [pendingInvite, setPendingInvite] = useState<{ conn: Connection; permission: CalendarPermission } | null>(null);
|
||||
|
||||
const { connections } = useConnections();
|
||||
const { invite, isInviting, updateMember, removeMember } = useSharedCalendars();
|
||||
|
||||
const membersQuery = useQuery({
|
||||
queryKey: ['calendar-members', calendar?.id],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<CalendarMemberInfo[]>(
|
||||
`/shared-calendars/${calendar!.id}/members`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
enabled: !!calendar?.is_shared,
|
||||
});
|
||||
const members = membersQuery.data ?? [];
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (calendar) {
|
||||
@ -78,11 +94,42 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
|
||||
mutation.mutate();
|
||||
};
|
||||
|
||||
const handleSelectConnection = useCallback((conn: Connection) => {
|
||||
setPendingInvite({ conn, permission: 'read_only' });
|
||||
}, []);
|
||||
|
||||
const handleSendInvite = async () => {
|
||||
if (!calendar || !pendingInvite) return;
|
||||
await invite({
|
||||
calendarId: calendar.id,
|
||||
connectionId: pendingInvite.conn.id,
|
||||
permission: pendingInvite.permission,
|
||||
canAddOthers: false,
|
||||
});
|
||||
setPendingInvite(null);
|
||||
};
|
||||
|
||||
const handleUpdatePermission = async (memberId: number, permission: CalendarPermission) => {
|
||||
if (!calendar) return;
|
||||
await updateMember({ calendarId: calendar.id, memberId, permission });
|
||||
};
|
||||
|
||||
const handleUpdateCanAddOthers = async (memberId: number, canAddOthers: boolean) => {
|
||||
if (!calendar) return;
|
||||
await updateMember({ calendarId: calendar.id, memberId, canAddOthers });
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (memberId: number) => {
|
||||
if (!calendar) return;
|
||||
await removeMember({ calendarId: calendar.id, memberId });
|
||||
};
|
||||
|
||||
const canDelete = calendar && !calendar.is_default && !calendar.is_system;
|
||||
const showSharing = calendar && !calendar.is_system;
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogContent className={calendar?.is_shared && showSharing ? 'max-w-3xl' : undefined}>
|
||||
<DialogClose onClick={onClose} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{calendar ? 'Edit Calendar' : 'New Calendar'}</DialogTitle>
|
||||
@ -108,7 +155,7 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setColor(c)}
|
||||
className="h-8 w-8 rounded-full border-2 transition-all duration-150 hover:scale-110"
|
||||
className="h-6 w-6 rounded-full border-2 transition-all duration-150 hover:scale-110"
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
borderColor: color === c ? 'hsl(0 0% 98%)' : 'transparent',
|
||||
@ -119,6 +166,72 @@ export default function CalendarForm({ calendar, onClose }: CalendarFormProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSharing && (
|
||||
<>
|
||||
{calendar?.is_shared && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="mb-0">Members</Label>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
You (Owner)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{pendingInvite ? (
|
||||
<div
|
||||
className="rounded-lg border border-border bg-card-elevated p-4 space-y-3 animate-fade-in"
|
||||
style={{ borderLeftWidth: '3px', borderLeftColor: 'hsl(var(--accent-color) / 0.5)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{pendingInvite.conn.connected_preferred_name || pendingInvite.conn.connected_umbral_name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingInvite(null)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<PermissionToggle
|
||||
value={pendingInvite.permission}
|
||||
onChange={(p) => setPendingInvite((prev) => prev ? { ...prev, permission: p } : null)}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSendInvite}
|
||||
disabled={isInviting}
|
||||
>
|
||||
{isInviting ? 'Sending...' : 'Send Invite'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<CalendarMemberSearch
|
||||
connections={connections}
|
||||
existingMembers={members}
|
||||
onSelect={handleSelectConnection}
|
||||
isLoading={isInviting}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CalendarMemberList
|
||||
members={members}
|
||||
isLoading={membersQuery.isLoading}
|
||||
isOwner={true}
|
||||
onUpdatePermission={handleUpdatePermission}
|
||||
onUpdateCanAddOthers={handleUpdateCanAddOthers}
|
||||
onRemove={handleRemoveMember}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{canDelete && (
|
||||
<Button
|
||||
|
||||
55
frontend/src/components/calendar/CalendarMemberList.tsx
Normal file
55
frontend/src/components/calendar/CalendarMemberList.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { CalendarMemberInfo, CalendarPermission } from '@/types';
|
||||
import CalendarMemberRow from './CalendarMemberRow';
|
||||
|
||||
interface CalendarMemberListProps {
|
||||
members: CalendarMemberInfo[];
|
||||
isLoading?: boolean;
|
||||
isOwner: boolean;
|
||||
readOnly?: boolean;
|
||||
onUpdatePermission?: (memberId: number, permission: CalendarPermission) => void;
|
||||
onUpdateCanAddOthers?: (memberId: number, canAddOthers: boolean) => void;
|
||||
onRemove?: (memberId: number) => void;
|
||||
}
|
||||
|
||||
export default function CalendarMemberList({
|
||||
members,
|
||||
isLoading = false,
|
||||
isOwner,
|
||||
readOnly = false,
|
||||
onUpdatePermission,
|
||||
onUpdateCanAddOthers,
|
||||
onRemove,
|
||||
}: CalendarMemberListProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (members.length === 0) {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground py-2">
|
||||
Search your connections to add members
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 max-h-72 overflow-y-auto">
|
||||
{members.map((member) => (
|
||||
<CalendarMemberRow
|
||||
key={member.id}
|
||||
member={member}
|
||||
isOwner={isOwner}
|
||||
readOnly={readOnly}
|
||||
onUpdatePermission={onUpdatePermission}
|
||||
onUpdateCanAddOthers={onUpdateCanAddOthers}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
frontend/src/components/calendar/CalendarMemberRow.tsx
Normal file
86
frontend/src/components/calendar/CalendarMemberRow.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { X, UserPlus } from 'lucide-react';
|
||||
import type { CalendarMemberInfo, CalendarPermission } from '@/types';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||
import PermissionBadge from './PermissionBadge';
|
||||
import PermissionToggle from './PermissionToggle';
|
||||
|
||||
interface CalendarMemberRowProps {
|
||||
member: CalendarMemberInfo;
|
||||
isOwner: boolean;
|
||||
readOnly?: boolean;
|
||||
onUpdatePermission?: (memberId: number, permission: CalendarPermission) => void;
|
||||
onUpdateCanAddOthers?: (memberId: number, canAddOthers: boolean) => void;
|
||||
onRemove?: (memberId: number) => void;
|
||||
}
|
||||
|
||||
export default function CalendarMemberRow({
|
||||
member,
|
||||
isOwner,
|
||||
readOnly = false,
|
||||
onUpdatePermission,
|
||||
onUpdateCanAddOthers,
|
||||
onRemove,
|
||||
}: CalendarMemberRowProps) {
|
||||
const { confirming, handleClick: handleRemoveClick } = useConfirmAction(
|
||||
() => onRemove?.(member.id)
|
||||
);
|
||||
|
||||
const displayName = member.preferred_name || member.umbral_name;
|
||||
const initial = displayName.charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border p-3 transition-all duration-200 hover:border-border/80">
|
||||
<div className="h-8 w-8 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
|
||||
<span className="text-sm text-violet-400 font-medium">{initial}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1 truncate">
|
||||
<span className="text-sm font-medium truncate">{displayName}</span>
|
||||
{member.preferred_name && (
|
||||
<span className="text-xs text-violet-400 truncate shrink-0">{member.umbral_name}</span>
|
||||
)}
|
||||
{member.status === 'pending' && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded-full bg-orange-500/10 text-orange-400 font-medium shrink-0">
|
||||
Pending
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{readOnly ? (
|
||||
<PermissionBadge permission={member.permission} />
|
||||
) : isOwner ? (
|
||||
<div className="flex items-center gap-2.5 shrink-0">
|
||||
<PermissionToggle
|
||||
value={member.permission}
|
||||
onChange={(p) => onUpdatePermission?.(member.id, p)}
|
||||
/>
|
||||
{(member.permission === 'create_modify' || member.permission === 'full_access') && (
|
||||
<label className="flex items-center gap-1.5 cursor-pointer shrink-0" title="Can add others">
|
||||
<Checkbox
|
||||
checked={member.can_add_others}
|
||||
onChange={() => onUpdateCanAddOthers?.(member.id, !member.can_add_others)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<UserPlus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveClick}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
title={confirming ? 'Click again to confirm' : 'Remove member'}
|
||||
>
|
||||
{confirming ? (
|
||||
<span className="text-[10px] text-destructive font-medium px-1">Sure?</span>
|
||||
) : (
|
||||
<X className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<PermissionBadge permission={member.permission} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
frontend/src/components/calendar/CalendarMemberSearch.tsx
Normal file
103
frontend/src/components/calendar/CalendarMemberSearch.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Search, Loader2 } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { Connection, CalendarMemberInfo } from '@/types';
|
||||
|
||||
interface CalendarMemberSearchProps {
|
||||
connections: Connection[];
|
||||
existingMembers: CalendarMemberInfo[];
|
||||
onSelect: (connection: Connection) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export default function CalendarMemberSearch({
|
||||
connections,
|
||||
existingMembers,
|
||||
onSelect,
|
||||
isLoading = false,
|
||||
}: CalendarMemberSearchProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [focused, setFocused] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setFocused(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
|
||||
const existingUserIds = new Set(existingMembers.map((m) => m.user_id));
|
||||
|
||||
const filtered = connections.filter((c) => {
|
||||
if (existingUserIds.has(c.connected_user_id)) return false;
|
||||
if (!query.trim()) return true;
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
c.connected_umbral_name.toLowerCase().includes(q) ||
|
||||
(c.connected_preferred_name?.toLowerCase().includes(q) ?? false)
|
||||
);
|
||||
});
|
||||
|
||||
const handleSelect = (connection: Connection) => {
|
||||
onSelect(connection);
|
||||
setQuery('');
|
||||
setFocused(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div className="relative">
|
||||
{isLoading ? (
|
||||
<Loader2 className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground animate-spin" />
|
||||
) : (
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<Input
|
||||
placeholder="Search connections to invite..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => setFocused(true)}
|
||||
className="pl-8 h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{focused && filtered.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg overflow-hidden max-h-40 overflow-y-auto">
|
||||
{filtered.map((conn) => {
|
||||
const displayName = conn.connected_preferred_name || conn.connected_umbral_name;
|
||||
const initial = displayName.charAt(0).toUpperCase();
|
||||
return (
|
||||
<button
|
||||
key={conn.id}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => handleSelect(conn)}
|
||||
className="flex items-center gap-2.5 w-full px-3 py-2 text-sm text-left hover:bg-accent/10 transition-colors"
|
||||
>
|
||||
<div className="h-6 w-6 rounded-full bg-violet-500/15 flex items-center justify-center shrink-0">
|
||||
<span className="text-xs text-violet-400 font-medium">{initial}</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-medium truncate block">{displayName}</span>
|
||||
{conn.connected_preferred_name && (
|
||||
<span className="text-xs text-muted-foreground">{conn.connected_umbral_name}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{focused && query.trim() && filtered.length === 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg p-3">
|
||||
<p className="text-xs text-muted-foreground text-center">No matching connections</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
@ -9,7 +9,8 @@ import interactionPlugin from '@fullcalendar/interaction';
|
||||
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core';
|
||||
import { ChevronLeft, ChevronRight, Plus, Search } from 'lucide-react';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import type { CalendarEvent, EventTemplate, Location as LocationType } from '@/types';
|
||||
import axios from 'axios';
|
||||
import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types';
|
||||
import { useCalendars } from '@/hooks/useCalendars';
|
||||
import { useSettings } from '@/hooks/useSettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -42,9 +43,62 @@ export default function CalendarPage() {
|
||||
const [createDefaults, setCreateDefaults] = useState<CreateDefaults | null>(null);
|
||||
|
||||
const { settings } = useSettings();
|
||||
const { data: calendars = [] } = useCalendars();
|
||||
const { data: calendars = [], sharedData, allCalendarIds } = useCalendars({ pollingEnabled: true });
|
||||
const [visibleSharedIds, setVisibleSharedIds] = useState<Set<number>>(new Set());
|
||||
const calendarContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Resizable sidebar
|
||||
const SIDEBAR_STORAGE_KEY = 'umbra-calendar-sidebar-width';
|
||||
const SIDEBAR_MIN = 180;
|
||||
const SIDEBAR_MAX = 400;
|
||||
const SIDEBAR_DEFAULT = 224; // w-56
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const saved = localStorage.getItem(SIDEBAR_STORAGE_KEY);
|
||||
if (saved) {
|
||||
const n = parseInt(saved, 10);
|
||||
if (!isNaN(n) && n >= SIDEBAR_MIN && n <= SIDEBAR_MAX) return n;
|
||||
}
|
||||
return SIDEBAR_DEFAULT;
|
||||
});
|
||||
const isResizingRef = useRef(false);
|
||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleSidebarMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isResizingRef.current = true;
|
||||
const startX = e.clientX;
|
||||
const startWidth = sidebarWidth;
|
||||
let latestWidth = startWidth;
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
latestWidth = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, startWidth + (ev.clientX - startX)));
|
||||
// Direct DOM mutation — bypasses React entirely during drag, zero re-renders
|
||||
if (sidebarRef.current) {
|
||||
sidebarRef.current.style.width = latestWidth + 'px';
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
isResizingRef.current = false;
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
// Single React state commit on release — triggers localStorage persist + final reconciliation
|
||||
setSidebarWidth(latestWidth);
|
||||
};
|
||||
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
}, [sidebarWidth]);
|
||||
|
||||
// Persist sidebar width on change
|
||||
useEffect(() => {
|
||||
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(sidebarWidth));
|
||||
}, [sidebarWidth]);
|
||||
|
||||
// Location data for event panel
|
||||
const { data: locations = [] } = useQuery({
|
||||
queryKey: ['locations'],
|
||||
@ -61,6 +115,22 @@ export default function CalendarPage() {
|
||||
return map;
|
||||
}, [locations]);
|
||||
|
||||
// Build permission map: calendar_id -> permission level
|
||||
const permissionMap = useMemo(() => {
|
||||
const map = new Map<number, CalendarPermission | 'owner'>();
|
||||
calendars.forEach((cal) => map.set(cal.id, 'owner'));
|
||||
sharedData.forEach((m) => map.set(m.calendar_id, m.permission));
|
||||
return map;
|
||||
}, [calendars, sharedData]);
|
||||
|
||||
// Set of calendar IDs that are shared (owned or membership)
|
||||
const sharedCalendarIds = useMemo(() => {
|
||||
const ids = new Set<number>();
|
||||
calendars.forEach((cal) => { if (cal.is_shared) ids.add(cal.id); });
|
||||
sharedData.forEach((m) => ids.add(m.calendar_id));
|
||||
return ids;
|
||||
}, [calendars, sharedData]);
|
||||
|
||||
// Handle navigation state from dashboard
|
||||
useEffect(() => {
|
||||
const state = location.state as { date?: string; view?: string; eventId?: number } | null;
|
||||
@ -90,6 +160,15 @@ export default function CalendarPage() {
|
||||
|
||||
const panelOpen = panelMode !== 'closed';
|
||||
|
||||
// Track desktop breakpoint to prevent dual EventDetailPanel mount
|
||||
const [isDesktop, setIsDesktop] = useState(() => window.matchMedia('(min-width: 1024px)').matches);
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia('(min-width: 1024px)');
|
||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
// Continuously resize calendar during panel open/close CSS transition
|
||||
useEffect(() => {
|
||||
let rafId: number;
|
||||
@ -131,6 +210,7 @@ export default function CalendarPage() {
|
||||
const { data } = await api.get<CalendarEvent[]>('/events');
|
||||
return data;
|
||||
},
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
|
||||
const selectedEvent = useMemo(
|
||||
@ -138,6 +218,18 @@ export default function CalendarPage() {
|
||||
[selectedEventId, events],
|
||||
);
|
||||
|
||||
const selectedEventPermission = selectedEvent ? permissionMap.get(selectedEvent.calendar_id) ?? null : null;
|
||||
const selectedEventIsShared = selectedEvent ? sharedCalendarIds.has(selectedEvent.calendar_id) : false;
|
||||
|
||||
// Close panel if shared calendar was removed while viewing
|
||||
useEffect(() => {
|
||||
if (!selectedEvent || allCalendarIds.size === 0) return;
|
||||
if (!allCalendarIds.has(selectedEvent.calendar_id)) {
|
||||
handlePanelClose();
|
||||
toast.info('This calendar is no longer available');
|
||||
}
|
||||
}, [allCalendarIds, selectedEvent]);
|
||||
|
||||
// Escape key closes detail panel
|
||||
useEffect(() => {
|
||||
if (!panelOpen) return;
|
||||
@ -149,8 +241,11 @@ export default function CalendarPage() {
|
||||
}, [panelOpen]);
|
||||
|
||||
const visibleCalendarIds = useMemo(
|
||||
() => new Set(calendars.filter((c) => c.is_visible).map((c) => c.id)),
|
||||
[calendars],
|
||||
() => {
|
||||
const owned = calendars.filter((c) => c.is_visible).map((c) => c.id);
|
||||
return new Set([...owned, ...visibleSharedIds]);
|
||||
},
|
||||
[calendars, visibleSharedIds],
|
||||
);
|
||||
|
||||
const toLocalDatetime = (d: Date): string => {
|
||||
@ -202,7 +297,11 @@ export default function CalendarPage() {
|
||||
onError: (error, variables) => {
|
||||
variables.revert();
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||
toast.error(getErrorMessage(error, 'Failed to update event'));
|
||||
if (axios.isAxiosError(error) && error.response?.status === 423) {
|
||||
toast.error('Event is locked by another user');
|
||||
} else {
|
||||
toast.error(getErrorMessage(error, 'Failed to update event'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -246,10 +345,12 @@ export default function CalendarPage() {
|
||||
allDay: event.all_day,
|
||||
backgroundColor: event.calendar_color || 'hsl(var(--accent-color))',
|
||||
borderColor: event.calendar_color || 'hsl(var(--accent-color))',
|
||||
editable: permissionMap.get(event.calendar_id) !== 'read_only',
|
||||
extendedProps: {
|
||||
is_virtual: event.is_virtual,
|
||||
is_recurring: event.is_recurring,
|
||||
parent_event_id: event.parent_event_id,
|
||||
calendar_id: event.calendar_id,
|
||||
},
|
||||
}));
|
||||
|
||||
@ -274,6 +375,11 @@ export default function CalendarPage() {
|
||||
toast.info('Click the event to edit recurring events');
|
||||
return;
|
||||
}
|
||||
if (permissionMap.get(info.event.extendedProps.calendar_id) === 'read_only') {
|
||||
info.revert();
|
||||
toast.error('You have read-only access to this calendar');
|
||||
return;
|
||||
}
|
||||
const id = parseInt(info.event.id);
|
||||
const start = info.event.allDay
|
||||
? info.event.startStr
|
||||
@ -298,6 +404,11 @@ export default function CalendarPage() {
|
||||
toast.info('Click the event to edit recurring events');
|
||||
return;
|
||||
}
|
||||
if (permissionMap.get(info.event.extendedProps.calendar_id) === 'read_only') {
|
||||
info.revert();
|
||||
toast.error('You have read-only access to this calendar');
|
||||
return;
|
||||
}
|
||||
const id = parseInt(info.event.id);
|
||||
const start = info.event.allDay
|
||||
? info.event.startStr
|
||||
@ -364,7 +475,11 @@ export default function CalendarPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden animate-fade-in">
|
||||
<CalendarSidebar onUseTemplate={handleUseTemplate} />
|
||||
<CalendarSidebar ref={sidebarRef} onUseTemplate={handleUseTemplate} onSharedVisibilityChange={setVisibleSharedIds} width={sidebarWidth} />
|
||||
<div
|
||||
onMouseDown={handleSidebarMouseDown}
|
||||
className="w-1 shrink-0 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors duration-150"
|
||||
/>
|
||||
|
||||
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Custom toolbar */}
|
||||
@ -482,27 +597,27 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
|
||||
{/* Detail panel (desktop) */}
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
|
||||
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<EventDetailPanel
|
||||
event={panelMode === 'view' ? selectedEvent : null}
|
||||
isCreating={panelMode === 'create'}
|
||||
createDefaults={createDefaults}
|
||||
onClose={handlePanelClose}
|
||||
onSaved={handlePanelClose}
|
||||
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
|
||||
/>
|
||||
</div>
|
||||
{panelOpen && isDesktop && (
|
||||
<div className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]">
|
||||
<EventDetailPanel
|
||||
event={panelMode === 'view' ? selectedEvent : null}
|
||||
isCreating={panelMode === 'create'}
|
||||
createDefaults={createDefaults}
|
||||
onClose={handlePanelClose}
|
||||
onSaved={handlePanelClose}
|
||||
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
|
||||
myPermission={selectedEventPermission}
|
||||
isSharedEvent={selectedEventIsShared}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile detail panel overlay */}
|
||||
{panelOpen && (
|
||||
{panelOpen && !isDesktop && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
|
||||
onClick={handlePanelClose}
|
||||
>
|
||||
<div
|
||||
@ -516,6 +631,8 @@ export default function CalendarPage() {
|
||||
onClose={handlePanelClose}
|
||||
onSaved={handlePanelClose}
|
||||
locationName={selectedEvent?.location_id ? locationMap.get(selectedEvent.location_id) : undefined}
|
||||
myPermission={selectedEventPermission}
|
||||
isSharedEvent={selectedEventIsShared}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useCallback, forwardRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Pencil, Trash2, FileText } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@ -9,19 +9,42 @@ import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import CalendarForm from './CalendarForm';
|
||||
import TemplateForm from './TemplateForm';
|
||||
import SharedCalendarSection, { loadVisibility, saveVisibility } from './SharedCalendarSection';
|
||||
|
||||
interface CalendarSidebarProps {
|
||||
onUseTemplate?: (template: EventTemplate) => void;
|
||||
onSharedVisibilityChange?: (visibleIds: Set<number>) => void;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps) {
|
||||
const CalendarSidebar = forwardRef<HTMLDivElement, CalendarSidebarProps>(function CalendarSidebar({ onUseTemplate, onSharedVisibilityChange, width }, ref) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: calendars = [] } = useCalendars();
|
||||
const { data: calendars = [], sharedData: sharedCalendars = [] } = useCalendars();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingCalendar, setEditingCalendar] = useState<Calendar | null>(null);
|
||||
const [showTemplateForm, setShowTemplateForm] = useState(false);
|
||||
const [editingTemplate, setEditingTemplate] = useState<EventTemplate | null>(null);
|
||||
|
||||
const [sharedVisibility, setSharedVisibility] = useState<Record<number, boolean>>(() => loadVisibility());
|
||||
|
||||
const visibleSharedIds = new Set(
|
||||
sharedCalendars
|
||||
.filter((m) => sharedVisibility[m.calendar_id] !== false)
|
||||
.map((m) => m.calendar_id)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onSharedVisibilityChange?.(visibleSharedIds);
|
||||
}, [sharedCalendars, sharedVisibility]);
|
||||
|
||||
const handleSharedVisibilityChange = useCallback((calendarId: number, visible: boolean) => {
|
||||
setSharedVisibility((prev) => {
|
||||
const next = { ...prev, [calendarId]: visible };
|
||||
saveVisibility(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { data: templates = [] } = useQuery({
|
||||
queryKey: ['event-templates'],
|
||||
queryFn: async () => {
|
||||
@ -71,7 +94,7 @@ export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps)
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-56 shrink-0 border-r bg-card flex flex-col">
|
||||
<div ref={ref} className="shrink-0 border-r bg-card flex flex-col" style={{ width }}>
|
||||
<div className="h-16 px-4 border-b flex items-center justify-between shrink-0">
|
||||
<span className="text-sm font-semibold font-heading text-foreground">Calendars</span>
|
||||
<Button
|
||||
@ -84,9 +107,9 @@ export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps)
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-4">
|
||||
{/* Calendars list */}
|
||||
{/* Owned calendars list (non-shared only) */}
|
||||
<div className="space-y-0.5">
|
||||
{calendars.map((cal) => (
|
||||
{calendars.filter((c) => !c.is_shared).map((cal) => (
|
||||
<div
|
||||
key={cal.id}
|
||||
className="group flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-card-elevated transition-colors duration-150"
|
||||
@ -116,6 +139,18 @@ export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps)
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Shared calendars section -- owned + member */}
|
||||
{(calendars.some((c) => c.is_shared) || sharedCalendars.length > 0) && (
|
||||
<SharedCalendarSection
|
||||
ownedSharedCalendars={calendars.filter((c) => c.is_shared)}
|
||||
memberships={sharedCalendars}
|
||||
visibleSharedIds={visibleSharedIds}
|
||||
onVisibilityChange={handleSharedVisibilityChange}
|
||||
onEditCalendar={handleEdit}
|
||||
onToggleCalendar={handleToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Templates section */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
@ -185,4 +220,6 @@ export default function CalendarSidebar({ onUseTemplate }: CalendarSidebarProps)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default CalendarSidebar;
|
||||
@ -3,14 +3,17 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import {
|
||||
X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar,
|
||||
X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar, Loader2,
|
||||
} from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import type { CalendarEvent, Location as LocationType, RecurrenceRule } from '@/types';
|
||||
import type { CalendarEvent, Location as LocationType, RecurrenceRule, CalendarPermission, EventLockInfo } from '@/types';
|
||||
import { useCalendars } from '@/hooks/useCalendars';
|
||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||
import { useEventLock } from '@/hooks/useEventLock';
|
||||
import { formatUpdatedAt } from '@/components/shared/utils';
|
||||
import CopyableField from '@/components/shared/CopyableField';
|
||||
import EventLockBanner from './EventLockBanner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
@ -126,6 +129,8 @@ interface EventDetailPanelProps {
|
||||
onSaved?: () => void;
|
||||
onDeleted?: () => void;
|
||||
locationName?: string;
|
||||
myPermission?: CalendarPermission | 'owner' | null;
|
||||
isSharedEvent?: boolean;
|
||||
}
|
||||
|
||||
interface EditState {
|
||||
@ -218,10 +223,17 @@ export default function EventDetailPanel({
|
||||
onSaved,
|
||||
onDeleted,
|
||||
locationName,
|
||||
myPermission,
|
||||
isSharedEvent = false,
|
||||
}: EventDetailPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: calendars = [] } = useCalendars();
|
||||
const selectableCalendars = calendars.filter((c) => !c.is_system);
|
||||
const { data: calendars = [], sharedData: sharedMemberships = [] } = useCalendars();
|
||||
const selectableCalendars = [
|
||||
...calendars.filter((c) => !c.is_system),
|
||||
...sharedMemberships
|
||||
.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 defaultCalendar = calendars.find((c) => c.is_default);
|
||||
|
||||
const { data: locations = [] } = useQuery({
|
||||
@ -233,6 +245,12 @@ export default function EventDetailPanel({
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const { acquire: acquireLock, release: releaseLock, isAcquiring: isAcquiringLock } = useEventLock(
|
||||
isSharedEvent && event ? (typeof event.id === 'number' ? event.id : null) : null
|
||||
);
|
||||
const [lockInfo, setLockInfo] = useState<EventLockInfo | null>(null);
|
||||
|
||||
|
||||
const [isEditing, setIsEditing] = useState(isCreating);
|
||||
const [editState, setEditState] = useState<EditState>(() =>
|
||||
isCreating
|
||||
@ -245,14 +263,47 @@ export default function EventDetailPanel({
|
||||
const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null);
|
||||
const [locationSearch, setLocationSearch] = useState('');
|
||||
|
||||
// Poll lock status in view mode for shared events (Stream A: real-time lock awareness)
|
||||
// lockInfo is only set from the 423 error path; poll data (viewLockQuery.data) is used directly.
|
||||
const viewLockQuery = useQuery({
|
||||
queryKey: ['event-lock', event?.id],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<EventLockInfo>(
|
||||
`/shared-calendars/events/${event!.id}/lock`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
enabled: !!isSharedEvent && !!event && typeof event.id === 'number' && !isEditing && !isCreating,
|
||||
refetchInterval: 5_000,
|
||||
refetchIntervalInBackground: true,
|
||||
refetchOnMount: 'always',
|
||||
});
|
||||
|
||||
// Clear 423-error lockInfo when poll confirms lock is gone
|
||||
useEffect(() => {
|
||||
if (viewLockQuery.data && !viewLockQuery.data.locked) {
|
||||
setLockInfo(null);
|
||||
}
|
||||
}, [viewLockQuery.data]);
|
||||
|
||||
// Derived: authoritative lock state — poll data wins, 423 error lockInfo as fallback
|
||||
const activeLockInfo: EventLockInfo | null =
|
||||
(viewLockQuery.data?.locked ? viewLockQuery.data : null) ??
|
||||
(lockInfo?.locked ? lockInfo : null);
|
||||
|
||||
const isRecurring = !!(event?.is_recurring || event?.parent_event_id);
|
||||
|
||||
// Permission helpers
|
||||
const canEdit = !isSharedEvent || myPermission === 'owner' || myPermission === 'create_modify' || myPermission === 'full_access';
|
||||
const canDelete = !isSharedEvent || myPermission === 'owner' || myPermission === 'full_access';
|
||||
|
||||
// Reset state when event changes
|
||||
useEffect(() => {
|
||||
setIsEditing(false);
|
||||
setScopeStep(null);
|
||||
setEditScope(null);
|
||||
setLocationSearch('');
|
||||
setLockInfo(null);
|
||||
if (event) setEditState(buildEditStateFromEvent(event));
|
||||
}, [event?.id]);
|
||||
|
||||
@ -307,15 +358,16 @@ export default function EventDetailPanel({
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (isSharedEvent) releaseLock();
|
||||
invalidateAll();
|
||||
toast.success(isCreating ? 'Event created' : 'Event updated');
|
||||
if (isCreating) {
|
||||
onClose();
|
||||
onSaved?.();
|
||||
} else {
|
||||
setIsEditing(false);
|
||||
setEditScope(null);
|
||||
}
|
||||
onSaved?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error, isCreating ? 'Failed to create event' : 'Failed to update event'));
|
||||
@ -343,7 +395,28 @@ export default function EventDetailPanel({
|
||||
|
||||
// --- Handlers ---
|
||||
|
||||
const handleEditStart = () => {
|
||||
const handleEditStart = async () => {
|
||||
// For shared events, acquire lock first
|
||||
if (isSharedEvent && event && typeof event.id === 'number') {
|
||||
try {
|
||||
await acquireLock();
|
||||
setLockInfo(null);
|
||||
} catch (err: unknown) {
|
||||
if (axios.isAxiosError(err) && err.response?.status === 423) {
|
||||
const data = err.response.data as { locked_by_name?: string; expires_at?: string; is_permanent?: boolean } | undefined;
|
||||
setLockInfo({
|
||||
locked: true,
|
||||
locked_by_name: data?.locked_by_name || 'another user',
|
||||
expires_at: data?.expires_at || null,
|
||||
is_permanent: data?.is_permanent || false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.error('Failed to acquire edit lock');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRecurring) {
|
||||
setScopeStep('edit');
|
||||
} else {
|
||||
@ -361,8 +434,6 @@ export default function EventDetailPanel({
|
||||
} else if (scopeStep === 'delete') {
|
||||
// Delete with scope — execute immediately
|
||||
setScopeStep(null);
|
||||
// The deleteMutation will read editScope, but we need to set it first
|
||||
// Since setState is async, use the mutation directly with the scope
|
||||
const scopeParam = `?scope=${scope}`;
|
||||
api.delete(`/events/${event!.id}${scopeParam}`).then(() => {
|
||||
invalidateAll();
|
||||
@ -376,6 +447,7 @@ export default function EventDetailPanel({
|
||||
};
|
||||
|
||||
const handleEditCancel = () => {
|
||||
if (isSharedEvent) releaseLock();
|
||||
setIsEditing(false);
|
||||
setEditScope(null);
|
||||
setLocationSearch('');
|
||||
@ -507,37 +579,42 @@ export default function EventDetailPanel({
|
||||
<>
|
||||
{!event?.is_virtual && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={handleEditStart}
|
||||
title="Edit event"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{confirmingDelete ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleDeleteStart}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
|
||||
title="Confirm delete"
|
||||
>
|
||||
Sure?
|
||||
</Button>
|
||||
) : (
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={handleDeleteStart}
|
||||
disabled={deleteMutation.isPending}
|
||||
title="Delete event"
|
||||
className="h-7 w-7"
|
||||
onClick={handleEditStart}
|
||||
disabled={isAcquiringLock || !!activeLockInfo}
|
||||
title={activeLockInfo ? `Locked by ${activeLockInfo.locked_by_name || 'another user'}` : 'Edit event'}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{isAcquiringLock ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Pencil className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
confirmingDelete ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleDeleteStart}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
|
||||
title="Confirm delete"
|
||||
>
|
||||
Sure?
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={handleDeleteStart}
|
||||
disabled={deleteMutation.isPending}
|
||||
title="Delete event"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
@ -557,6 +634,15 @@ export default function EventDetailPanel({
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
|
||||
{/* Lock banner — shown when activeLockInfo reports a lock (poll-authoritative) */}
|
||||
{activeLockInfo && (
|
||||
<EventLockBanner
|
||||
lockedByName={activeLockInfo.locked_by_name || 'another user'}
|
||||
expiresAt={activeLockInfo.expires_at}
|
||||
isPermanent={activeLockInfo.is_permanent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{scopeStep ? (
|
||||
/* Scope selection step */
|
||||
<div className="space-y-3">
|
||||
|
||||
30
frontend/src/components/calendar/EventLockBanner.tsx
Normal file
30
frontend/src/components/calendar/EventLockBanner.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { Lock } from 'lucide-react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
interface EventLockBannerProps {
|
||||
lockedByName: string;
|
||||
expiresAt: string | null;
|
||||
isPermanent?: boolean;
|
||||
}
|
||||
|
||||
export default function EventLockBanner({ lockedByName, expiresAt, isPermanent = false }: EventLockBannerProps) {
|
||||
return (
|
||||
<div className="rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2 mb-3 animate-fade-in">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="h-4 w-4 text-amber-400 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm text-amber-200">
|
||||
{isPermanent
|
||||
? `Locked by owner (${lockedByName})`
|
||||
: `Locked for editing by ${lockedByName}`}
|
||||
</p>
|
||||
{!isPermanent && expiresAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lock expires at {(() => { try { return format(parseISO(expiresAt), 'h:mm a'); } catch { return 'unknown'; } })()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/calendar/PermissionBadge.tsx
Normal file
24
frontend/src/components/calendar/PermissionBadge.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { Eye, Pencil, Shield } from 'lucide-react';
|
||||
import type { CalendarPermission } from '@/types';
|
||||
|
||||
const config: Record<CalendarPermission, { label: string; icon: typeof Eye; bg: string; text: string }> = {
|
||||
read_only: { label: 'Read Only', icon: Eye, bg: 'bg-blue-500/10', text: 'text-blue-400' },
|
||||
create_modify: { label: 'Create/Modify', icon: Pencil, bg: 'bg-amber-500/10', text: 'text-amber-400' },
|
||||
full_access: { label: 'Full Access', icon: Shield, bg: 'bg-green-500/10', text: 'text-green-400' },
|
||||
};
|
||||
|
||||
interface PermissionBadgeProps {
|
||||
permission: CalendarPermission;
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
export default function PermissionBadge({ permission, showIcon = true }: PermissionBadgeProps) {
|
||||
const c = config[permission];
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-[9px] px-1.5 py-0.5 rounded-full font-medium ${c.bg} ${c.text}`}>
|
||||
{showIcon && <Icon className="h-3 w-3" />}
|
||||
{c.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/calendar/PermissionToggle.tsx
Normal file
46
frontend/src/components/calendar/PermissionToggle.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { Eye, Pencil, Shield } from 'lucide-react';
|
||||
import type { CalendarPermission } from '@/types';
|
||||
|
||||
const segments: { value: CalendarPermission; label: string; shortLabel: string; icon: typeof Eye }[] = [
|
||||
{ value: 'read_only', label: 'Read Only', shortLabel: 'Read', icon: Eye },
|
||||
{ value: 'create_modify', label: 'Create & Modify', shortLabel: 'Edit', icon: Pencil },
|
||||
{ value: 'full_access', label: 'Full Access', shortLabel: 'Full', icon: Shield },
|
||||
];
|
||||
|
||||
interface PermissionToggleProps {
|
||||
value: CalendarPermission;
|
||||
onChange: (permission: CalendarPermission) => void;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PermissionToggle({ value, onChange, compact = false, className = '' }: PermissionToggleProps) {
|
||||
return (
|
||||
<div className={`inline-flex items-center rounded-md border border-border overflow-hidden ${className}`}>
|
||||
{segments.map((seg) => {
|
||||
const isActive = value === seg.value;
|
||||
const Icon = seg.icon;
|
||||
return (
|
||||
<button
|
||||
key={seg.value}
|
||||
type="button"
|
||||
onClick={() => onChange(seg.value)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium transition-colors duration-150 ${
|
||||
isActive
|
||||
? 'text-accent'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: isActive ? 'hsl(var(--accent-color) / 0.15)' : undefined,
|
||||
color: isActive ? 'hsl(var(--accent-color))' : undefined,
|
||||
}}
|
||||
title={seg.label}
|
||||
>
|
||||
<Icon className="h-3 w-3 shrink-0" />
|
||||
{!compact && <span>{seg.shortLabel}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
frontend/src/components/calendar/SharedCalendarSection.tsx
Normal file
127
frontend/src/components/calendar/SharedCalendarSection.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import { useState } from 'react';
|
||||
import { Ghost, Pencil } from 'lucide-react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import type { Calendar, SharedCalendarMembership } from '@/types';
|
||||
import SharedCalendarSettings from './SharedCalendarSettings';
|
||||
|
||||
const STORAGE_KEY = 'umbra_shared_cal_visibility';
|
||||
|
||||
function loadVisibility(): Record<number, boolean> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveVisibility(v: Record<number, boolean>) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(v));
|
||||
}
|
||||
|
||||
interface SharedCalendarSectionProps {
|
||||
ownedSharedCalendars?: Calendar[];
|
||||
memberships: SharedCalendarMembership[];
|
||||
visibleSharedIds: Set<number>;
|
||||
onVisibilityChange: (calendarId: number, visible: boolean) => void;
|
||||
onEditCalendar?: (calendar: Calendar) => void;
|
||||
onToggleCalendar?: (calendar: Calendar) => void;
|
||||
}
|
||||
|
||||
export default function SharedCalendarSection({
|
||||
ownedSharedCalendars = [],
|
||||
memberships,
|
||||
visibleSharedIds,
|
||||
onVisibilityChange,
|
||||
onEditCalendar,
|
||||
onToggleCalendar,
|
||||
}: SharedCalendarSectionProps) {
|
||||
const [settingsFor, setSettingsFor] = useState<SharedCalendarMembership | null>(null);
|
||||
|
||||
if (memberships.length === 0 && ownedSharedCalendars.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5 px-2">
|
||||
<Ghost className="h-3.5 w-3.5 text-violet-400 shrink-0" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Shared Calendars
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{ownedSharedCalendars.map((cal) => (
|
||||
<div
|
||||
key={`owned-${cal.id}`}
|
||||
className="group flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-card-elevated transition-colors duration-150"
|
||||
>
|
||||
<Checkbox
|
||||
checked={cal.is_visible}
|
||||
onChange={() => onToggleCalendar?.(cal)}
|
||||
className="shrink-0"
|
||||
style={{
|
||||
accentColor: cal.color,
|
||||
borderColor: cal.is_visible ? cal.color : undefined,
|
||||
backgroundColor: cal.is_visible ? cal.color : undefined,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: cal.color }}
|
||||
/>
|
||||
<span className="text-sm text-foreground truncate flex-1">{cal.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">Owner</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEditCalendar?.(cal)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{memberships.map((m) => {
|
||||
const color = m.local_color || m.calendar_color;
|
||||
const isVisible = visibleSharedIds.has(m.calendar_id);
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className="group flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-card-elevated transition-colors duration-150"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isVisible}
|
||||
onChange={() => onVisibilityChange(m.calendar_id, !isVisible)}
|
||||
className="shrink-0"
|
||||
style={{
|
||||
accentColor: color,
|
||||
borderColor: isVisible ? color : undefined,
|
||||
backgroundColor: isVisible ? color : undefined,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-sm text-foreground truncate flex-1">{m.calendar_name}</span>
|
||||
<button
|
||||
onClick={() => setSettingsFor(m)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity duration-150 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settingsFor && (
|
||||
<SharedCalendarSettings
|
||||
membership={settingsFor}
|
||||
onClose={() => setSettingsFor(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { STORAGE_KEY, loadVisibility, saveVisibility };
|
||||
145
frontend/src/components/calendar/SharedCalendarSettings.tsx
Normal file
145
frontend/src/components/calendar/SharedCalendarSettings.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import { useState } from 'react';
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import type { SharedCalendarMembership, CalendarMemberInfo, Connection } from '@/types';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useConfirmAction } from '@/hooks/useConfirmAction';
|
||||
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
|
||||
import { useConnections } from '@/hooks/useConnections';
|
||||
import PermissionBadge from './PermissionBadge';
|
||||
import CalendarMemberList from './CalendarMemberList';
|
||||
import CalendarMemberSearch from './CalendarMemberSearch';
|
||||
|
||||
const colorSwatches = [
|
||||
'#3b82f6', '#ef4444', '#f97316', '#eab308',
|
||||
'#22c55e', '#8b5cf6', '#ec4899', '#06b6d4',
|
||||
];
|
||||
|
||||
interface SharedCalendarSettingsProps {
|
||||
membership: SharedCalendarMembership;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SharedCalendarSettings({ membership, onClose }: SharedCalendarSettingsProps) {
|
||||
const [localColor, setLocalColor] = useState(membership.local_color || membership.calendar_color);
|
||||
const { updateColor, leaveCalendar, invite, isInviting } = useSharedCalendars();
|
||||
const { connections } = useConnections();
|
||||
|
||||
const membersQuery = useQuery({
|
||||
queryKey: ['calendar-members', membership.calendar_id],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<CalendarMemberInfo[]>(
|
||||
`/shared-calendars/${membership.calendar_id}/members`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
const members = membersQuery.data ?? [];
|
||||
|
||||
const { confirming: leaveConfirming, handleClick: handleLeaveClick } = useConfirmAction(
|
||||
async () => {
|
||||
await leaveCalendar({ calendarId: membership.calendar_id, memberId: membership.id });
|
||||
onClose();
|
||||
}
|
||||
);
|
||||
|
||||
const handleColorSelect = async (color: string) => {
|
||||
setLocalColor(color);
|
||||
await updateColor({ calendarId: membership.calendar_id, localColor: color });
|
||||
};
|
||||
|
||||
const handleInvite = async (conn: Connection) => {
|
||||
await invite({
|
||||
calendarId: membership.calendar_id,
|
||||
connectionId: conn.id,
|
||||
permission: 'read_only',
|
||||
canAddOthers: false,
|
||||
});
|
||||
membersQuery.refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogClose onClick={onClose} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>Shared Calendar Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">{membership.calendar_name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-muted-foreground">Your permission:</span>
|
||||
<PermissionBadge permission={membership.permission} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Your Color</Label>
|
||||
<div className="flex gap-2">
|
||||
{colorSwatches.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => handleColorSelect(c)}
|
||||
className="h-6 w-6 rounded-full border-2 transition-all duration-150 hover:scale-110"
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
borderColor: localColor === c ? 'hsl(0 0% 98%)' : 'transparent',
|
||||
boxShadow: localColor === c ? `0 0 0 2px ${c}40` : 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Members ({members.length})</Label>
|
||||
<CalendarMemberList
|
||||
members={members}
|
||||
isLoading={membersQuery.isLoading}
|
||||
isOwner={false}
|
||||
readOnly={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{membership.can_add_others && (
|
||||
<div className="space-y-2">
|
||||
<Label>Add Members</Label>
|
||||
<CalendarMemberSearch
|
||||
connections={connections}
|
||||
existingMembers={members}
|
||||
onSelect={handleInvite}
|
||||
isLoading={isInviting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-border">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleLeaveClick}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{leaveConfirming ? 'Sure?' : 'Leave Calendar'}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Check, X, Bell, UserPlus } from 'lucide-react';
|
||||
import { Check, X, Bell, UserPlus, Calendar } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { useConnections } from '@/hooks/useConnections';
|
||||
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
|
||||
import axios from 'axios';
|
||||
import { getErrorMessage } from '@/lib/api';
|
||||
import type { AppNotification } from '@/types';
|
||||
@ -11,6 +12,7 @@ import type { AppNotification } from '@/types';
|
||||
export default function NotificationToaster() {
|
||||
const { notifications, unreadCount, markRead } = useNotifications();
|
||||
const { respond } = useConnections();
|
||||
const { respondInvite } = useSharedCalendars();
|
||||
const queryClient = useQueryClient();
|
||||
const maxSeenIdRef = useRef(0);
|
||||
const initializedRef = useRef(false);
|
||||
@ -18,7 +20,9 @@ export default function NotificationToaster() {
|
||||
// Track in-flight request IDs so repeated clicks are blocked
|
||||
const respondingRef = useRef<Set<number>>(new Set());
|
||||
// Always call the latest respond — Sonner toasts capture closures at creation time
|
||||
const respondInviteRef = useRef(respondInvite);
|
||||
const respondRef = useRef(respond);
|
||||
respondInviteRef.current = respondInvite;
|
||||
respondRef.current = respond;
|
||||
const markReadRef = useRef(markRead);
|
||||
markReadRef.current = markRead;
|
||||
@ -56,6 +60,34 @@ 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);
|
||||
|
||||
toast.dismiss(toastId);
|
||||
const loadingId = toast.loading(
|
||||
action === 'accept' ? 'Accepting calendar invite\u2026' : 'Declining invite\u2026',
|
||||
);
|
||||
|
||||
try {
|
||||
await respondInviteRef.current({ inviteId, action });
|
||||
toast.dismiss(loadingId);
|
||||
markReadRef.current([notificationId]).catch(() => {});
|
||||
} catch (err) {
|
||||
toast.dismiss(loadingId);
|
||||
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
||||
markReadRef.current([notificationId]).catch(() => {});
|
||||
} else {
|
||||
toast.error(getErrorMessage(err, 'Failed to respond to invite'));
|
||||
}
|
||||
} finally {
|
||||
respondingRef.current.delete(inviteId + 100000);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
// Track unread count changes to force-refetch the list
|
||||
useEffect(() => {
|
||||
if (unreadCount > prevUnreadRef.current && initializedRef.current) {
|
||||
@ -91,11 +123,16 @@ export default function NotificationToaster() {
|
||||
if (newNotifications.some((n) => n.type === 'connection_request')) {
|
||||
queryClient.invalidateQueries({ queryKey: ['connections', 'incoming'] });
|
||||
}
|
||||
if (newNotifications.some((n) => n.type === 'calendar_invite')) {
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] });
|
||||
}
|
||||
|
||||
// Show toasts
|
||||
newNotifications.forEach((notification) => {
|
||||
if (notification.type === 'connection_request' && notification.source_id) {
|
||||
showConnectionRequestToast(notification);
|
||||
} else if (notification.type === 'calendar_invite' && notification.source_id) {
|
||||
showCalendarInviteToast(notification);
|
||||
} else {
|
||||
toast(notification.title || 'New Notification', {
|
||||
description: notification.message || undefined,
|
||||
@ -104,7 +141,7 @@ export default function NotificationToaster() {
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [notifications, handleConnectionRespond]);
|
||||
}, [notifications, handleConnectionRespond, handleCalendarInviteRespond]);
|
||||
|
||||
const showConnectionRequestToast = (notification: AppNotification) => {
|
||||
const requestId = notification.source_id!;
|
||||
@ -145,5 +182,45 @@ export default function NotificationToaster() {
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const showCalendarInviteToast = (notification: AppNotification) => {
|
||||
const inviteId = notification.source_id!;
|
||||
const calendarName = (notification.data as Record<string, string>)?.calendar_name || 'a calendar';
|
||||
|
||||
toast.custom(
|
||||
(id) => (
|
||||
<div className="w-[356px] rounded-lg border border-border bg-card p-4 shadow-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-9 w-9 rounded-full bg-purple-500/15 flex items-center justify-center shrink-0">
|
||||
<Calendar className="h-4 w-4 text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">Calendar Invite</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{notification.message || `You've been invited to ${calendarName}`}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => handleCalendarInviteRespond(inviteId, 'accept', id, notification.id)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md bg-accent text-accent-foreground hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCalendarInviteRespond(inviteId, 'reject', id, notification.id)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:bg-card-elevated transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ id: `calendar-invite-${inviteId}`, duration: 30000 },
|
||||
);
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2 } from 'lucide-react';
|
||||
import { Bell, Check, CheckCheck, Trash2, UserPlus, Info, AlertCircle, X, Loader2, Calendar } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { useConnections } from '@/hooks/useConnections';
|
||||
import { useSharedCalendars } from '@/hooks/useSharedCalendars';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import axios from 'axios';
|
||||
@ -16,6 +17,9 @@ import type { AppNotification } from '@/types';
|
||||
const typeIcons: Record<string, { icon: typeof Bell; color: string }> = {
|
||||
connection_request: { icon: UserPlus, color: 'text-violet-400' },
|
||||
connection_accepted: { icon: UserPlus, color: 'text-green-400' },
|
||||
calendar_invite: { icon: Calendar, color: 'text-purple-400' },
|
||||
calendar_invite_accepted: { icon: Calendar, color: 'text-green-400' },
|
||||
calendar_invite_rejected: { icon: Calendar, color: 'text-muted-foreground' },
|
||||
info: { icon: Info, color: 'text-blue-400' },
|
||||
warning: { icon: AlertCircle, color: 'text-amber-400' },
|
||||
};
|
||||
@ -33,11 +37,17 @@ export default function NotificationsPage() {
|
||||
} = useNotifications();
|
||||
|
||||
const { incomingRequests, respond, isResponding } = useConnections();
|
||||
const { incomingInvites, respondInvite, isResponding: isRespondingInvite } = useSharedCalendars();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
|
||||
// Build a set of pending connection request IDs for quick lookup
|
||||
const pendingInviteIds = useMemo(
|
||||
() => new Set(incomingInvites.map((inv) => inv.id)),
|
||||
[incomingInvites],
|
||||
);
|
||||
|
||||
const pendingRequestIds = useMemo(
|
||||
() => new Set(incomingRequests.map((r) => r.id)),
|
||||
[incomingRequests],
|
||||
@ -46,6 +56,10 @@ export default function NotificationsPage() {
|
||||
// Eagerly fetch incoming requests when notifications contain connection_request
|
||||
// entries whose source_id isn't in pendingRequestIds yet (stale connections data)
|
||||
useEffect(() => {
|
||||
// Also refresh calendar invites
|
||||
if (notifications.some((n) => n.type === 'calendar_invite' && !n.is_read)) {
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-invites', 'incoming'] });
|
||||
}
|
||||
const hasMissing = notifications.some(
|
||||
(n) => n.type === 'connection_request' && n.source_id && !n.is_read && !pendingRequestIds.has(n.source_id),
|
||||
);
|
||||
@ -106,6 +120,27 @@ export default function NotificationsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleCalendarInviteRespond = async (
|
||||
notification: AppNotification,
|
||||
action: 'accept' | 'reject',
|
||||
) => {
|
||||
if (!notification.source_id) return;
|
||||
try {
|
||||
await respondInvite({ inviteId: notification.source_id, action });
|
||||
if (!notification.is_read) {
|
||||
await markRead([notification.id]).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
||||
if (!notification.is_read) {
|
||||
await markRead([notification.id]).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
toast.error(getErrorMessage(err, 'Failed to respond'));
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleNotificationClick = async (notification: AppNotification) => {
|
||||
// Don't navigate for pending connection requests — let user act inline
|
||||
if (
|
||||
@ -250,6 +285,32 @@ export default function NotificationsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Calendar invite actions (inline) */}
|
||||
{notification.type === 'calendar_invite' &&
|
||||
notification.source_id &&
|
||||
pendingInviteIds.has(notification.source_id) && (
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleCalendarInviteRespond(notification, 'accept'); }}
|
||||
disabled={isRespondingInvite}
|
||||
className="gap-1 h-7 text-xs"
|
||||
>
|
||||
{isRespondingInvite ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleCalendarInviteRespond(notification, 'reject'); }}
|
||||
disabled={isRespondingInvite}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Timestamp + actions */}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
|
||||
@ -61,6 +61,24 @@ export function useAdminUserDetail(userId: number | null) {
|
||||
});
|
||||
}
|
||||
|
||||
interface SharingStats {
|
||||
shared_calendars_owned: number;
|
||||
calendars_member_of: number;
|
||||
pending_invites_sent: number;
|
||||
pending_invites_received: number;
|
||||
}
|
||||
|
||||
export function useAdminSharingStats(userId: number | null) {
|
||||
return useQuery<SharingStats>({
|
||||
queryKey: ['admin', 'users', userId, 'sharing-stats'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<SharingStats>(`/admin/users/${userId}/sharing-stats`);
|
||||
return data;
|
||||
},
|
||||
enabled: userId !== null,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminDashboard() {
|
||||
return useQuery<AdminDashboardData>({
|
||||
queryKey: ['admin', 'dashboard'],
|
||||
|
||||
@ -1,13 +1,42 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import type { Calendar } from '@/types';
|
||||
import type { Calendar, SharedCalendarMembership } from '@/types';
|
||||
|
||||
export function useCalendars() {
|
||||
return useQuery({
|
||||
interface UseCalendarsOptions {
|
||||
pollingEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function useCalendars({ pollingEnabled = false }: UseCalendarsOptions = {}) {
|
||||
const ownedQuery = useQuery({
|
||||
queryKey: ['calendars'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<Calendar[]>('/calendars');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const sharedQuery = useQuery({
|
||||
queryKey: ['calendars', 'shared'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<SharedCalendarMembership[]>('/shared-calendars');
|
||||
return data;
|
||||
},
|
||||
refetchInterval: pollingEnabled ? 5_000 : false,
|
||||
staleTime: 3_000,
|
||||
});
|
||||
|
||||
const allCalendarIds = useMemo(() => {
|
||||
const owned = (ownedQuery.data ?? []).map((c) => c.id);
|
||||
const shared = (sharedQuery.data ?? []).map((m) => m.calendar_id);
|
||||
return new Set([...owned, ...shared]);
|
||||
}, [ownedQuery.data, sharedQuery.data]);
|
||||
|
||||
return {
|
||||
data: ownedQuery.data ?? [],
|
||||
sharedData: sharedQuery.data ?? [],
|
||||
allCalendarIds,
|
||||
isLoading: ownedQuery.isLoading,
|
||||
isLoadingShared: sharedQuery.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
@ -88,6 +88,8 @@ export function useConnections() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['connections'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['people'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
68
frontend/src/hooks/useEventLock.ts
Normal file
68
frontend/src/hooks/useEventLock.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import type { EventLockInfo } from '@/types';
|
||||
|
||||
export function useEventLock(eventId: number | null) {
|
||||
const lockHeldRef = useRef(false);
|
||||
const activeEventIdRef = useRef<number | null>(null);
|
||||
|
||||
const acquireMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await api.post<EventLockInfo>(
|
||||
`/shared-calendars/events/${id}/lock`
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_data, lockedId) => {
|
||||
lockHeldRef.current = true;
|
||||
activeEventIdRef.current = lockedId;
|
||||
},
|
||||
});
|
||||
|
||||
const releaseMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await api.delete(`/shared-calendars/events/${id}/lock`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
lockHeldRef.current = false;
|
||||
activeEventIdRef.current = null;
|
||||
},
|
||||
});
|
||||
|
||||
const acquire = useCallback(async () => {
|
||||
if (!eventId) return null;
|
||||
const data = await acquireMutation.mutateAsync(eventId);
|
||||
return data;
|
||||
}, [eventId, acquireMutation]);
|
||||
|
||||
const release = useCallback(async () => {
|
||||
const id = activeEventIdRef.current;
|
||||
if (!id || !lockHeldRef.current) return;
|
||||
try {
|
||||
await releaseMutation.mutateAsync(id);
|
||||
} catch {
|
||||
// Fire-and-forget on release errors
|
||||
}
|
||||
}, [releaseMutation]);
|
||||
|
||||
// Auto-release on unmount or eventId change
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const id = activeEventIdRef.current;
|
||||
if (id && lockHeldRef.current) {
|
||||
// Fire-and-forget cleanup
|
||||
api.delete(`/shared-calendars/events/${id}/lock`).catch(() => {});
|
||||
lockHeldRef.current = false;
|
||||
activeEventIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [eventId]);
|
||||
|
||||
return {
|
||||
acquire,
|
||||
release,
|
||||
isAcquiring: acquireMutation.isPending,
|
||||
acquireError: acquireMutation.error,
|
||||
};
|
||||
}
|
||||
196
frontend/src/hooks/useSharedCalendars.ts
Normal file
196
frontend/src/hooks/useSharedCalendars.ts
Normal file
@ -0,0 +1,196 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import axios from 'axios';
|
||||
import type { CalendarMemberInfo, CalendarInvite, CalendarPermission } from '@/types';
|
||||
|
||||
export function useSharedCalendars() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const incomingInvitesQuery = useQuery({
|
||||
queryKey: ['calendar-invites', 'incoming'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<CalendarInvite[]>(
|
||||
'/shared-calendars/invites/incoming'
|
||||
);
|
||||
return data;
|
||||
},
|
||||
refetchOnMount: 'always' as const,
|
||||
});
|
||||
|
||||
const fetchMembers = async (calendarId: number) => {
|
||||
const { data } = await api.get<CalendarMemberInfo[]>(
|
||||
`/shared-calendars/${calendarId}/members`
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
const useMembersQuery = (calendarId: number | null) =>
|
||||
useQuery({
|
||||
queryKey: ['calendar-members', calendarId],
|
||||
queryFn: () => fetchMembers(calendarId!),
|
||||
enabled: calendarId != null,
|
||||
});
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
calendarId,
|
||||
connectionId,
|
||||
permission,
|
||||
canAddOthers,
|
||||
}: {
|
||||
calendarId: number;
|
||||
connectionId: number;
|
||||
permission: CalendarPermission;
|
||||
canAddOthers: boolean;
|
||||
}) => {
|
||||
const { data } = await api.post(`/shared-calendars/${calendarId}/invite`, {
|
||||
connection_id: connectionId,
|
||||
permission,
|
||||
can_add_others: canAddOthers,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-members', variables.calendarId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['calendars'] });
|
||||
toast.success('Invite sent');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error, 'Failed to send invite'));
|
||||
},
|
||||
});
|
||||
|
||||
const updateMemberMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
calendarId,
|
||||
memberId,
|
||||
permission,
|
||||
canAddOthers,
|
||||
}: {
|
||||
calendarId: number;
|
||||
memberId: number;
|
||||
permission?: CalendarPermission;
|
||||
canAddOthers?: boolean;
|
||||
}) => {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (permission !== undefined) body.permission = permission;
|
||||
if (canAddOthers !== undefined) body.can_add_others = canAddOthers;
|
||||
const { data } = await api.put(
|
||||
`/shared-calendars/${calendarId}/members/${memberId}`,
|
||||
body
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-members', variables.calendarId] });
|
||||
toast.success('Member updated');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error, 'Failed to update member'));
|
||||
},
|
||||
});
|
||||
|
||||
const removeMemberMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
calendarId,
|
||||
memberId,
|
||||
}: {
|
||||
calendarId: number;
|
||||
memberId: number;
|
||||
}) => {
|
||||
await api.delete(`/shared-calendars/${calendarId}/members/${memberId}`);
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-members', variables.calendarId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['calendars'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] });
|
||||
toast.success('Member removed');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error, 'Failed to remove member'));
|
||||
},
|
||||
});
|
||||
|
||||
const respondInviteMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
inviteId,
|
||||
action,
|
||||
}: {
|
||||
inviteId: number;
|
||||
action: 'accept' | 'reject';
|
||||
}) => {
|
||||
const { data } = await api.put(`/shared-calendars/invites/${inviteId}/respond`, {
|
||||
action,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
toast.dismiss(`calendar-invite-${variables.inviteId}`);
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-invites'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
toast.success(variables.action === 'accept' ? 'Calendar added' : 'Invite declined');
|
||||
},
|
||||
onError: (error, variables) => {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||
toast.dismiss(`calendar-invite-${variables.inviteId}`);
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-invites'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
return;
|
||||
}
|
||||
toast.error(getErrorMessage(error, 'Failed to respond to invite'));
|
||||
},
|
||||
});
|
||||
|
||||
const updateColorMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
calendarId,
|
||||
localColor,
|
||||
}: {
|
||||
calendarId: number;
|
||||
localColor: string | null;
|
||||
}) => {
|
||||
await api.put(`/shared-calendars/${calendarId}/members/me/color`, {
|
||||
local_color: localColor,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error, 'Failed to update color'));
|
||||
},
|
||||
});
|
||||
|
||||
const leaveCalendarMutation = useMutation({
|
||||
mutationFn: async ({ calendarId, memberId }: { calendarId: number; memberId: number }) => {
|
||||
await api.delete(`/shared-calendars/${calendarId}/members/${memberId}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['calendars', 'shared'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
|
||||
toast.success('Left calendar');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error, 'Failed to leave calendar'));
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
incomingInvites: incomingInvitesQuery.data ?? [],
|
||||
isLoadingInvites: incomingInvitesQuery.isLoading,
|
||||
useMembersQuery,
|
||||
invite: inviteMutation.mutateAsync,
|
||||
isInviting: inviteMutation.isPending,
|
||||
updateMember: updateMemberMutation.mutateAsync,
|
||||
removeMember: removeMemberMutation.mutateAsync,
|
||||
respondInvite: respondInviteMutation.mutateAsync,
|
||||
isResponding: respondInviteMutation.isPending,
|
||||
updateColor: updateColorMutation.mutateAsync,
|
||||
leaveCalendar: leaveCalendarMutation.mutateAsync,
|
||||
};
|
||||
}
|
||||
@ -81,6 +81,7 @@ export interface Calendar {
|
||||
is_default: boolean;
|
||||
is_system: boolean;
|
||||
is_visible: boolean;
|
||||
is_shared: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@ -439,3 +440,50 @@ export interface Connection {
|
||||
export interface UmbralSearchResponse {
|
||||
found: boolean;
|
||||
}
|
||||
|
||||
// ── Shared Calendars ──────────────────────────────────────────────
|
||||
|
||||
export type CalendarPermission = 'read_only' | 'create_modify' | 'full_access';
|
||||
|
||||
export interface SharedCalendarMembership {
|
||||
id: number;
|
||||
calendar_id: number;
|
||||
calendar_name: string;
|
||||
calendar_color: string;
|
||||
local_color: string | null;
|
||||
permission: CalendarPermission;
|
||||
can_add_others: boolean;
|
||||
is_owner: false;
|
||||
}
|
||||
|
||||
export interface CalendarMemberInfo {
|
||||
id: number;
|
||||
calendar_id: number;
|
||||
user_id: number;
|
||||
umbral_name: string;
|
||||
preferred_name: string | null;
|
||||
permission: CalendarPermission;
|
||||
can_add_others: boolean;
|
||||
local_color: string | null;
|
||||
status: 'pending' | 'accepted';
|
||||
invited_at: string;
|
||||
accepted_at: string | null;
|
||||
}
|
||||
|
||||
export interface CalendarInvite {
|
||||
id: number;
|
||||
calendar_id: number;
|
||||
calendar_name: string;
|
||||
calendar_color: string;
|
||||
owner_umbral_name: string;
|
||||
inviter_umbral_name: string;
|
||||
permission: CalendarPermission;
|
||||
invited_at: string;
|
||||
}
|
||||
|
||||
export interface EventLockInfo {
|
||||
locked: boolean;
|
||||
locked_by_name: string | null;
|
||||
expires_at: string | null;
|
||||
is_permanent: boolean;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user