diff --git a/apps/lib/features/calendar/data/models/schedule_item_model.dart b/apps/lib/features/calendar/data/models/schedule_item_model.dart index a421386..a5568db 100644 --- a/apps/lib/features/calendar/data/models/schedule_item_model.dart +++ b/apps/lib/features/calendar/data/models/schedule_item_model.dart @@ -2,11 +2,53 @@ enum ScheduleSourceType { manual, imported, agentGenerated } enum ScheduleStatus { active, archived } +class Subscriber { + final String userId; + final String? username; + final String? avatarUrl; + final String? phone; + final int permission; + final String status; + final DateTime subscribedAt; + + static const int permissionView = 1; + static const int permissionInvite = 2; + static const int permissionEdit = 4; + static const int permissionDelete = 8; + static const int permissionOwner = 15; + + bool get canEdit => (permission & permissionEdit) != 0; + bool get canInvite => (permission & permissionInvite) != 0; + bool get canDelete => (permission & permissionDelete) != 0; + bool get canView => (permission & permissionView) != 0; + + Subscriber({ + required this.userId, + this.username, + this.avatarUrl, + this.phone, + required this.permission, + required this.status, + required this.subscribedAt, + }); + + factory Subscriber.fromJson(Map json) { + return Subscriber( + userId: json['user_id'] as String, + username: json['username'] as String?, + avatarUrl: json['avatar_url'] as String?, + phone: json['phone'] as String?, + permission: json['permission'] as int, + status: json['status'] as String, + subscribedAt: DateTime.parse(json['subscribed_at'] as String).toLocal(), + ); + } +} + class ScheduleItemModel { final String id; final String ownerId; final int permission; - final bool isOwner; final String title; final String? description; final DateTime startAt; @@ -17,20 +59,23 @@ class ScheduleItemModel { final ScheduleStatus status; final DateTime createdAt; final DateTime updatedAt; + final List subscribers; static const int permissionView = 1; static const int permissionInvite = 2; static const int permissionEdit = 4; + static const int permissionDelete = 8; + static const int permissionOwner = 15; - bool get canEdit => isOwner || (permission & permissionEdit) != 0; - bool get canInvite => isOwner || (permission & permissionInvite) != 0; - bool get canDelete => isOwner; + bool get canEdit => (permission & permissionEdit) != 0; + bool get canInvite => (permission & permissionInvite) != 0; + bool get canDelete => (permission & permissionDelete) != 0; + bool get isOwner => (permission & permissionOwner) != 0; ScheduleItemModel({ required this.id, required this.ownerId, this.permission = 1, - this.isOwner = false, required this.title, this.description, required this.startAt, @@ -41,6 +86,7 @@ class ScheduleItemModel { this.status = ScheduleStatus.active, DateTime? createdAt, DateTime? updatedAt, + this.subscribers = const [], }) : createdAt = createdAt ?? DateTime.now(), updatedAt = updatedAt ?? DateTime.now(); @@ -48,7 +94,6 @@ class ScheduleItemModel { String? id, String? ownerId, int? permission, - bool? isOwner, String? title, String? description, DateTime? startAt, @@ -59,12 +104,12 @@ class ScheduleItemModel { ScheduleStatus? status, DateTime? createdAt, DateTime? updatedAt, + List? subscribers, }) { return ScheduleItemModel( id: id ?? this.id, ownerId: ownerId ?? this.ownerId, permission: permission ?? this.permission, - isOwner: isOwner ?? this.isOwner, title: title ?? this.title, description: description ?? this.description, startAt: startAt ?? this.startAt, @@ -75,15 +120,21 @@ class ScheduleItemModel { status: status ?? this.status, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, + subscribers: subscribers ?? this.subscribers, ); } factory ScheduleItemModel.fromJson(Map json) { + final subscribersList = json['subscribers'] as List?; + final subscribers = + subscribersList + ?.map((s) => Subscriber.fromJson(s as Map)) + .toList() ?? + []; return ScheduleItemModel( id: json['id'] as String, ownerId: json['owner_id'] as String, permission: json['permission'] as int, - isOwner: json['is_owner'] as bool, title: json['title'] as String, description: json['description'] as String?, startAt: DateTime.parse(json['start_at'] as String).toLocal(), @@ -98,6 +149,7 @@ class ScheduleItemModel { status: _statusFromApi(json['status'] as String), createdAt: DateTime.parse(json['created_at'] as String).toLocal(), updatedAt: DateTime.parse(json['updated_at'] as String).toLocal(), + subscribers: subscribers, ); } diff --git a/apps/lib/features/calendar/data/repositories/calendar_repository.dart b/apps/lib/features/calendar/data/repositories/calendar_repository.dart index 7407538..c5285d3 100644 --- a/apps/lib/features/calendar/data/repositories/calendar_repository.dart +++ b/apps/lib/features/calendar/data/repositories/calendar_repository.dart @@ -138,7 +138,6 @@ class CalendarRepository extends CachedRepository> { 'id': item.id, 'owner_id': item.ownerId, 'permission': item.permission, - 'is_owner': item.isOwner, 'title': item.title, 'description': item.description, 'start_at': item.startAt.toIso8601String(), diff --git a/apps/lib/features/calendar/presentation/screens/calendar_event_detail_screen.dart b/apps/lib/features/calendar/presentation/screens/calendar_event_detail_screen.dart index 12b980b..37218e7 100644 --- a/apps/lib/features/calendar/presentation/screens/calendar_event_detail_screen.dart +++ b/apps/lib/features/calendar/presentation/screens/calendar_event_detail_screen.dart @@ -149,6 +149,10 @@ class _CalendarEventDetailScreenState extends State { const SizedBox(height: AppSpacing.md), _buildExtraSurface(event), ], + if (event.subscribers.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.md), + _buildSubscribersSurface(event), + ], ], ), ), @@ -465,6 +469,131 @@ class _CalendarEventDetailScreenState extends State { ); } + Widget _buildSubscribersSurface(ScheduleItemModel event) { + if (event.subscribers.isEmpty) { + return const SizedBox.shrink(); + } + return Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: _colorScheme.surface, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: _colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.calendarDetailSubscribers(event.subscribers.length), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: _colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: AppSpacing.md), + ...event.subscribers.map( + (sub) => _buildSubscriberRow(sub, event.ownerId), + ), + ], + ), + ); + } + + Widget _buildSubscriberRow(Subscriber subscriber, String ownerId) { + final palette = Theme.of(context).extension()!; + final avatarColor = + palette.avatarColors[subscriber.userId.hashCode.abs() % + palette.avatarColors.length]; + final isOwner = subscriber.userId == ownerId; + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: _colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: subscriber.avatarUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.network( + subscriber.avatarUrl!, + width: 32, + height: 32, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + Icon(Icons.person, size: 16, color: avatarColor), + ), + ) + : Icon(Icons.person, size: 16, color: avatarColor), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + subscriber.phone ?? + subscriber.username ?? + subscriber.userId.substring(0, 8), + style: TextStyle( + fontSize: 14, + color: _colorScheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (isOwner) ...[ + const SizedBox(width: AppSpacing.xs), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs, + vertical: 2, + ), + decoration: BoxDecoration( + color: _colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + child: Text( + context.l10n.calendarOwnerBadge, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: _colorScheme.onPrimaryContainer, + ), + ), + ), + ], + ], + ), + ), + if (subscriber.canEdit) + Padding( + padding: const EdgeInsets.only(left: AppSpacing.xs), + child: Icon( + LucideIcons.pencil, + size: 14, + color: _colorScheme.primary, + ), + ), + if (subscriber.canInvite) + Padding( + padding: const EdgeInsets.only(left: AppSpacing.xs), + child: Icon( + LucideIcons.userPlus, + size: 14, + color: _colorScheme.primary, + ), + ), + ], + ), + ); + } + String _formatReminderText(int? reminderMinutes) { if (reminderMinutes == null) { return context.l10n.calendarDetailReminderNone; diff --git a/apps/lib/features/calendar/presentation/widgets/calendar_share_dialog.dart b/apps/lib/features/calendar/presentation/widgets/calendar_share_dialog.dart index f22c2ca..83fdffa 100644 --- a/apps/lib/features/calendar/presentation/widgets/calendar_share_dialog.dart +++ b/apps/lib/features/calendar/presentation/widgets/calendar_share_dialog.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart' hide BackButton; +import 'package:flutter/services.dart'; import 'package:social_app/core/l10n/l10n.dart'; import '../../../../app/di/injection.dart'; +import '../../../../data/models/dial_codes.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_button.dart'; +import '../../../../shared/widgets/phone_prefix_selector.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import '../../data/apis/calendar_api.dart'; @@ -50,6 +53,7 @@ class CalendarShareDialog extends StatefulWidget { class _CalendarShareDialogState extends State { final _phoneController = TextEditingController(); + String _dialCode = kDialCodes.first.value; final bool _permissionView = true; bool _permissionEdit = false; bool _permissionInvite = false; @@ -73,13 +77,14 @@ class _CalendarShareDialogState extends State { return; } + final fullPhone = '$_dialCode$phone'; setState(() => _isLoading = true); try { final api = sl(); await api.share( widget.eventId, - phone: phone, + phone: fullPhone, view: _permissionView, edit: _permissionEdit, invite: _permissionInvite, @@ -147,14 +152,37 @@ class _CalendarShareDialogState extends State { const SizedBox(height: AppSpacing.lg), TextField( controller: _phoneController, + keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(14), + ], + style: TextStyle(fontSize: 16, color: colorScheme.onSurface), decoration: InputDecoration( - labelText: l10n.calendarSharePhoneLabel, hintText: l10n.calendarSharePhoneHint, + filled: true, + fillColor: colorScheme.surface, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.lg, + ), + prefixIcon: PhonePrefixSelector( + value: _dialCode, + onChanged: (value) => setState(() => _dialCode = value), + ), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppRadius.md), + borderRadius: BorderRadius.circular(AppRadius.lg), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + borderSide: BorderSide(color: colorScheme.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + borderSide: BorderSide(color: colorScheme.primary), ), ), - keyboardType: TextInputType.phone, ), const SizedBox(height: AppSpacing.lg), Text( diff --git a/apps/lib/features/calendar/presentation/widgets/create_event_sheet.dart b/apps/lib/features/calendar/presentation/widgets/create_event_sheet.dart index 87fdf23..c25157f 100644 --- a/apps/lib/features/calendar/presentation/widgets/create_event_sheet.dart +++ b/apps/lib/features/calendar/presentation/widgets/create_event_sheet.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:social_app/core/l10n/l10n.dart'; import '../../../../app/di/injection.dart'; +import '../../../../app/router/app_routes.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; import '../../../../shared/widgets/app_selection_sheet.dart'; @@ -215,7 +217,7 @@ class _CreateEventSheetState extends State title: _isEditing ? context.l10n.calendarCreateEditTitle : context.l10n.calendarCreateNewTitle, - onBack: () => Navigator.of(context).pop(), + onBack: () => _closeSheet(), trailing: ValueListenableBuilder( valueListenable: _titleController, builder: (context, value, child) { @@ -264,7 +266,7 @@ class _CreateEventSheetState extends State width: AppSpacing.xxl * 2, height: AppSpacing.xxl * 2, child: TextButton( - onPressed: () => Navigator.pop(context), + onPressed: _closeSheet, style: TextButton.styleFrom( padding: const EdgeInsets.all(AppSpacing.none), shape: RoundedRectangleBorder( @@ -783,6 +785,8 @@ class _CreateEventSheetState extends State metadata: metadata, ); + var saved = false; + try { final service = sl(); @@ -792,10 +796,9 @@ class _CreateEventSheetState extends State await service.addEvent(event); } + saved = true; + widget.onSaved?.call(); - if (mounted) { - Navigator.pop(context, true); - } } catch (e) { if (mounted) { Toast.show( @@ -811,5 +814,29 @@ class _CreateEventSheetState extends State }); } } + + if (saved && mounted) { + _closeSheet(result: true); + } + } + + void _closeSheet({Object? result}) { + if (!mounted) { + return; + } + + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(result); + return; + } + + final router = GoRouter.of(context); + if (router.canPop()) { + context.pop(result); + return; + } + + context.go(AppRoutes.homeMain); } } diff --git a/backend/alembic/versions/202603270001_expand_permission_constraint.py b/backend/alembic/versions/202603270001_expand_permission_constraint.py new file mode 100644 index 0000000..6c040a0 --- /dev/null +++ b/backend/alembic/versions/202603270001_expand_permission_constraint.py @@ -0,0 +1,33 @@ +"""expand schedule_subscriptions permission constraint to 15 + +Revision ID: 202603270001 +Revises: 202603260001 +Create Date: 2026-03-27 00:00:00 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "202603270001" +down_revision: Union[str, Sequence[str], None] = "202603260001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE schedule_subscriptions DROP CONSTRAINT IF EXISTS chk_schedule_subscription_permission" + ) + op.execute( + "ALTER TABLE schedule_subscriptions ADD CONSTRAINT chk_schedule_subscription_permission CHECK (permission BETWEEN 0 AND 15)" + ) + + +def downgrade() -> None: + op.execute( + "ALTER TABLE schedule_subscriptions DROP CONSTRAINT IF EXISTS chk_schedule_subscription_permission" + ) + op.execute( + "ALTER TABLE schedule_subscriptions ADD CONSTRAINT chk_schedule_subscription_permission CHECK (permission BETWEEN 0 AND 7)" + ) diff --git a/backend/alembic/versions/202603300001_remove_paused_status.py b/backend/alembic/versions/202603300001_remove_paused_status.py new file mode 100644 index 0000000..40bfb2a --- /dev/null +++ b/backend/alembic/versions/202603300001_remove_paused_status.py @@ -0,0 +1,33 @@ +"""remove paused status from schedule_subscriptions + +Revision ID: 202603300001 +Revises: 202603270001 +Create Date: 2026-03-30 00:00:00 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "202603300001" +down_revision: Union[str, Sequence[str], None] = "202603270001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE schedule_subscriptions DROP CONSTRAINT IF EXISTS chk_schedule_subscription_status" + ) + op.execute( + "ALTER TABLE schedule_subscriptions ADD CONSTRAINT chk_schedule_subscription_status CHECK (status::text = ANY (ARRAY['active'::character varying, 'pending'::character varying, 'unsubscribed'::character varying]::text[]))" + ) + + +def downgrade() -> None: + op.execute( + "ALTER TABLE schedule_subscriptions DROP CONSTRAINT IF EXISTS chk_schedule_subscription_status" + ) + op.execute( + "ALTER TABLE schedule_subscriptions ADD CONSTRAINT chk_schedule_subscription_status CHECK (status::text = ANY (ARRAY['active'::character varying, 'paused'::character varying, 'pending'::character varying, 'unsubscribed'::character varying]::text[]))" + ) diff --git a/backend/src/schemas/enums.py b/backend/src/schemas/enums.py index ba4bf8a..372b779 100644 --- a/backend/src/schemas/enums.py +++ b/backend/src/schemas/enums.py @@ -83,7 +83,6 @@ class InboxMessageStatus(str, Enum): class SubscriptionStatus(str, Enum): ACTIVE = "active" PENDING = "pending" - PAUSED = "paused" UNSUBSCRIBED = "unsubscribed" @@ -97,7 +96,8 @@ class SubscriptionPermission(int, Enum): VIEW = 1 INVITE = 2 EDIT = 4 - OWNER = 7 + DELETE = 8 + OWNER = 15 # VIEW | INVITE | EDIT | DELETE class FriendshipStatus(str, Enum): diff --git a/backend/src/v1/schedule_items/dependencies.py b/backend/src/v1/schedule_items/dependencies.py index 9e522f7..112566c 100644 --- a/backend/src/v1/schedule_items/dependencies.py +++ b/backend/src/v1/schedule_items/dependencies.py @@ -11,17 +11,20 @@ from v1.inbox_messages.repository import SQLAlchemyInboxMessageRepository from v1.schedule_items.repository import SQLAlchemyScheduleItemRepository from v1.schedule_items.service import ScheduleItemService from v1.users.dependencies import get_current_user +from v1.users.repository import SQLAlchemyUserRepository -def get_schedule_item_service( +async def get_schedule_item_service( session: Annotated[AsyncSession, Depends(get_db)], user: Annotated[CurrentUser, Depends(get_current_user)], ) -> ScheduleItemService: repository = SQLAlchemyScheduleItemRepository(session) inbox_repository = SQLAlchemyInboxMessageRepository(session) + user_repository = SQLAlchemyUserRepository(session) return ScheduleItemService( repository=repository, session=session, current_user=user, inbox_repository=inbox_repository, + user_repository=user_repository, ) diff --git a/backend/src/v1/schedule_items/repository.py b/backend/src/v1/schedule_items/repository.py index 6b60680..bc309e7 100644 --- a/backend/src/v1/schedule_items/repository.py +++ b/backend/src/v1/schedule_items/repository.py @@ -21,16 +21,10 @@ logger = get_logger("v1.schedule_items.repository") class ScheduleItemRepository(Protocol): async def get_by_id(self, entity_id: UUID) -> ScheduleItem | None: ... - async def get_by_item_id( - self, item_id: UUID, owner_id: UUID - ) -> ScheduleItem | None: ... + async def get_item(self, item_id: UUID) -> ScheduleItem | None: ... async def create(self, data: dict) -> ScheduleItem: ... - async def update_by_item_id( - self, item_id: UUID, owner_id: UUID, data: dict - ) -> ScheduleItem | None: ... - async def delete_by_item_id( - self, item_id: UUID, owner_id: UUID - ) -> ScheduleItem | None: ... + async def update_item(self, item_id: UUID, data: dict) -> ScheduleItem | None: ... + async def delete_item(self, item_id: UUID) -> ScheduleItem | None: ... async def list_by_date_range( self, owner_id: UUID, start_at: datetime, end_at: datetime ) -> list[ScheduleItem]: ... @@ -74,14 +68,11 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]): super().__init__(session, ScheduleItem) self._session = session - async def get_by_item_id( - self, item_id: UUID, owner_id: UUID - ) -> ScheduleItem | None: + async def get_item(self, item_id: UUID) -> ScheduleItem | None: try: stmt = ( select(ScheduleItem) .where(ScheduleItem.id == item_id) - .where(ScheduleItem.owner_id == owner_id) .where(ScheduleItem.deleted_at.is_(None)) ) result = await self._session.execute(stmt) @@ -90,7 +81,6 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]): logger.exception( "Schedule item lookup failed", item_id=str(item_id), - owner_id=str(owner_id), ) raise @@ -104,19 +94,16 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]): logger.exception("Schedule item creation failed") raise - async def update_by_item_id( - self, item_id: UUID, owner_id: UUID, data: dict - ) -> ScheduleItem | None: + async def update_item(self, item_id: UUID, data: dict) -> ScheduleItem | None: if not data: - return await self.get_by_item_id(item_id, owner_id) + return await self.get_item(item_id) try: - existing = await self.get_by_item_id(item_id, owner_id) + existing = await self.get_item(item_id) if existing is None: return None stmt = ( update(ScheduleItem) .where(ScheduleItem.id == item_id) - .where(ScheduleItem.owner_id == owner_id) .where(ScheduleItem.deleted_at.is_(None)) .values(**data) .returning(ScheduleItem) @@ -128,14 +115,11 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]): logger.exception("Schedule item update failed", item_id=str(item_id)) raise - async def delete_by_item_id( - self, item_id: UUID, owner_id: UUID - ) -> ScheduleItem | None: + async def delete_item(self, item_id: UUID) -> ScheduleItem | None: try: stmt = ( update(ScheduleItem) .where(ScheduleItem.id == item_id) - .where(ScheduleItem.owner_id == owner_id) .where(ScheduleItem.deleted_at.is_(None)) .values(deleted_at=datetime.now(timezone.utc)) .returning(ScheduleItem) diff --git a/backend/src/v1/schedule_items/schemas.py b/backend/src/v1/schedule_items/schemas.py index 044c4fb..a494535 100644 --- a/backend/src/v1/schedule_items/schemas.py +++ b/backend/src/v1/schedule_items/schemas.py @@ -40,6 +40,7 @@ __all__ = [ "ScheduleItemListRequest", "ScheduleItemShareRequest", "ScheduleItemShareResponse", + "SubscriberInfo", ] @@ -104,6 +105,18 @@ class ScheduleItemUpdateRequest(BaseModel): return value +class SubscriberInfo(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True) + + user_id: UUID + username: str | None = None + avatar_url: str | None = None + phone: str | None = None + permission: int + status: str + subscribed_at: datetime + + class ScheduleItemResponse(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True) @@ -121,6 +134,7 @@ class ScheduleItemResponse(BaseModel): updated_at: datetime permission: int = 1 is_owner: bool = False + subscribers: list[SubscriberInfo] = [] class ScheduleItemListItem(BaseModel): diff --git a/backend/src/v1/schedule_items/service.py b/backend/src/v1/schedule_items/service.py index 27a986a..fbdf7c5 100644 --- a/backend/src/v1/schedule_items/service.py +++ b/backend/src/v1/schedule_items/service.py @@ -11,6 +11,7 @@ from core.db.base_service import BaseService from core.http.errors import ApiProblemError, problem_payload from core.logging import get_logger from models.inbox_messages import InboxMessage +from models.profile import Profile from models.schedule_items import ScheduleItem from schemas.enums import ( InboxMessageStatus, @@ -31,12 +32,14 @@ from v1.schedule_items.schemas import ( ScheduleItemUpdateRequest, ScheduleItemSourceType, ScheduleItemStatus, + SubscriberInfo, ) if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession - from v1.auth.schemas import UserByPhoneResponse + from v1.auth.schemas import UserByIdResponse, UserByPhoneResponse + from v1.users.repository import UserRepository logger = get_logger("v1.schedule_items.service") @@ -45,6 +48,7 @@ _LEGACY_ARCHIVED_STATUSES = {"completed", "canceled"} class AuthByPhoneGateway(Protocol): async def get_user_by_phone(self, phone: str) -> "UserByPhoneResponse": ... + async def get_user_by_id(self, user_id: str) -> "UserByIdResponse": ... class ScheduleItemService(BaseService): @@ -52,6 +56,7 @@ class ScheduleItemService(BaseService): _session: AsyncSession _auth_gateway: AuthByPhoneGateway _inbox_repository: InboxMessageRepository + _user_repository: UserRepository | None def __init__( self, @@ -60,6 +65,7 @@ class ScheduleItemService(BaseService): current_user: CurrentUser | None, auth_gateway: AuthByPhoneGateway | None = None, inbox_repository: InboxMessageRepository | None = None, + user_repository: UserRepository | None = None, ) -> None: super().__init__(current_user=current_user) self._repository = repository @@ -68,6 +74,7 @@ class ScheduleItemService(BaseService): if inbox_repository is None: raise ValueError("inbox_repository is required") self._inbox_repository = inbox_repository + self._user_repository = user_repository async def create(self, request: ScheduleItemCreateRequest) -> ScheduleItemResponse: return await self._create_with_source( @@ -166,13 +173,52 @@ class ScheduleItemService(BaseService): ) is_owner = item.owner_id == user_id - permission = 1 - if not is_owner: - subscription = await self._repository.get_subscription(item_id, user_id) - if subscription: - permission = subscription.permission + subscription = await self._repository.get_subscription(item_id, user_id) + permission = subscription.permission if subscription else 1 - return self._to_response(item, is_owner=is_owner, permission=permission) + subscriptions = await self._repository.get_subscriptions_by_item_id(item_id) + subscribers: list[SubscriberInfo] = [] + if subscriptions: + subscriber_ids = [sub.subscriber_id for sub in subscriptions] + profiles: dict[UUID, Profile] = {} + if self._user_repository and subscriber_ids: + try: + profiles = await self._user_repository.get_by_user_ids( + subscriber_ids + ) + except SQLAlchemyError: + logger.exception("Failed to get subscriber profiles") + for sub in subscriptions: + if sub.status == SubscriptionStatus.ACTIVE: + profile = profiles.get(sub.subscriber_id) + phone = None + try: + user_info = await self._auth_gateway.get_user_by_id( + str(sub.subscriber_id) + ) + phone = user_info.phone + except Exception: + logger.warning( + "Failed to get phone for subscriber", + subscriber_id=str(sub.subscriber_id), + ) + subscribers.append( + SubscriberInfo( + user_id=sub.subscriber_id, + username=profile.username if profile else None, + avatar_url=profile.avatar_url if profile else None, + phone=phone, + permission=sub.permission, + status=sub.status.value + if hasattr(sub.status, "value") + else str(sub.status), + subscribed_at=sub.created_at, + ) + ) + + return self._to_response( + item, is_owner=is_owner, permission=permission, subscribers=subscribers + ) async def update( self, item_id: UUID, request: ScheduleItemUpdateRequest @@ -180,7 +226,7 @@ class ScheduleItemService(BaseService): user_id = self.require_user_id() try: - existing = await self._repository.get_by_item_id(item_id, user_id) + existing = await self._repository.get_item(item_id) if existing is None: raise ApiProblemError( status_code=404, @@ -190,10 +236,32 @@ class ScheduleItemService(BaseService): ), ) - # Build update dict from non-null fields + is_owner = existing.owner_id == user_id + subscription = await self._repository.get_subscription(item_id, user_id) + + if not is_owner: + if ( + subscription is None + or subscription.status != SubscriptionStatus.ACTIVE + ): + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="SCHEDULE_ITEM_NOT_FOUND", + detail="Schedule item not found", + ), + ) + if not (subscription.permission & SubscriptionPermission.EDIT): + raise ApiProblemError( + status_code=403, + detail=problem_payload( + code="SCHEDULE_ITEM_FORBIDDEN", + detail="You do not have permission to edit this schedule item", + ), + ) + update_data = request.model_dump(exclude_unset=True) - # Handle metadata separately (model_dump returns dict) if "metadata" in update_data: metadata_value = update_data.pop("metadata") update_data["extra_metadata"] = ( @@ -202,7 +270,6 @@ class ScheduleItemService(BaseService): else metadata_value ) - # Validate time range next_start = update_data.get("start_at", existing.start_at) next_end = update_data.get("end_at", existing.end_at) if isinstance(next_start, datetime): @@ -230,11 +297,10 @@ class ScheduleItemService(BaseService): ) if not update_data: - return self._to_response(existing) + return self._to_response(existing, is_owner=is_owner) + + item = await self._repository.update_item(item_id, update_data) - item = await self._repository.update_by_item_id( - item_id, user_id, update_data - ) await self._notify_subscribers(item_id, existing.title, "updated") await self._session.commit() except SQLAlchemyError: @@ -257,13 +323,13 @@ class ScheduleItemService(BaseService): ), ) - return self._to_response(item) + return self._to_response(item, is_owner=is_owner) async def delete(self, item_id: UUID) -> None: user_id = self.require_user_id() try: - existing = await self._repository.get_by_item_id(item_id, user_id) + existing = await self._repository.get_item(item_id) if existing is None: raise ApiProblemError( status_code=404, @@ -273,10 +339,25 @@ class ScheduleItemService(BaseService): ), ) + is_owner = existing.owner_id == user_id + subscription = await self._repository.get_subscription(item_id, user_id) + + if not is_owner: + if subscription is None or not ( + subscription.permission & SubscriptionPermission.DELETE + ): + raise ApiProblemError( + status_code=403, + detail=problem_payload( + code="SCHEDULE_ITEM_FORBIDDEN", + detail="You do not have permission to delete this schedule item", + ), + ) + title = existing.title await self._repository.delete_subscriptions_by_item_id(item_id) await self._notify_subscribers(item_id, title, "deleted") - await self._repository.delete_by_item_id(item_id, user_id) + await self._repository.delete_item(item_id) await self._session.commit() except SQLAlchemyError: await self._session.rollback() @@ -539,6 +620,7 @@ class ScheduleItemService(BaseService): item: ScheduleItem, is_owner: bool = False, permission: int = 1, + subscribers: list[SubscriberInfo] | None = None, ) -> ScheduleItemResponse: status_value = ( item.status.value if hasattr(item.status, "value") else str(item.status) @@ -565,8 +647,9 @@ class ScheduleItemService(BaseService): source_type=ScheduleItemSourceType(str(source_type_value)), created_at=item.created_at, updated_at=item.updated_at, - permission=permission if not is_owner else 7, + permission=permission, is_owner=is_owner, + subscribers=subscribers or [], ) async def accept_subscription(self, item_id: UUID) -> dict: @@ -708,12 +791,5 @@ class ScheduleItemService(BaseService): def _to_utc_required(self, dt: datetime) -> datetime: normalized = self._to_utc(dt) - if normalized is None: - raise ApiProblemError( - status_code=400, - detail=problem_payload( - code="SCHEDULE_ITEM_DATETIME_REQUIRED", - detail="datetime is required", - ), - ) + assert normalized is not None, "datetime is required" return normalized diff --git a/docs/protocols/calendar/schedule-items.md b/docs/protocols/calendar/schedule-items.md index 4d8adb9..459cf7a 100644 --- a/docs/protocols/calendar/schedule-items.md +++ b/docs/protocols/calendar/schedule-items.md @@ -116,11 +116,31 @@ Base URL: `/api/v1/schedule-items` "source_type": "ScheduleItemSourceType", "created_at": "datetime", "updated_at": "datetime", - "permission": "int (位掩码: 1=view, 2=invite, 4=edit)", - "is_owner": "boolean" + "permission": "int (位掩码: 1=view, 2=invite, 4=edit, 8=delete, 15=owner)", + "is_owner": "boolean (当前用户是否为日程所有者)", + "subscribers": ["SubscriberInfo"] } ``` +### SubscriberInfo + +```json +{ + "user_id": "uuid", + "username": "string | null", + "avatar_url": "string | null", + "phone": "string | null", + "permission": "int (位掩码: 1=view, 2=invite, 4=edit, 8=delete, 15=owner)", + "status": "string (active | pending | unsubscribed)", + "subscribed_at": "datetime" +} +``` + +说明: +- `subscribers` 列表仅包含状态为 `active` 的订阅者 +- `phone` 字段来自 Supabase Auth,用于显示订阅者手机号 +- 前端显示优先级:`phone ?? username ?? userId` + ### ScheduleItemShareRequest ```json @@ -136,6 +156,8 @@ Base URL: `/api/v1/schedule-items` - `permission_view = 1` - `permission_invite = 2` - `permission_edit = 4` +- `permission_delete = 8` +- `permission_owner = 15` ### ScheduleItemShareResponse @@ -194,6 +216,11 @@ Base URL: `/api/v1/schedule-items` 更新日程(部分更新)。 +### Authorization + +- **Owner**: 可更新所有字段 +- **Subscriber (EDIT permission)**: 可更新所有字段(权限位掩码包含 `4`) + ### Path Parameters - `item_id`: 日程 UUID @@ -206,12 +233,24 @@ Base URL: `/api/v1/schedule-items` `ScheduleItemResponse` 对象。 +### Error Responses + +| Status | Code | 说明 | +|--------|------|------| +| 403 | `SCHEDULE_ITEM_FORBIDDEN` | 当前用户无权编辑此日程 | +| 404 | `SCHEDULE_ITEM_NOT_FOUND` | 日程不存在或用户既不是 owner 也没有订阅 | + --- ## 5) DELETE `/{item_id}` 删除日程。 +### Authorization + +- **Owner**: 可删除日程(权限位掩码包含 `8`) +- **Subscriber (DELETE permission)**: 可删除日程(权限位掩码包含 `8`) + ### Path Parameters - `item_id`: 日程 UUID @@ -220,6 +259,13 @@ Base URL: `/api/v1/schedule-items` 204 No Content。 +### Error Responses + +| Status | Code | 说明 | +|--------|------|------| +| 403 | `SCHEDULE_ITEM_FORBIDDEN` | 当前用户无权删除此日程 | +| 404 | `SCHEDULE_ITEM_NOT_FOUND` | 日程不存在或用户既不是 owner 也没有订阅 | + --- ## 6) POST `/{item_id}/share`