diff --git a/backend/alembic/versions/047_add_is_shared_to_calendars.py b/backend/alembic/versions/047_add_is_shared_to_calendars.py new file mode 100644 index 0000000..81efe52 --- /dev/null +++ b/backend/alembic/versions/047_add_is_shared_to_calendars.py @@ -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") diff --git a/backend/alembic/versions/048_create_calendar_members.py b/backend/alembic/versions/048_create_calendar_members.py new file mode 100644 index 0000000..18acfac --- /dev/null +++ b/backend/alembic/versions/048_create_calendar_members.py @@ -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") diff --git a/backend/alembic/versions/049_create_event_locks.py b/backend/alembic/versions/049_create_event_locks.py new file mode 100644 index 0000000..847ba46 --- /dev/null +++ b/backend/alembic/versions/049_create_event_locks.py @@ -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") diff --git a/backend/alembic/versions/050_expand_notification_types_calendar.py b/backend/alembic/versions/050_expand_notification_types_calendar.py new file mode 100644 index 0000000..27df8ac --- /dev/null +++ b/backend/alembic/versions/050_expand_notification_types_calendar.py @@ -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)) diff --git a/backend/alembic/versions/051_add_updated_by_to_calendar_events.py b/backend/alembic/versions/051_add_updated_by_to_calendar_events.py new file mode 100644 index 0000000..7e7a040 --- /dev/null +++ b/backend/alembic/versions/051_add_updated_by_to_calendar_events.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0b96dc8..81e14b1 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/calendar.py b/backend/app/models/calendar.py index 43ab782..48c9ab2 100644 --- a/backend/app/models/calendar.py +++ b/backend/app/models/calendar.py @@ -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") diff --git a/backend/app/models/calendar_event.py b/backend/app/models/calendar_event.py index c85dfc7..2c4f256 100644 --- a/backend/app/models/calendar_event.py +++ b/backend/app/models/calendar_event.py @@ -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()) diff --git a/backend/app/models/calendar_member.py b/backend/app/models/calendar_member.py new file mode 100644 index 0000000..56f647f --- /dev/null +++ b/backend/app/models/calendar_member.py @@ -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="selectin") + user: Mapped["User"] = relationship(foreign_keys=[user_id], lazy="selectin") + inviter: Mapped[Optional["User"]] = relationship( + foreign_keys=[invited_by], lazy="selectin" + ) diff --git a/backend/app/models/event_lock.py b/backend/app/models/event_lock.py new file mode 100644 index 0000000..79bb05b --- /dev/null +++ b/backend/app/models/event_lock.py @@ -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") diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py index 58d71c3..2f4cfc6 100644 --- a/backend/app/models/notification.py +++ b/backend/app/models/notification.py @@ -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", ) diff --git a/backend/app/schemas/calendar.py b/backend/app/schemas/calendar.py index e9e2753..15da21f 100644 --- a/backend/app/schemas/calendar.py +++ b/backend/app/schemas/calendar.py @@ -8,6 +8,7 @@ class CalendarCreate(BaseModel): name: str = Field(min_length=1, max_length=100) color: str = Field("#3b82f6", max_length=20) + is_shared: bool = False class CalendarUpdate(BaseModel): @@ -16,6 +17,7 @@ class CalendarUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=100) color: Optional[str] = Field(None, max_length=20) is_visible: Optional[bool] = None + is_shared: Optional[bool] = None class CalendarResponse(BaseModel): @@ -27,5 +29,11 @@ class CalendarResponse(BaseModel): is_visible: bool created_at: datetime updated_at: datetime + is_shared: bool = False + 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) diff --git a/backend/app/schemas/shared_calendar.py b/backend/app/schemas/shared_calendar.py new file mode 100644 index 0000000..5b5420f --- /dev/null +++ b/backend/app/schemas/shared_calendar.py @@ -0,0 +1,79 @@ +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 ConvertToSharedRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + +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